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

# Entry point of the BulkImport/Direct Transfer feature.
# This service receives a Gitlab Instance connection params
# and a list of groups or projects to be imported.
#
# Process topography:
#
#       sync      |   async
#                 |
#  User +--> P1 +----> Pn +---+
#                 |     ^     | Enqueue new job
#                 |     +-----+
#
# P1 (sync)
#
# - Create a BulkImport record
# - Create a BulkImport::Entity for each group or project (entities) to be imported
# - Enqueue a BulkImportWorker job (P2) to import the given entity
#
# Pn (async)
#
# - For each group to be imported (BulkImport::Entity.with_status(:created))
#   - Import the group data
#   - Create entities for each subgroup of the imported group
#   - Create entities for each project of the imported group
#   - Enqueue a BulkImportWorker job (Pn) to import the new entities

module BulkImports
  class CreateService
    ENTITY_TYPES_MAPPING = {
      'group_entity' => 'groups',
      'project_entity' => 'projects'
    }.freeze

    attr_reader :current_user, :params, :credentials

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

    def execute
      validate!

      bulk_import = create_bulk_import

      Gitlab::Tracking.event(
        self.class.name,
        'create',
        label: 'bulk_import_group',
        extra: { source_equals_destination: source_equals_destination? }
      )

      BulkImportWorker.perform_async(bulk_import.id)

      ServiceResponse.success(payload: bulk_import)

    rescue ActiveRecord::RecordInvalid, BulkImports::Error, BulkImports::NetworkError => e
      ServiceResponse.error(
        message: e.message,
        http_status: :unprocessable_entity
      )
    end

    private

    def validate!
      client.validate_instance_version!
      validate_setting_enabled!
      client.validate_import_scopes!
    end

    def create_bulk_import
      BulkImport.transaction do
        bulk_import = BulkImport.create!(
          user: current_user,
          source_type: 'gitlab',
          source_version: client.instance_version,
          source_enterprise: client.instance_enterprise
        )
        bulk_import.create_configuration!(credentials.slice(:url, :access_token))

        Array.wrap(params).each do |entity_params|
          track_access_level(entity_params)

          validate_destination_namespace(entity_params)
          validate_destination_slug(entity_params[:destination_slug] || entity_params[:destination_name])
          validate_destination_full_path(entity_params)

          BulkImports::Entity.create!(
            bulk_import: bulk_import,
            source_type: entity_params[:source_type],
            source_full_path: entity_params[:source_full_path],
            destination_slug: entity_params[:destination_slug] || entity_params[:destination_name],
            destination_namespace: entity_params[:destination_namespace],
            migrate_projects: Gitlab::Utils.to_boolean(entity_params[:migrate_projects], default: true)
          )
        end
        bulk_import
      end
    end

    def validate_setting_enabled!
      source_full_path, source_type = Array.wrap(params)[0].values_at(:source_full_path, :source_type)
      entity_type = ENTITY_TYPES_MAPPING.fetch(source_type)
      if /^[0-9]+$/.match?(source_full_path)
        query = query_type(entity_type)
        response = graphql_client.execute(
          graphql_client.parse(query.to_s),
          { full_path: source_full_path }
        ).original_hash

        source_entity_identifier = ::GlobalID.parse(response.dig(*query.data_path, 'id')).model_id
      else
        source_entity_identifier = ERB::Util.url_encode(source_full_path)
      end

      client.get("/#{entity_type}/#{source_entity_identifier}/export_relations/status")
    end

    def track_access_level(entity_params)
      Gitlab::Tracking.event(
        self.class.name,
        'create',
        label: 'import_access_level',
        user: current_user,
        extra: { user_role: user_role(entity_params[:destination_namespace]), import_type: 'bulk_import_group' }
      )
    end

    def source_equals_destination?
      credentials[:url].starts_with?(Settings.gitlab.base_url)
    end

    def validate_destination_namespace(entity_params)
      destination_namespace = entity_params[:destination_namespace]
      source_type = entity_params[:source_type]

      return if destination_namespace.blank?

      group = Group.find_by_full_path(destination_namespace)
      if group.nil? ||
          (source_type == 'group_entity' && !current_user.can?(:create_subgroup, group)) ||
          (source_type == 'project_entity' && !current_user.can?(:import_projects, group))
        raise BulkImports::Error.destination_namespace_validation_failure(destination_namespace)
      end
    end

    def validate_destination_slug(destination_slug)
      return if Gitlab::Regex.oci_repository_path_regex.match?(destination_slug)

      raise BulkImports::Error.destination_slug_validation_failure
    end

    def validate_destination_full_path(entity_params)
      source_type = entity_params[:source_type]

      full_path = [
        entity_params[:destination_namespace],
        entity_params[:destination_slug] || entity_params[:destination_name]
      ].reject(&:blank?).join('/')

      case source_type
      when 'group_entity'
        return if Namespace.find_by_full_path(full_path).nil?
      when 'project_entity'
        return if Project.find_by_full_path(full_path).nil?
      end

      raise BulkImports::Error.destination_full_path_validation_failure(full_path)
    end

    def user_role(destination_namespace)
      namespace = Namespace.find_by_full_path(destination_namespace)
      # if there is no parent namespace we assume user will be group creator/owner
      return owner_role unless destination_namespace
      return owner_role unless namespace
      return owner_role unless namespace.group_namespace? # user namespace

      membership = current_user.group_members.find_by(source_id: namespace.id) # rubocop:disable CodeReuse/ActiveRecord

      return 'Not a member' unless membership

      Gitlab::Access.human_access(membership.access_level)
    end

    def owner_role
      Gitlab::Access.human_access(Gitlab::Access::OWNER)
    end

    def client
      @client ||= BulkImports::Clients::HTTP.new(
        url: @credentials[:url],
        token: @credentials[:access_token]
      )
    end

    def graphql_client
      @graphql_client ||= BulkImports::Clients::Graphql.new(
        url: @credentials[:url],
        token: @credentials[:access_token]
      )
    end

    def query_type(entity_type)
      if entity_type == 'groups'
        BulkImports::Groups::Graphql::GetGroupQuery.new(context: nil)
      else
        BulkImports::Projects::Graphql::GetProjectQuery.new(context: nil)
      end
    end
  end
end

```

Response:
# frozen_string_literal: true

require 'spec_helper'

RSpec.describe BulkImports::CreateService, feature_category: :importers do
  let(:user) { create(:user) }
  let(:credentials) { { url: 'http://gitlab.example', access_token: 'token' } }
  let(:destination_group) { create(:group, path: 'destination1') }
  let(:migrate_projects) { true }
  let_it_be(:parent_group) { create(:group, path: 'parent-group') }
  # note: destination_name and destination_slug are currently interchangable so we need to test for both possibilities
  let(:params) do
    [
      {
        source_type: 'group_entity',
        source_full_path: 'full/path/to/group1',
        destination_slug: 'destination-group-1',
        destination_namespace: 'parent-group',
        migrate_projects: migrate_projects
      },
      {
        source_type: 'group_entity',
        source_full_path: 'full/path/to/group2',
        destination_name: 'destination-group-2',
        destination_namespace: 'parent-group',
        migrate_projects: migrate_projects
      },
      {
        source_type: 'project_entity',
        source_full_path: 'full/path/to/project1',
        destination_slug: 'destination-project-1',
        destination_namespace: 'parent-group',
        migrate_projects: migrate_projects
      }
    ]
  end

  let(:source_entity_identifier) { ERB::Util.url_encode(params[0][:source_full_path]) }
  let(:source_entity_type) { BulkImports::CreateService::ENTITY_TYPES_MAPPING.fetch(params[0][:source_type]) }

  subject { described_class.new(user, params, credentials) }

  describe '#execute' do
    context 'when gitlab version is 15.5 or higher' do
      let(:source_version) { { version: "15.6.0", enterprise: false } }

      context 'when a BulkImports::Error is raised while validating the instance version' do
        before do
          allow_next_instance_of(BulkImports::Clients::HTTP) do |client|
            allow(client)
              .to receive(:validate_instance_version!)
              .and_raise(BulkImports::Error, "This is a BulkImports error.")
          end
        end

        it 'rescues the error and raises a ServiceResponse::Error' do
          result = subject.execute

          expect(result).to be_a(ServiceResponse)
          expect(result).to be_error
          expect(result.message).to eq("This is a BulkImports error.")
        end
      end

      # response when authorize_admin_project in API endpoint fails
      context 'when direct transfer status query returns a 403' do
        it 'raises a ServiceResponse::Error' do
          expect_next_instance_of(BulkImports::Clients::HTTP) do |client|
            expect(client).to receive(:validate_instance_version!).and_return(true)
            expect(client).to receive(:get)
              .with("/groups/full%2Fpath%2Fto%2Fgroup1/export_relations/status")
              .and_raise(BulkImports::NetworkError, '403 Forbidden')
          end

          result = subject.execute

          expect(result).to be_a(ServiceResponse)
          expect(result).to be_error
          expect(result.message).to eq("403 Forbidden")
        end
      end

      context 'when direct transfer setting query returns a 404' do
        it 'raises a ServiceResponse::Error' do
          stub_request(:get, 'http://gitlab.example/api/v4/version?private_token=token').to_return(status: 404)
          stub_request(:get, 'http://gitlab.example/api/v4/metadata?private_token=token')
            .to_return(
              status: 200,
              body: source_version.to_json,
              headers: { 'Content-Type' => 'application/json' }
            )
          stub_request(:get, "http://gitlab.example/api/v4/#{source_entity_type}/#{source_entity_identifier}/export_relations/status?page=1&per_page=30&private_token=token")
            .to_return(status: 404)

          expect_next_instance_of(BulkImports::Clients::HTTP) do |client|
            expect(client).to receive(:get).and_raise(BulkImports::Error.setting_not_enabled)
          end

          result = subject.execute

          expect(result).to be_a(ServiceResponse)
          expect(result).to be_error
          expect(result.message)
            .to eq(
              "Group import disabled on source or destination instance. " \
              "Ask an administrator to enable it on both instances and try again."
            )
        end
      end

      context 'when required scopes are not present' do
        it 'returns ServiceResponse with error if token does not have api scope' do
          stub_request(:get, 'http://gitlab.example/api/v4/version?private_token=token').to_return(status: 404)
          stub_request(:get, 'http://gitlab.example/api/v4/metadata?private_token=token')
            .to_return(
              status: 200,
              body: source_version.to_json,
              headers: { 'Content-Type' => 'application/json' }
            )
          stub_request(:get, "http://gitlab.example/api/v4/#{source_entity_type}/#{source_entity_identifier}/export_relations/status?page=1&per_page=30&private_token=token")
            .to_return(
              status: 200
            )

          allow_next_instance_of(BulkImports::Clients::HTTP) do |client|
            allow(client).to receive(:validate_import_scopes!)
              .and_raise(BulkImports::Error.scope_or_url_validation_failure)
          end

          result = subject.execute

          expect(result).to be_a(ServiceResponse)
          expect(result).to be_error
          expect(result.message)
            .to eq(
              "Check that the source instance base URL and the personal access token meet the necessary requirements."
            )
        end
      end

      context 'when token validation succeeds' do
        before do
          stub_request(:get, 'http://gitlab.example/api/v4/version?private_token=token').to_return(status: 404)
          stub_request(:get, 'http://gitlab.example/api/v4/metadata?private_token=token')
            .to_return(status: 200, body: source_version.to_json, headers: { 'Content-Type' => 'application/json' })
          stub_request(:get, "http://gitlab.example/api/v4/#{source_entity_type}/#{source_entity_identifier}/export_relations/status?page=1&per_page=30&private_token=token")
            .to_return(
              status: 200
            )
          stub_request(:get, 'http://gitlab.example/api/v4/personal_access_tokens/self?private_token=token')
            .to_return(
              status: 200,
              body: { 'scopes' => ['api'] }.to_json,
              headers: { 'Content-Type' => 'application/json' }
            )

          parent_group.add_owner(user)
        end

        it 'creates bulk import' do
          expect { subject.execute }.to change { BulkImport.count }.by(1)

          last_bulk_import = BulkImport.last
          expect(last_bulk_import.user).to eq(user)
          expect(last_bulk_import.source_version).to eq(source_version[:version])
          expect(last_bulk_import.user).to eq(user)
          expect(last_bulk_import.source_enterprise).to eq(false)

          expect_snowplow_event(
            category: 'BulkImports::CreateService',
            action: 'create',
            label: 'bulk_import_group',
            extra: { source_equals_destination: false }
          )

          expect_snowplow_event(
            category: 'BulkImports::CreateService',
            action: 'create',
            label: 'import_access_level',
            user: user,
            extra: { user_role: 'Owner', import_type: 'bulk_import_group' }
          )
        end

        context 'on the same instance' do
          before do
            allow(Settings.gitlab).to receive(:base_url).and_return('http://gitlab.example')
          end

          it 'tracks the same instance migration' do
            expect { subject.execute }.to change { BulkImport.count }.by(1)

            expect_snowplow_event(
              category: 'BulkImports::CreateService',
              action: 'create',
              label: 'bulk_import_group',
              extra: { source_equals_destination: true }
            )
          end
        end

        describe 'projects migration flag' do
          let(:import) { BulkImport.last }

          context 'when false' do
            let(:migrate_projects) { false }

            it 'sets false' do
              subject.execute

              expect(import.entities.pluck(:migrate_projects)).to contain_exactly(false, false, false)
            end
          end

          context 'when true' do
            let(:migrate_projects) { true }

            it 'sets true' do
              subject.execute

              expect(import.entities.pluck(:migrate_projects)).to contain_exactly(true, true, true)
            end
          end

          context 'when nil' do
            let(:migrate_projects) { nil }

            it 'sets true' do
              subject.execute

              expect(import.entities.pluck(:migrate_projects)).to contain_exactly(true, true, true)
            end
          end
        end
      end
    end

    context 'when gitlab version is lower than 15.5' do
      let(:source_version) do
        Gitlab::VersionInfo.new(
          ::BulkImport::MIN_MAJOR_VERSION,
          ::BulkImport::MIN_MINOR_VERSION_FOR_PROJECT
        )
      end

      before do
        allow_next_instance_of(BulkImports::Clients::HTTP) do |instance|
          allow(instance).to receive(:instance_version).and_return(source_version)
          allow(instance).to receive(:instance_enterprise).and_return(false)
          stub_request(:get, "http://gitlab.example/api/v4/#{source_entity_type}/#{source_entity_identifier}/export_relations/status?page=1&per_page=30&private_token=token")
            .to_return(
              status: 200
            )
        end

        parent_group.add_owner(user)
      end

      it 'creates bulk import' do
        expect { subject.execute }.to change { BulkImport.count }.by(1)

        last_bulk_import = BulkImport.last

        expect(last_bulk_import.user).to eq(user)
        expect(last_bulk_import.source_version).to eq(source_version.to_s)
        expect(last_bulk_import.user).to eq(user)
        expect(last_bulk_import.source_enterprise).to eq(false)

        expect_snowplow_event(
          category: 'BulkImports::CreateService',
          action: 'create',
          label: 'bulk_import_group',
          extra: { source_equals_destination: false }
        )

        expect_snowplow_event(
          category: 'BulkImports::CreateService',
          action: 'create',
          label: 'import_access_level',
          user: user,
          extra: { user_role: 'Owner', import_type: 'bulk_import_group' }
        )
      end

      context 'on the same instance' do
        before do
          allow(Settings.gitlab).to receive(:base_url).and_return('http://gitlab.example')
        end

        it 'tracks the same instance migration' do
          expect { subject.execute }.to change { BulkImport.count }.by(1)

          expect_snowplow_event(
            category: 'BulkImports::CreateService',
            action: 'create',
            label: 'bulk_import_group',
            extra: { source_equals_destination: true }
          )
        end
      end

      it 'creates bulk import entities' do
        expect { subject.execute }.to change { BulkImports::Entity.count }.by(3)
      end

      it 'creates bulk import configuration' do
        expect { subject.execute }.to change { BulkImports::Configuration.count }.by(1)
      end

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

        subject.execute
      end

      it 'returns success ServiceResponse' do
        result = subject.execute

        expect(result).to be_a(ServiceResponse)
        expect(result).to be_success
      end

      it 'returns ServiceResponse with error if validation fails' do
        params[0][:source_full_path] = nil

        result = subject.execute

        expect(result).to be_a(ServiceResponse)
        expect(result).to be_error
        expect(result.message).to eq("Validation failed: Source full path can't be blank, " \
                                     "Source full path must have a relative path structure with " \
                                     "no HTTP protocol characters, or leading or trailing forward slashes. " \
                                     "Path segments must not start or end with a special character, and " \
                                     "must not contain consecutive special characters")
      end

      describe '#user-role' do
        context 'when there is a parent_namespace and the user is a member' do
          let(:group2) { create(:group, path: 'destination200', source_id: parent_group.id ) }
          let(:params) do
            [
              {
                source_type: 'group_entity',
                source_full_path: 'full/path/to/group1',
                destination_slug: 'destination200',
                destination_namespace: 'parent-group'
              }
            ]
          end

          it 'defines access_level from parent namespace membership' do
            parent_group.add_guest(user)
            subject.execute

            expect_snowplow_event(
              category: 'BulkImports::CreateService',
              action: 'create',
              label: 'import_access_level',
              user: user,
              extra: { user_role: 'Guest', import_type: 'bulk_import_group' }
            )
          end
        end

        it 'defines access_level as not a member' do
          parent_group.members.delete_all

          subject.execute
          expect_snowplow_event(
            category: 'BulkImports::CreateService',
            action: 'create',
            label: 'import_access_level',
            user: user,
            extra: { user_role: 'Not a member', import_type: 'bulk_import_group' }
          )
        end

        context 'when there is a destination_namespace but no parent_namespace' do
          let(:params) do
            [
              {
                source_type: 'group_entity',
                source_full_path: 'full/path/to/group1',
                destination_slug: 'destination-group-1',
                destination_namespace: 'destination1'
              }
            ]
          end

          it 'defines access_level from destination_namespace' do
            destination_group.add_developer(user)
            subject.execute

            expect_snowplow_event(
              category: 'BulkImports::CreateService',
              action: 'create',
              label: 'import_access_level',
              user: user,
              extra: { user_role: 'Developer', import_type: 'bulk_import_group' }
            )
          end
        end

        context 'when there is no destination_namespace or parent_namespace' do
          let(:params) do
            [
              {
                source_type: 'group_entity',
                source_full_path: 'full/path/to/group1',
                destination_slug: 'destinationational-mcdestiny',
                destination_namespace: 'destinational-mcdestiny'
              }
            ]
          end

          it 'defines access_level as owner' do
            subject.execute

            expect_snowplow_event(
              category: 'BulkImports::CreateService',
              action: 'create',
              label: 'import_access_level',
              user: user,
              extra: { user_role: 'Owner', import_type: 'bulk_import_group' }
            )
          end
        end
      end

      describe '#validate_setting_enabled!' do
        let(:entity_source_id) { 'gid://gitlab/Model/12345' }
        let(:graphql_client) { instance_double(BulkImports::Clients::Graphql) }
        let(:http_client) { instance_double(BulkImports::Clients::HTTP) }
        let(:http_response) { double(code: 200, success?: true) } # rubocop:disable RSpec/VerifiedDoubles

        before do
          allow(BulkImports::Clients::HTTP).to receive(:new).and_return(http_client)
          allow(BulkImports::Clients::Graphql).to receive(:new).and_return(graphql_client)

          allow(http_client).to receive(:instance_version).and_return(status: 200)
          allow(http_client).to receive(:instance_enterprise).and_return(false)
          allow(http_client).to receive(:validate_instance_version!).and_return(source_version)
          allow(http_client).to receive(:validate_import_scopes!).and_return(true)
        end

        context 'when the source_type is a group' do
          context 'when the source_full_path contains only integer characters' do
            let(:query_string) { BulkImports::Groups::Graphql::GetGroupQuery.new(context: nil).to_s }
            let(:graphql_response) do
              double(original_hash: { 'data' => { 'group' => { 'id' => entity_source_id } } }) # rubocop:disable RSpec/VerifiedDoubles
            end

            let(:params) do
              [
                {
                  source_type: 'group_entity',
                  source_full_path: '67890',
                  destination_slug: 'destination-group-1',
                  destination_namespace: 'destination1'
                }
              ]
            end

            before do
              allow(graphql_client).to receive(:parse).with(query_string)
              allow(graphql_client).to receive(:execute).and_return(graphql_response)

              allow(http_client).to receive(:get)
                .with("/groups/12345/export_relations/status")
                .and_return(http_response)

              stub_request(:get, "http://gitlab.example/api/v4/groups/12345/export_relations/status?page=1&per_page=30&private_token=token")
                  .to_return(status: 200, body: "", headers: {})
            end

            it 'makes a graphql request using the group full path and an http request with the correct id' do
              expect(graphql_client).to receive(:parse).with(query_string)
              expect(graphql_client).to receive(:execute).and_return(graphql_response)

              expect(http_client).to receive(:get).with("/groups/12345/export_relations/status")

              subject.execute
            end
          end
        end

        context 'when the source_type is a project' do
          context 'when the source_full_path contains only integer characters' do
            let(:query_string) { BulkImports::Projects::Graphql::GetProjectQuery.new(context: nil).to_s }
            let(:graphql_response) do
              double(original_hash: { 'data' => { 'project' => { 'id' => entity_source_id } } }) # rubocop:disable RSpec/VerifiedDoubles
            end

            let(:params) do
              [
                {
                  source_type: 'project_entity',
                  source_full_path: '67890',
                  destination_slug: 'destination-group-1',
                  destination_namespace: 'destination1'
                }
              ]
            end

            before do
              allow(graphql_client).to receive(:parse).with(query_string)
              allow(graphql_client).to receive(:execute).and_return(graphql_response)

              allow(http_client).to receive(:get)
                .with("/projects/12345/export_relations/status")
                .and_return(http_response)

              stub_request(:get, "http://gitlab.example/api/v4/projects/12345/export_relations/status?page=1&per_page=30&private_token=token")
                  .to_return(status: 200, body: "", headers: {})
            end

            it 'makes a graphql request using the group full path and an http request with the correct id' do
              expect(graphql_client).to receive(:parse).with(query_string)
              expect(graphql_client).to receive(:execute).and_return(graphql_response)

              expect(http_client).to receive(:get).with("/projects/12345/export_relations/status")

              subject.execute
            end
          end
        end
      end

      describe '#validate_destination_namespace' do
        context 'when the destination_namespace does not exist' do
          let(:params) do
            [
              {
                source_type: 'group_entity',
                source_full_path: 'full/path/to/source',
                destination_slug: 'destination-slug',
                destination_namespace: 'destination-namespace',
                migrate_projects: migrate_projects
              }
            ]
          end

          it 'returns ServiceResponse with an error message' do
            result = subject.execute

            expect(result).to be_a(ServiceResponse)
            expect(result).to be_error
            expect(result.message)
              .to eq("Import failed. Destination 'destination-namespace' is invalid, " \
                     "or you don't have permission.")
          end
        end

        context 'when the user does not have permission to create subgroups' do
          let(:params) do
            [
              {
                source_type: 'group_entity',
                source_full_path: 'full/path/to/source',
                destination_slug: 'destination-slug',
                destination_namespace: parent_group.path,
                migrate_projects: migrate_projects
              }
            ]
          end

          it 'returns ServiceResponse with an error message' do
            parent_group.members.delete_all

            result = subject.execute

            expect(result).to be_a(ServiceResponse)
            expect(result).to be_error
            expect(result.message)
            .to eq("Import failed. Destination '#{parent_group.path}' is invalid, " \
                   "or you don't have permission.")
          end
        end

        context 'when the user does not have permission to create projects' do
          let(:params) do
            [
              {
                source_type: 'project_entity',
                source_full_path: 'full/path/to/source',
                destination_slug: 'destination-slug',
                destination_namespace: parent_group.path,
                migrate_projects: migrate_projects
              }
            ]
          end

          it 'returns ServiceResponse with an error message' do
            parent_group.members.delete_all

            result = subject.execute

            expect(result).to be_a(ServiceResponse)
            expect(result).to be_error
            expect(result.message)
              .to eq("Import failed. Destination '#{parent_group.path}' is invalid, " \
                     "or you don't have permission.")
          end
        end
      end

      describe '#validate_destination_slug' do
        context 'when the destination_slug is invalid' do
          let(:params) do
            [
              {
                source_type: 'group_entity',
                source_full_path: 'full/path/to/source',
                destination_slug: 'destin-*-ation-slug',
                destination_namespace: parent_group.path,
                migrate_projects: migrate_projects
              }
            ]
          end

          it 'returns ServiceResponse with an error message' do
            result = subject.execute

            expect(result).to be_a(ServiceResponse)
            expect(result).to be_error
            expect(result.message)
              .to eq(
                "Import failed. Destination URL " \
                "must not start or end with a special character and must " \
                "not contain consecutive special characters."
              )
          end
        end
      end

      describe '#validate_destination_full_path' do
        context 'when the source_type is a group' do
          context 'when the provided destination_slug already exists in the destination_namespace' do
            let_it_be(:existing_subgroup) { create(:group, path: 'existing-subgroup', parent_id: parent_group.id ) }
            let_it_be(:existing_subgroup_2) { create(:group, path: 'existing-subgroup_2', parent_id: parent_group.id ) }
            let(:params) do
              [
                {
                  source_type: 'group_entity',
                  source_full_path: 'full/path/to/source',
                  destination_slug: existing_subgroup.path,
                  destination_namespace: parent_group.path,
                  migrate_projects: migrate_projects
                }
              ]
            end

            it 'returns ServiceResponse with an error message' do
              result = subject.execute

              expect(result).to be_a(ServiceResponse)
              expect(result).to be_error
              expect(result.message)
                .to eq(
                  "Import failed. 'parent-group/existing-subgroup' already exists. " \
                  "Change the destination and try again."
                )
            end
          end

          context 'when the destination_slug conflicts with an existing top-level namespace' do
            let_it_be(:existing_top_level_group) { create(:group, path: 'top-level-group') }
            let(:params) do
              [
                {
                  source_type: 'group_entity',
                  source_full_path: 'full/path/to/source',
                  destination_slug: existing_top_level_group.path,
                  destination_namespace: '',
                  migrate_projects: migrate_projects
                }
              ]
            end

            it 'returns ServiceResponse with an error message' do
              result = subject.execute

              expect(result).to be_a(ServiceResponse)
              expect(result).to be_error
              expect(result.message)
                .to eq(
                  "Import failed. 'top-level-group' already exists. " \
                  "Change the destination and try again."
                )
            end
          end

          context 'when the destination_slug does not conflict with an existing top-level namespace' do
            let(:params) do
              [
                {
                  source_type: 'group_entity',
                  source_full_path: 'full/path/to/source',
                  destination_slug: 'new-group',
                  destination_namespace: parent_group.path,
                  migrate_projects: migrate_projects
                }
              ]
            end

            it 'returns success ServiceResponse' do
              result = subject.execute

              expect(result).to be_a(ServiceResponse)
              expect(result).to be_success
            end
          end
        end

        context 'when the source_type is a project' do
          context 'when the provided destination_slug already exists in the destination_namespace' do
            let_it_be(:existing_group) { create(:group, path: 'existing-group' ) }
            let_it_be(:existing_project) { create(:project, path: 'existing-project', parent_id: existing_group.id ) }
            let(:params) do
              [
                {
                  source_type: 'project_entity',
                  source_full_path: 'full/path/to/source',
                  destination_slug: existing_project.path,
                  destination_namespace: existing_group.path,
                  migrate_projects: migrate_projects
                }
              ]
            end

            it 'returns ServiceResponse with an error message' do
              existing_group.add_owner(user)

              result = subject.execute

              expect(result).to be_a(ServiceResponse)
              expect(result).to be_error
              expect(result.message)
                .to eq(
                  "Import failed. 'existing-group/existing-project' already exists. " \
                  "Change the destination and try again."
                )
            end
          end

          context 'when the destination_slug does not conflict with an existing project' do
            let_it_be(:existing_group) { create(:group, path: 'existing-group' ) }
            let(:params) do
              [
                {
                  source_type: 'project_entity',
                  source_full_path: 'full/path/to/source',
                  destination_slug: 'new-project',
                  destination_namespace: 'existing-group',
                  migrate_projects: migrate_projects
                }
              ]
            end

            it 'returns success ServiceResponse' do
              existing_group.add_owner(user)

              result = subject.execute

              expect(result).to be_a(ServiceResponse)
              expect(result).to be_success
            end
          end
        end
      end
    end
  end
end


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

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

    EXISTING_EXPORT_TTL = 3.minutes

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

    def execute
      find_or_create_export! do |export|
        export.remove_existing_upload!
        export_service.execute
        ensure_export_file_exists!
        compress_exported_relation
        upload_compressed_file(export)
      end
    ensure
      FileUtils.remove_entry(export_path)
    end

    private

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

    delegate :export_path, to: :config

    def find_or_create_export!
      export = portable.bulk_import_exports.safe_find_or_create_by!(relation: relation)

      return export if export.finished? && export.updated_at > EXISTING_EXPORT_TTL.ago && !export.batched?

      start_export!(export)

      yield export

      finish_export!(export)
    end

    def export_service
      @export_service ||= if config.tree_relation?(relation) || config.self_relation?(relation)
                            TreeExportService.new(portable, export_path, relation, user)
                          elsif config.file_relation?(relation)
                            FileExportService.new(portable, export_path, relation, user)
                          else
                            raise BulkImports::Error, 'Unsupported export relation'
                          end
    end

    def upload_compressed_file(export)
      compressed_file = File.join(export_path, "#{export_service.exported_filename}.gz")

      upload = ExportUpload.find_or_initialize_by(export_id: export.id) # rubocop: disable CodeReuse/ActiveRecord

      File.open(compressed_file) { |file| upload.export_file = file }

      upload.save!
    end

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

    def start_export!(export)
      export.update!(
        status_event: 'start',
        jid: jid,
        batched: false,
        batches_count: 0,
        total_objects_count: 0,
        error: nil
      )

      export.batches.destroy_all if export.batches.any? # rubocop:disable Cop/DestroyAll
    end

    def finish_export!(export)
      export.update!(status_event: 'finish', batched: false, error: nil)
    end

    def exported_filepath
      File.join(export_path, export_service.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::RelationExportService, feature_category: :importers do
  let_it_be(:jid) { 'jid' }
  let_it_be(:relation) { 'labels' }
  let_it_be(:user) { create(:user) }
  let_it_be(:group) { create(:group) }
  let_it_be(:project) { create(:project) }
  let_it_be(:label) { create(:group_label, group: group) }
  let_it_be(:export_path) { "#{Dir.tmpdir}/relation_export_service_spec/tree" }
  let_it_be_with_reload(:export) { create(:bulk_import_export, group: group, relation: relation) }

  before do
    FileUtils.mkdir_p(export_path)

    group.add_owner(user)
    project.add_maintainer(user)

    allow(subject).to receive(:export_path).and_return(export_path)
  end

  after :all do
    FileUtils.rm_rf(export_path)
  end

  subject { described_class.new(user, group, relation, jid) }

  describe '#execute' do
    it 'exports specified relation and marks export as finished' do
      expect_next_instance_of(BulkImports::TreeExportService) do |service|
        expect(service).to receive(:execute).and_call_original
      end

      subject.execute

      expect(export.reload.upload.export_file).to be_present
      expect(export.finished?).to eq(true)
      expect(export.batched?).to eq(false)
      expect(export.batches_count).to eq(0)
      expect(export.batches.count).to eq(0)
      expect(export.total_objects_count).to eq(0)
    end

    it 'removes temp export files' do
      subject.execute

      expect(Dir.exist?(export_path)).to eq(false)
    end

    it 'exports specified relation and marks export as finished' do
      subject.execute

      expect(export.upload.export_file).to be_present
    end

    context 'when relation is empty and there is nothing to export' do
      let(:relation) { 'milestones' }

      it 'creates empty file on disk' do
        expect(FileUtils).to receive(:touch).with("#{export_path}/#{relation}.ndjson").and_call_original

        subject.execute
      end
    end

    context 'when exporting a file relation' do
      it 'uses file export service' do
        service = described_class.new(user, project, 'uploads', jid)

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

        service.execute
      end
    end

    context 'when export record does not exist' do
      let(:another_group) { create(:group) }

      subject { described_class.new(user, another_group, relation, jid) }

      it 'creates export record' do
        another_group.add_owner(user)

        expect { subject.execute }
          .to change { another_group.bulk_import_exports.count }
          .from(0)
          .to(1)
      end
    end

    context 'when there is existing export present' do
      let(:upload) { create(:bulk_import_export_upload, export: export) }

      it 'removes existing export before exporting' do
        upload.update!(export_file: fixture_file_upload('spec/fixtures/bulk_imports/gz/labels.ndjson.gz'))

        expect_any_instance_of(BulkImports::ExportUpload) do |upload|
          expect(upload).to receive(:remove_export_file!)
        end

        subject.execute
      end

      context 'when export is recently finished' do
        it 'returns recently finished export instead of re-exporting' do
          updated_at = 5.seconds.ago
          export.update!(status: 1, updated_at: updated_at)

          expect { subject.execute }.not_to change { export.updated_at }

          expect(export.status).to eq(1)
          expect(export.updated_at).to eq(updated_at)
        end
      end
    end

    context 'when export was batched' do
      let(:relation) { 'milestones' }
      let(:export) { create(:bulk_import_export, group: group, relation: relation, batched: true, batches_count: 2) }

      it 'removes existing batches and marks export as not batched' do
        create(:bulk_import_export_batch, batch_number: 1, export: export)
        create(:bulk_import_export_batch, batch_number: 2, export: export)

        expect { described_class.new(user, group, relation, jid).execute }
          .to change { export.reload.batches.count }
          .from(2)
          .to(0)

        expect(export.batched?).to eq(false)
        expect(export.batches_count).to eq(0)
      end
    end
  end
end


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

module BulkImports
  class GetImportableDataService
    def initialize(params, query_params, credentials)
      @params = params
      @query_params = query_params
      @credentials = credentials
    end

    def execute
      {
        version_validation: version_validation,
        response: importables
      }
    end

    private

    def importables
      client.get('groups', @query_params)
    end

    def version_validation
      {
        features: {
          project_migration: {
            available: client.compatible_for_project_migration?,
            min_version: BulkImport.min_gl_version_for_project_migration.to_s
          },
          source_instance_version: client.instance_version.to_s
        }
      }
    end

    def client
      @client ||= BulkImports::Clients::HTTP.new(
        url: @credentials[:url],
        token: @credentials[:access_token],
        per_page: @params[:per_page],
        page: @params[:page]
      )
    end
  end
end

```

Response:
# frozen_string_literal: true

require 'spec_helper'

RSpec.describe BulkImports::GetImportableDataService, feature_category: :importers do
  describe '#execute' do
    include_context 'bulk imports requests context', 'https://gitlab.example.com'

    let_it_be(:params) { { per_page: 20, page: 1 } }
    let_it_be(:query_params) { { top_level_only: true, min_access_level: 50, search: '' } }
    let_it_be(:credentials) { { url: 'https://gitlab.example.com', access_token: 'demo-pat' } }
    let_it_be(:expected_version_validation) do
      {
        features: {
          project_migration: {
            available: true,
            min_version: BulkImport.min_gl_version_for_project_migration.to_s
          },
          'source_instance_version': BulkImport.min_gl_version_for_project_migration.to_s
        }
      }
    end

    let_it_be(:expected_parsed_response) do
      [
        {
          'id' => 2595438,
          'web_url' => 'https://gitlab.com/groups/auto-breakfast',
          'name' => 'Stub',
          'path' => 'stub-group',
          'full_name' => 'Stub',
          'full_path' => 'stub-group'
        }
      ]
    end

    let(:source_version) do
      Gitlab::VersionInfo.new(
        ::BulkImport::MIN_MAJOR_VERSION,
        ::BulkImport::MIN_MINOR_VERSION_FOR_PROJECT
      )
    end

    before do
      allow_next_instance_of(BulkImports::Clients::HTTP) do |instance|
        allow(instance).to receive(:instance_version).and_return(source_version)
        allow(instance).to receive(:instance_enterprise).and_return(false)
      end
    end

    subject do
      described_class.new(params, query_params, credentials).execute
    end

    it 'returns version_validation and a response' do
      expect(subject[:version_validation]).to eq(expected_version_validation)
      expect(subject[:response].parsed_response).to eq(expected_parsed_response)
    end
  end
end


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

# File Decompression Service allows gzipped files decompression into tmp directory.
#
# @param tmpdir [String] Temp directory to store downloaded file to. Must be located under `Dir.tmpdir`.
# @param filename [String] Name of the file to decompress.
module BulkImports
  class FileDecompressionService
    include Gitlab::ImportExport::CommandLineUtil

    ServiceError = Class.new(StandardError)

    def initialize(tmpdir:, filename:)
      @tmpdir = tmpdir
      @filename = filename
      @filepath = File.join(@tmpdir, @filename)
      @decompressed_filename = File.basename(@filename, '.gz')
      @decompressed_filepath = File.join(@tmpdir, @decompressed_filename)
    end

    def execute
      validate_tmpdir
      validate_filepath
      validate_decompressed_file_size if Feature.enabled?(:validate_import_decompressed_archive_size)
      validate_symlink(filepath)

      decompress_file

      validate_symlink(decompressed_filepath)

      filepath
    rescue StandardError => e
      File.delete(filepath) if File.exist?(filepath)
      File.delete(decompressed_filepath) if File.exist?(decompressed_filepath)

      raise e
    end

    private

    attr_reader :tmpdir, :filename, :filepath, :decompressed_filename, :decompressed_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_decompressed_file_size
      raise(ServiceError, 'File decompression error') unless size_validator.valid?
    end

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

    def decompress_file
      gunzip(dir: tmpdir, filename: filename)
    end

    def size_validator
      @size_validator ||= Gitlab::ImportExport::DecompressedArchiveSizeValidator.new(archive_path: filepath)
    end
  end
end

```

Response:
# frozen_string_literal: true

require 'spec_helper'

RSpec.describe BulkImports::FileDecompressionService, feature_category: :importers do
  using RSpec::Parameterized::TableSyntax

  let_it_be(:tmpdir) { Dir.mktmpdir }
  let_it_be(:ndjson_filename) { 'labels.ndjson' }
  let_it_be(:ndjson_filepath) { File.join(tmpdir, ndjson_filename) }
  let_it_be(:gz_filename) { "#{ndjson_filename}.gz" }
  let_it_be(:gz_filepath) { "spec/fixtures/bulk_imports/gz/#{gz_filename}" }

  before do
    FileUtils.copy_file(gz_filepath, File.join(tmpdir, gz_filename))
    FileUtils.remove_entry(ndjson_filepath) if File.exist?(ndjson_filepath)
  end

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

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

  describe '#execute' do
    it 'decompresses specified file' do
      subject.execute

      expect(File.exist?(File.join(tmpdir, ndjson_filename))).to eq(true)
      expect(File.open(ndjson_filepath, &:readline)).to include('title', 'description')
    end

    context 'when validate_import_decompressed_archive_size feature flag is enabled' do
      before do
        stub_feature_flags(validate_import_decompressed_archive_size: true)
      end

      it 'performs decompressed file size validation' do
        expect_next_instance_of(Gitlab::ImportExport::DecompressedArchiveSizeValidator) do |validator|
          expect(validator).to receive(:valid?).and_return(true)
        end

        subject.execute
      end
    end

    context 'when validate_import_decompressed_archive_size feature flag is disabled' do
      before do
        stub_feature_flags(validate_import_decompressed_archive_size: false)
      end

      it 'does not perform decompressed file size validation' do
        expect(Gitlab::ImportExport::DecompressedArchiveSizeValidator).not_to receive(:new)

        subject.execute
      end
    end

    context 'when dir is not in tmpdir' do
      subject { described_class.new(tmpdir: '/etc', filename: 'filename') }

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

    context 'when path is being traversed' do
      subject { described_class.new(tmpdir: File.join(Dir.mktmpdir, 'test', '..'), filename: 'filename') }

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

    shared_examples 'raises an error and removes the file' do |error_message:|
      specify do
        expect { subject.execute }
          .to raise_error(BulkImports::FileDecompressionService::ServiceError, error_message)
        expect(File).not_to exist(file)
      end
    end

    shared_context 'when compressed file' do
      let_it_be(:file) { File.join(tmpdir, 'file.gz') }

      subject { described_class.new(tmpdir: tmpdir, filename: 'file.gz') }

      before do
        FileUtils.send(link_method, File.join(tmpdir, gz_filename), file)
      end
    end

    shared_context 'when decompressed file' do
      let_it_be(:file) { File.join(tmpdir, 'file.txt') }

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

      before do
        original_file = File.join(tmpdir, 'original_file.txt')
        FileUtils.touch(original_file)
        FileUtils.send(link_method, original_file, file)

        subject.instance_variable_set(:@decompressed_filepath, file)
      end
    end

    context 'when compressed file is a symlink' do
      let(:link_method) { :symlink }

      include_context 'when compressed file'

      include_examples 'raises an error and removes the file', error_message: 'File decompression error'
    end

    context 'when compressed file shares multiple hard links' do
      let(:link_method) { :link }

      include_context 'when compressed file'

      include_examples 'raises an error and removes the file', error_message: 'File decompression error'
    end

    context 'when decompressed file is a symlink' do
      let(:link_method) { :symlink }

      include_context 'when decompressed file'

      include_examples 'raises an error and removes the file', error_message: 'Invalid file'
    end

    context 'when decompressed file shares multiple hard links' do
      let(:link_method) { :link }

      include_context 'when decompressed file'

      include_examples 'raises an error and removes the file', error_message: 'Invalid file'
    end
  end
end


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

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

    delegate :exported_objects_count, to: :serializer

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

    def execute
      if self_relation?(relation)
        serializer.serialize_root(config.class::SELF_RELATION)
      else
        serializer.serialize_relation(relation_definition)
      end
    end

    def export_batch(ids)
      serializer.serialize_relation(relation_definition, batch_ids: Array.wrap(ids))
    end

    def exported_filename
      "#{relation}.#{extension}"
    end

    private

    delegate :self_relation?, to: :config

    attr_reader :export_path, :portable, :relation, :config, :user

    # rubocop: disable CodeReuse/Serializer
    def serializer
      @serializer ||= ::Gitlab::ImportExport::Json::StreamingSerializer.new(
        portable,
        config.portable_tree,
        ::Gitlab::ImportExport::Json::NdjsonWriter.new(export_path),
        exportable_path: '',
        current_user: user
      )
    end
    # rubocop: enable CodeReuse/Serializer

    def extension
      return 'json' if self_relation?(relation)

      'ndjson'
    end

    def relation_definition
      definition = config.tree_relation_definition_for(relation)

      raise BulkImports::Error, 'Unsupported relation export type' unless definition

      definition
    end
    strong_memoize_attr :relation_definition
  end
end

```

Response:
# frozen_string_literal: true

require 'spec_helper'

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

  let(:relation) { 'issues' }

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

  describe '#execute' do
    it 'executes export service and archives exported data' do
      expect_next_instance_of(Gitlab::ImportExport::Json::StreamingSerializer) do |serializer|
        expect(serializer).to receive(:serialize_relation)
      end

      subject.execute
    end

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

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

    context 'when relation is self' do
      let(:relation) { 'self' }

      it 'executes export on portable itself' do
        expect_next_instance_of(Gitlab::ImportExport::Json::StreamingSerializer) do |serializer|
          expect(serializer).to receive(:serialize_root)
        end

        subject.execute
      end
    end
  end

  describe '#exported_filename' do
    it 'returns filename of the exported file' do
      expect(subject.exported_filename).to eq('issues.ndjson')
    end

    context 'when relation is self' do
      let(:relation) { 'self' }

      it 'returns filename of the exported file' do
        expect(subject.exported_filename).to eq('self.json')
      end
    end
  end

  describe '#export_batch' do
    it 'serializes relation with specified ids' do
      expect_next_instance_of(Gitlab::ImportExport::Json::StreamingSerializer) do |serializer|
        expect(serializer).to receive(:serialize_relation).with(anything, batch_ids: [1, 2, 3])
      end

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


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

TopicStatusUpdater =
  Struct.new(:topic, :user) do
    def update!(status, enabled, opts = {})
      status = Status.new(status, enabled)

      @topic_timer = topic.public_topic_timer

      updated = nil
      Topic.transaction do
        updated = change(status, opts)
        if updated
          highest_post_number = topic.highest_post_number
          create_moderator_post_for(status, opts)
          update_read_state_for(status, highest_post_number)
        end
      end

      updated
    end

    private

    def change(status, opts = {})
      result = true

      if status.pinned? || status.pinned_globally?
        topic.update_pinned(status.enabled?, status.pinned_globally?, opts[:until])
      elsif status.autoclosed?
        rc = Topic.where(id: topic.id, closed: !status.enabled?).update_all(closed: status.enabled?)
        topic.closed = status.enabled?
        result = false if rc == 0
      else
        rc =
          Topic.where(:id => topic.id, status.name => !status.enabled).update_all(
            status.name => status.enabled?,
          )

        topic.public_send("#{status.name}=", status.enabled?)
        result = false if rc == 0
      end

      DiscourseEvent.trigger(:topic_closed, topic) if status.manually_closing_topic?

      if status.visible? && status.disabled?
        UserProfile.remove_featured_topic_from_all_profiles(topic)
      end

      if status.visible? && result
        topic.update_category_topic_count_by(status.enabled? ? 1 : -1)
        UserStatCountUpdater.public_send(
          status.enabled? ? :increment! : :decrement!,
          topic.first_post,
        )
      end

      if @topic_timer
        if status.manually_closing_topic? || status.closing_topic?
          topic.delete_topic_timer(TopicTimer.types[:close])
          topic.delete_topic_timer(TopicTimer.types[:silent_close])
        elsif status.manually_opening_topic? || status.opening_topic?
          topic.delete_topic_timer(TopicTimer.types[:open])
          topic.inherit_auto_close_from_category
        end
      end

      # remove featured topics if we close/archive/make them invisible. Previously we used
      # to run the whole featuring logic but that could be very slow and have concurrency
      # errors on large sites with many autocloses and topics being created.
      if (
           (status.enabled? && (status.autoclosed? || status.closed? || status.archived?)) ||
             (status.disabled? && status.visible?)
         )
        CategoryFeaturedTopic.where(topic_id: topic.id).delete_all
      end

      result
    end

    def create_moderator_post_for(status, opts)
      message = opts[:message]
      topic.add_moderator_post(user, message || message_for(status), options_for(status, opts))
      topic.reload
    end

    def update_read_state_for(status, old_highest_read)
      if status.autoclosed? && status.enabled?
        # let's pretend all the people that read up to the autoclose message
        # actually read the topic
        PostTiming.pretend_read(topic.id, old_highest_read, topic.highest_post_number)
      end
    end

    def message_for(status)
      if status.autoclosed?
        locale_key = status.locale_key.dup
        locale_key << "_lastpost" if @topic_timer&.based_on_last_post
        message_for_autoclosed(locale_key)
      end
    end

    def message_for_autoclosed(locale_key)
      num_minutes =
        if @topic_timer&.based_on_last_post
          (@topic_timer.duration_minutes || 0).minutes.to_i
        elsif @topic_timer&.created_at
          Time.zone.now - @topic_timer.created_at
        else
          Time.zone.now - topic.created_at
        end

      # all of the results above are in seconds, this brings them
      # back to the actual minutes integer
      num_minutes = (num_minutes / 1.minute).round

      if num_minutes.minutes >= 2.days
        I18n.t("#{locale_key}_days", count: (num_minutes.minutes / 1.day).round)
      else
        num_hours = (num_minutes.minutes / 1.hour).round
        if num_hours >= 2
          I18n.t("#{locale_key}_hours", count: num_hours)
        else
          I18n.t("#{locale_key}_minutes", count: num_minutes)
        end
      end
    end

    def options_for(status, opts = {})
      {
        bump: status.opening_topic?,
        post_type: Post.types[:small_action],
        silent: opts[:silent],
        action_code: status.action_code,
      }
    end

    Status =
      Struct.new(:name, :enabled) do
        %w[pinned_globally pinned autoclosed closed visible archived].each do |status|
          define_method("#{status}?") { name == status }
        end

        def enabled?
          enabled
        end

        def disabled?
          !enabled?
        end

        def action_code
          "#{name}.#{enabled? ? "enabled" : "disabled"}"
        end

        def locale_key
          "topic_statuses.#{action_code.tr(".", "_")}"
        end

        def opening_topic?
          (closed? || autoclosed?) && disabled?
        end

        def closing_topic?
          (closed? || autoclosed?) && enabled?
        end

        def manually_closing_topic?
          closed? && enabled?
        end

        def manually_opening_topic?
          closed? && disabled?
        end
      end
  end

```

Response:
# encoding: UTF-8
# frozen_string_literal: true

# TODO - test pinning, create_moderator_post

RSpec.describe TopicStatusUpdater do
  fab!(:user)
  fab!(:admin)

  it "avoids notifying on automatically closed topics" do
    # TODO: TopicStatusUpdater should suppress message bus updates from the users it "pretends to read"
    post =
      PostCreator.create(
        user,
        raw: "this is a test post 123 this is a test post",
        title: "hello world title",
      )
    # TODO needed so counts sync up, PostCreator really should not give back out-of-date Topic
    post.topic.set_or_create_timer(TopicTimer.types[:close], "10")
    post.topic.reload

    TopicStatusUpdater.new(post.topic, admin).update!("autoclosed", true)

    expect(post.topic.posts.count).to eq(2)

    tu = TopicUser.find_by(user_id: user.id)
    expect(tu.last_read_post_number).to eq(2)
  end

  it "adds an autoclosed message" do
    topic = create_topic
    topic.set_or_create_timer(TopicTimer.types[:close], "10")

    TopicStatusUpdater.new(topic, admin).update!("autoclosed", true)

    last_post = topic.posts.last
    expect(last_post.post_type).to eq(Post.types[:small_action])
    expect(last_post.action_code).to eq("autoclosed.enabled")
    expect(last_post.raw).to eq(I18n.t("topic_statuses.autoclosed_enabled_minutes", count: 0))
  end

  it "triggers a DiscourseEvent on close" do
    topic = create_topic

    called = false
    updater = ->(_) { called = true }

    DiscourseEvent.on(:topic_closed, &updater)
    TopicStatusUpdater.new(topic, admin).update!("closed", true)
    DiscourseEvent.off(:topic_closed, &updater)

    expect(topic).to be_closed
    expect(called).to eq(true)
  end

  it "adds an autoclosed message based on last post" do
    topic = create_topic
    Fabricate(:post, topic: topic)

    topic.set_or_create_timer(
      TopicTimer.types[:close],
      nil,
      based_on_last_post: true,
      duration_minutes: 600,
    )

    TopicStatusUpdater.new(topic, admin).update!("autoclosed", true)

    last_post = topic.posts.last
    expect(last_post.post_type).to eq(Post.types[:small_action])
    expect(last_post.action_code).to eq("autoclosed.enabled")
    expect(last_post.raw).to eq(
      I18n.t("topic_statuses.autoclosed_enabled_lastpost_hours", count: 10),
    )
  end

  describe "opening the topic" do
    it "opens the topic and deletes the timer" do
      topic = create_topic

      topic.set_or_create_timer(TopicTimer.types[:open], 10.hours.from_now)

      TopicStatusUpdater.new(topic, admin).update!("closed", false)
      timer = TopicTimer.find_by(topic: topic)
      expect(timer).to eq(nil)
    end

    context "when the category has auto close settings" do
      let(:topic) { create_topic }
      let(:based_on_last_post) { false }

      before do
        # auto close after 3 days, topic was created a day ago
        topic.update(
          category:
            Fabricate(
              :category,
              auto_close_hours: 72,
              auto_close_based_on_last_post: based_on_last_post,
            ),
          created_at: 1.day.ago,
        )
      end

      it "inherits auto close from the topic category, based on the created_at date of the topic" do
        # close the topic manually, and set a timer to automatically open
        TopicStatusUpdater.new(topic, admin).update!("closed", true)
        topic.set_or_create_timer(TopicTimer.types[:open], 10.hours.from_now)

        # manually open the topic. it has been 1 days since creation so the
        # topic should auto-close 2 days from now, the original auto close time
        TopicStatusUpdater.new(topic, admin).update!("closed", false)

        timer = TopicTimer.find_by(topic: topic)
        expect(timer).not_to eq(nil)
        expect(timer.execute_at).to be_within_one_second_of(topic.created_at + 72.hours)
      end

      it "does not inherit auto close from the topic category if it has already been X hours since topic creation" do
        topic.category.update(auto_close_hours: 1)

        # close the topic manually, and set a timer to automatically open
        TopicStatusUpdater.new(topic, admin).update!("closed", true)
        topic.set_or_create_timer(TopicTimer.types[:open], 10.hours.from_now)

        # manually open the topic. it has been over a day since creation and
        # the auto close hours was 1 so a new timer should not be made
        TopicStatusUpdater.new(topic, admin).update!("closed", false)

        timer = TopicTimer.find_by(topic: topic)
        expect(timer).to eq(nil)
      end

      context "when category setting is based_on_last_post" do
        let(:based_on_last_post) { true }

        it "inherits auto close from the topic category, using the duration because the close is based_on_last_post" do
          # close the topic manually, and set a timer to automatically open
          TopicStatusUpdater.new(topic, admin).update!("closed", true)
          topic.set_or_create_timer(TopicTimer.types[:open], 10.hours.from_now)

          # manually open the topic. it should re open 3 days from now, NOT
          # 3 days from creation
          TopicStatusUpdater.new(topic, admin).update!("closed", false)

          timer = TopicTimer.find_by(topic: topic)
          expect(timer).not_to eq(nil)
          expect(timer.duration_minutes).to eq(72 * 60)
          expect(timer.execute_at).to be_within_one_second_of(Time.zone.now + 72.hours)
        end
      end
    end
  end

  describe "repeat actions" do
    shared_examples "an action that doesn't repeat" do
      it "does not perform the update twice" do
        topic = Fabricate(:topic, status_name => false)
        updated = TopicStatusUpdater.new(topic, admin).update!(status_name, true)
        expect(updated).to eq(true)
        expect(topic.public_send("#{status_name}?")).to eq(true)

        updated = TopicStatusUpdater.new(topic, admin).update!(status_name, true)
        expect(updated).to eq(false)
        expect(topic.posts.where(post_type: Post.types[:small_action]).count).to eq(1)

        updated = TopicStatusUpdater.new(topic, admin).update!(status_name, false)
        expect(updated).to eq(true)
        expect(topic.public_send("#{status_name}?")).to eq(false)

        updated = TopicStatusUpdater.new(topic, admin).update!(status_name, false)
        expect(updated).to eq(false)
        expect(topic.posts.where(post_type: Post.types[:small_action]).count).to eq(2)
      end
    end

    it_behaves_like "an action that doesn't repeat" do
      let(:status_name) { "closed" }
    end

    it_behaves_like "an action that doesn't repeat" do
      let(:status_name) { "visible" }
    end

    it_behaves_like "an action that doesn't repeat" do
      let(:status_name) { "archived" }
    end

    it "updates autoclosed" do
      topic = Fabricate(:topic)
      updated = TopicStatusUpdater.new(topic, admin).update!("autoclosed", true)
      expect(updated).to eq(true)
      expect(topic.closed?).to eq(true)

      updated = TopicStatusUpdater.new(topic, admin).update!("autoclosed", true)
      expect(updated).to eq(false)
      expect(topic.posts.where(post_type: Post.types[:small_action]).count).to eq(1)

      updated = TopicStatusUpdater.new(topic, admin).update!("autoclosed", false)
      expect(updated).to eq(true)
      expect(topic.closed?).to eq(false)

      updated = TopicStatusUpdater.new(topic, admin).update!("autoclosed", false)
      expect(updated).to eq(false)
      expect(topic.posts.where(post_type: Post.types[:small_action]).count).to eq(2)
    end
  end
end


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

class NotificationEmailer
  class EmailUser
    attr_reader :notification, :no_delay

    def initialize(notification, no_delay: false)
      @notification = notification
      @no_delay = no_delay
    end

    def group_mentioned
      enqueue :group_mentioned
    end

    def mentioned
      enqueue :user_mentioned
    end

    def posted
      enqueue :user_posted
    end

    def watching_category_or_tag
      enqueue :user_posted
    end

    def quoted
      enqueue :user_quoted
    end

    def replied
      enqueue :user_replied
    end

    def linked
      enqueue :user_linked
    end

    def watching_first_post
      enqueue :user_watching_first_post
    end

    def post_approved
      enqueue :post_approved
    end

    def private_message
      enqueue_private(:user_private_message)
    end

    def invited_to_private_message
      enqueue(:user_invited_to_private_message, private_delay)
    end

    def invited_to_topic
      enqueue(:user_invited_to_topic, private_delay)
    end

    def self.notification_params(notification, type)
      post_id = (notification.data_hash[:original_post_id] || notification.post_id).to_i
      notification_type = Notification.types[notification.notification_type]

      hash = {
        type: type.to_s,
        user_id: notification.user_id,
        notification_id: notification.id,
        notification_data_hash: notification.data_hash,
        notification_type: notification_type.to_s,
      }

      hash[:post_id] = post_id if post_id > 0 && notification_type != :post_approved
      hash
    end

    private

    EMAILABLE_POST_TYPES ||= Set.new [Post.types[:regular], Post.types[:whisper]]

    def enqueue(type, delay = default_delay)
      return if notification.user.user_option.email_level == UserOption.email_level_types[:never]
      perform_enqueue(type, delay)
    end

    def enqueue_private(type, delay = private_delay)
      if notification.user.user_option.nil?
        # this can happen if we roll back user creation really early
        # or delete user
        # bypass this pm
        return
      end

      if notification.user.user_option.email_messages_level == UserOption.email_level_types[:never]
        return
      end
      perform_enqueue(type, delay)
    end

    def perform_enqueue(type, delay)
      user = notification.user
      return unless user.active? || user.staged?
      return if SiteSetting.must_approve_users? && !user.approved? && !user.staged?
      return if user.staged? && (type == :user_linked || type == :user_quoted)

      return unless EMAILABLE_POST_TYPES.include?(post_type)

      Jobs.enqueue_in(delay, :user_email, self.class.notification_params(notification, type))
    end

    def default_delay
      no_delay ? 0 : SiteSetting.email_time_window_mins.minutes
    end

    def private_delay
      no_delay ? 0 : SiteSetting.personal_email_time_window_seconds
    end

    def post_type
      @post_type ||=
        begin
          type = notification.data_hash["original_post_type"] if notification.data_hash
          type ||= notification.post.try(:post_type)
          type
        end
    end
  end

  def self.disable
    @disabled = true
  end

  def self.enable
    @disabled = false
  end

  def self.process_notification(notification, no_delay: false)
    return if @disabled

    email_user = EmailUser.new(notification, no_delay: no_delay)
    email_method = Notification.types[notification.notification_type]

    DiscoursePluginRegistry.email_notification_filters.each do |filter|
      return unless filter.call(notification)
    end

    email_user.public_send(email_method) if email_user.respond_to? email_method
  end
end

```

Response:
# frozen_string_literal: true

RSpec.describe NotificationEmailer do
  before do
    freeze_time
    NotificationEmailer.enable
  end

  fab!(:topic)
  fab!(:post) { Fabricate(:post, topic: topic) }

  # something is off with fabricator
  def create_notification(type, user = nil)
    user ||= Fabricate(:user)
    Notification.create(
      data: "{\"a\": 1}",
      user: user,
      notification_type: Notification.types[type],
      topic: topic,
      post_number: post.post_number,
    )
  end

  shared_examples "enqueue" do
    it "enqueues a job for the email" do
      expect_enqueued_with(
        job: :user_email,
        args: NotificationEmailer::EmailUser.notification_params(notification, type),
        at: no_delay ? Time.zone.now : Time.zone.now + delay,
      ) { NotificationEmailer.process_notification(notification, no_delay: no_delay) }
    end

    context "with an inactive user" do
      before { notification.user.active = false }

      it "doesn't enqueue a job" do
        expect_not_enqueued_with(job: :user_email, args: { type: type }) do
          NotificationEmailer.process_notification(notification, no_delay: no_delay)
        end
      end

      it "enqueues a job if the user is staged for non-linked and non-quoted types" do
        notification.user.staged = true

        if type == :user_linked || type == :user_quoted
          expect_not_enqueued_with(job: :user_email, args: { type: type }) do
            NotificationEmailer.process_notification(notification, no_delay: no_delay)
          end
        else
          expect_enqueued_with(
            job: :user_email,
            args: NotificationEmailer::EmailUser.notification_params(notification, type),
            at: no_delay ? Time.zone.now : Time.zone.now + delay,
          ) { NotificationEmailer.process_notification(notification, no_delay: no_delay) }
        end
      end

      it "enqueues a job if the user is staged even if site requires user approval for non-linked and non-quoted typed" do
        notification.user.staged = true
        SiteSetting.must_approve_users = true

        if type == :user_linked || type == :user_quoted
          expect_not_enqueued_with(job: :user_email, args: { type: type }) do
            NotificationEmailer.process_notification(notification, no_delay: no_delay)
          end
        else
          expect_enqueued_with(
            job: :user_email,
            args: NotificationEmailer::EmailUser.notification_params(notification, type),
            at: no_delay ? Time.zone.now : Time.zone.now + delay,
          ) { NotificationEmailer.process_notification(notification, no_delay: no_delay) }
        end
      end
    end

    context "with an active but unapproved user" do
      before do
        SiteSetting.must_approve_users = true
        notification.user.approved = false
        notification.user.active = true
      end

      it "doesn't enqueue a job" do
        expect_not_enqueued_with(job: :user_email, args: { type: type }) do
          NotificationEmailer.process_notification(notification, no_delay: no_delay)
        end
      end
    end

    context "with a small action" do
      it "doesn't enqueue a job" do
        Post.any_instance.expects(:post_type).returns(Post.types[:small_action])

        expect_not_enqueued_with(job: :user_email, args: { type: type }) do
          NotificationEmailer.process_notification(notification, no_delay: no_delay)
        end
      end
    end
  end

  shared_examples "enqueue_public" do
    include_examples "enqueue"

    it "doesn't enqueue a job if the user has mention emails disabled" do
      notification.user.user_option.update_columns(
        email_level: UserOption.email_level_types[:never],
      )

      expect_not_enqueued_with(job: :user_email, args: { type: type }) do
        NotificationEmailer.process_notification(notification, no_delay: no_delay)
      end
    end
  end

  shared_examples "enqueue_private" do
    include_examples "enqueue"

    it "doesn't enqueue a job if the user has private message emails disabled" do
      notification.user.user_option.update_columns(
        email_messages_level: UserOption.email_level_types[:never],
      )

      expect_not_enqueued_with(job: :user_email, args: { type: type }) do
        NotificationEmailer.process_notification(notification)
      end
    end
  end

  [true, false].each do |no_delay|
    context "with user_mentioned" do
      let(:no_delay) { no_delay }
      let(:type) { :user_mentioned }
      let(:delay) { SiteSetting.email_time_window_mins.minutes }
      let!(:notification) { create_notification(:mentioned) }

      include_examples "enqueue_public"

      it "enqueue a delayed job for users that are online" do
        notification.user.last_seen_at = 1.minute.ago

        expect_enqueued_with(
          job: :user_email,
          args: NotificationEmailer::EmailUser.notification_params(notification, type),
          at: Time.zone.now + delay,
        ) { NotificationEmailer.process_notification(notification) }
      end
    end

    context "with user_replied" do
      let(:no_delay) { no_delay }
      let(:type) { :user_replied }
      let(:delay) { SiteSetting.email_time_window_mins.minutes }
      let!(:notification) { create_notification(:replied) }

      include_examples "enqueue_public"
    end

    context "with user_quoted" do
      let(:no_delay) { no_delay }
      let(:type) { :user_quoted }
      let(:delay) { SiteSetting.email_time_window_mins.minutes }
      let!(:notification) { create_notification(:quoted) }

      include_examples "enqueue_public"
    end

    context "with user_linked" do
      let(:no_delay) { no_delay }
      let(:type) { :user_linked }
      let(:delay) { SiteSetting.email_time_window_mins.minutes }
      let!(:notification) { create_notification(:linked) }

      include_examples "enqueue_public"
    end

    context "with user_posted" do
      let(:no_delay) { no_delay }
      let(:type) { :user_posted }
      let(:delay) { SiteSetting.email_time_window_mins.minutes }
      let!(:notification) { create_notification(:posted) }

      include_examples "enqueue_public"
    end

    context "with user_watching_category_or_tag" do
      let(:no_delay) { no_delay }
      let(:type) { :user_posted }
      let(:delay) { SiteSetting.email_time_window_mins.minutes }
      let!(:notification) { create_notification(:watching_category_or_tag) }

      include_examples "enqueue_public"
    end

    context "with user_private_message" do
      let(:no_delay) { no_delay }
      let(:type) { :user_private_message }
      let(:delay) { SiteSetting.personal_email_time_window_seconds }
      let!(:notification) { create_notification(:private_message) }

      include_examples "enqueue_private"

      it "doesn't enqueue a job for a small action" do
        notification.data_hash["original_post_type"] = Post.types[:small_action]

        expect_not_enqueued_with(job: :user_email, args: { type: type }) do
          NotificationEmailer.process_notification(notification)
        end
      end
    end

    context "with user_invited_to_private_message" do
      let(:no_delay) { no_delay }
      let(:type) { :user_invited_to_private_message }
      let(:delay) { SiteSetting.personal_email_time_window_seconds }
      let!(:notification) { create_notification(:invited_to_private_message) }

      include_examples "enqueue_public"
    end

    context "with user_invited_to_topic" do
      let(:no_delay) { no_delay }
      let(:type) { :user_invited_to_topic }
      let(:delay) { SiteSetting.personal_email_time_window_seconds }
      let!(:notification) { create_notification(:invited_to_topic) }

      include_examples "enqueue_public"
    end

    context "when watching the first post" do
      let(:no_delay) { no_delay }
      let(:type) { :user_watching_first_post }
      let(:delay) { SiteSetting.email_time_window_mins.minutes }
      let!(:notification) { create_notification(:watching_first_post) }

      include_examples "enqueue_public"
    end

    context "with post_approved" do
      let(:no_delay) { no_delay }
      let(:type) { :post_approved }
      let(:delay) { SiteSetting.email_time_window_mins.minutes }
      let!(:notification) { create_notification(:post_approved) }

      include_examples "enqueue_public"
    end
  end

  it "has translations for each sendable notification type" do
    notification = create_notification(:mentioned)
    email_user = NotificationEmailer::EmailUser.new(notification, no_delay: true)
    subkeys = %w[title subject_template text_body_template]

    # some notification types need special handling
    replace_keys = {
      "post_approved" => ["post_approved"],
      "private_message" => ["user_posted"],
      "invited_to_private_message" => %w[
        user_invited_to_private_message_pm
        user_invited_to_private_message_pm_group
        user_invited_to_private_message_pm_staged
      ],
    }

    Notification.types.keys.each do |notification_type|
      if email_user.respond_to?(notification_type)
        type_keys = replace_keys[notification_type.to_s] || ["user_#{notification_type}"]

        type_keys.each do |type_key|
          subkeys.each do |subkey|
            key = "user_notifications.#{type_key}.#{subkey}"
            expect(I18n.exists?(key)).to eq(true), "missing translation: #{key}"
          end
        end
      end
    end
  end

  describe "with plugin-added email_notification_filters" do
    let!(:plugin) { Plugin::Instance.new }
    let!(:notification) { create_notification(:quoted) }
    let(:no_delay) { true }
    let(:type) { :user_quoted }

    after { DiscoursePluginRegistry.reset! }

    it "sends email when all filters return true" do
      plugin.register_email_notification_filter { |_| true }
      plugin.register_email_notification_filter { |_| true }

      expect_enqueued_with(job: :user_email, args: { type: type }) do
        NotificationEmailer.process_notification(notification, no_delay: no_delay)
      end
    end

    it "doesn't send email when all one filter returns false" do
      plugin.register_email_notification_filter { |_| true }
      plugin.register_email_notification_filter { |_| false }

      expect_not_enqueued_with(job: :user_email, args: { type: type }) do
        NotificationEmailer.process_notification(notification, no_delay: no_delay)
      end
    end
  end
end