Instruction:
Write RSpec test file for following ruby class
```ruby
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2023 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

# See also: create_service.rb for comments
class OAuthClients::SetAttributesService < BaseServices::SetAttributes
  private

  def set_attributes(params)
    super(replace_client_secret_with_nil(params))
  end

  def replace_client_secret_with_nil(params)
    cloned_param = params.clone

    if cloned_param[:client_secret] == ''
      cloned_param.merge!(client_secret: nil)
    end

    cloned_param
  end
end

```

Response:
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2023 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

require 'spec_helper'

RSpec.describe OAuthClients::SetAttributesService, type: :model do
  let(:current_user) { build_stubbed(:admin) }

  let(:contract_instance) do
    contract = instance_double(OAuthClients::CreateContract, 'contract_instance')
    allow(contract)
      .to receive(:validate)
      .and_return(contract_valid)
    allow(contract)
      .to receive(:errors)
      .and_return(contract_errors)
    contract
  end

  let(:contract_errors) { instance_double(ActiveModel::Errors, 'contract_errors') }
  let(:contract_valid) { true }
  let(:model_valid) { true }

  let(:instance) do
    described_class.new(user: current_user, model: model_instance, contract_class:, contract_options: {})
  end
  let(:model_instance) { OAuthClient.new }
  let(:contract_class) do
    allow(OAuthClients::CreateContract)
      .to receive(:new)
      .and_return(contract_instance)

    OAuthClients::CreateContract
  end

  let(:params) { {} }

  before do
    allow(model_instance)
      .to receive(:valid?)
      .and_return(model_valid)
  end

  subject { instance.call(params) }

  it 'returns the instance as the result' do
    expect(subject.result)
      .to eql model_instance
  end

  it 'is a success' do
    expect(subject)
      .to be_success
  end

  context 'with params' do
    let(:params) do
      {
        client_id: '0123456789-client_id',
        client_secret: '1234567890-client_secret'
      }
    end

    before do
      subject
    end

    it 'assigns the params' do
      expect(model_instance.client_id).to eq '0123456789-client_id'
      expect(model_instance.client_secret).to eq '1234567890-client_secret'
    end
  end

  context 'with an invalid contract' do
    let(:contract_valid) { false }

    it 'returns failure' do
      expect(subject).not_to be_success
    end

    it "returns the contract's errors" do
      expect(subject.errors)
        .to eql(contract_errors)
    end
  end
end


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

#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2023 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

require "rack/oauth2"
require "uri/http"

module OAuthClients
  class ConnectionManager
    # Nextcloud API endpoint to check if Bearer token is valid
    TOKEN_IS_FRESH_DURATION = 10.seconds.freeze

    def initialize(user:, configuration:)
      @user = user
      @oauth_client = configuration.oauth_client
      @config = configuration
    end

    # Main method to initiate the OAuth2 flow called by a "client" component
    # that wants to access OAuth2 protected resources.
    # Returns an OAuthClientToken object or a String in case a renew is required.
    # @param state (OAuth2 RFC) encapsulates the state of the calling page (URL + params) to return
    # @param scope (OAuth2 RFC) specifies the resources to access. Nextcloud only has one global scope.
    # @return ServiceResult with ServiceResult.result being either an OAuthClientToken or a redirection URL
    def get_access_token(state: nil)
      # Check for an already existing token from last call
      token = get_existing_token
      return ServiceResult.success(result: token) if token.present?

      # Return the Nextcloud OAuth authorization URI that a user needs to open to grant access and eventually obtain
      # a token.
      @redirect_url = get_authorization_uri(state:)

      ServiceResult.failure(result: @redirect_url)
    end

    # rubocop:disable Metrics/AbcSize

    # The bearer/access token has expired or is due for renew for other reasons.
    # Talk to OAuth2 Authorization Server to exchange the renew_token for a new bearer token.
    def refresh_token
      OAuthClientToken.transaction do
        oauth_client_token = OAuthClientToken.lock('FOR UPDATE').find_by(user_id: @user, oauth_client_id: @oauth_client.id)

        if oauth_client_token.present?
          if (Time.current - oauth_client_token.updated_at) > TOKEN_IS_FRESH_DURATION
            service_result = request_new_token(refresh_token: oauth_client_token.refresh_token)

            if service_result.success?
              update_oauth_client_token(oauth_client_token, service_result.result)
            else
              service_result
            end
          else
            ServiceResult.success(result: oauth_client_token)
          end
        else
          storage_error = ::Storages::StorageError.new(
            code: :error,
            log_message: I18n.t('oauth_client.errors.refresh_token_called_without_existing_token')
          )
          ServiceResult.failure(result: :error, errors: storage_error)
        end
      end
    end

    # rubocop:enable Metrics/AbcSize

    # Returns the URI of the "authorize" endpoint of the OAuth2 Authorization Server.
    # @param state (OAuth2 RFC) is a nonce referencing a cookie containing the calling page (URL + params) to which to
    # return to at the end of the whole flow.
    # @param scope (OAuth2 RFC) specifies the resources to access. Nextcloud has only one global scope.
    def get_authorization_uri(state: nil)
      client = rack_oauth_client # Configure and start the rack-oauth2 client
      client.authorization_uri(scope: @config.scope, state:)
    end

    # rubocop:disable Metrics/AbcSize

    # Called by callback_page with a cryptographic "code" that indicates
    # that the user has successfully authorized the OAuth2 Authorization Server.
    # We now are going to exchange this code to a token (bearer+refresh)
    def code_to_token(code)
      # Return a Rack::OAuth2::AccessToken::Bearer or an error string
      service_result = request_new_token(authorization_code: code)
      return service_result unless service_result.success?

      # Check for existing OAuthClientToken and update,
      # or create a new one from Rack::OAuth::AccessToken::Bearer
      oauth_client_token = get_existing_token
      if oauth_client_token.present?
        update_oauth_client_token(oauth_client_token, service_result.result)
      else
        rack_access_token = service_result.result
        oauth_client_token =
          OAuthClientToken.create(
            user: @user,
            oauth_client: @oauth_client,
            origin_user_id: rack_access_token.raw_attributes[:user_id], # ID of user at OAuth2 Authorization Server
            access_token: rack_access_token.access_token,
            token_type: rack_access_token.token_type, # :bearer
            refresh_token: rack_access_token.refresh_token,
            expires_in: rack_access_token.raw_attributes[:expires_in],
            scope: rack_access_token.scope
          )
        OpenProject::Notifications.send(
          OpenProject::Events::OAUTH_CLIENT_TOKEN_CREATED,
          integration_type: @oauth_client.integration_type
        )
      end

      ServiceResult.success(result: oauth_client_token)
    end

    # rubocop:enable Metrics/AbcSize

    # Called by StorageRepresenter to inquire about the status of the OAuth2
    # authentication server.
    # Returns :connected/:authorization_failed or :error for a general error.
    # We have decided to distinguish between only these 3 cases, because the
    # front-end (and a normal user) probably wouldn't know how to deal with
    # other options.
    def authorization_state
      oauth_client_token = get_existing_token
      return :failed_authorization unless oauth_client_token

      state = @config.authorization_state_check(oauth_client_token.access_token)
      case state
      when :success
        :connected
      when :refresh_needed
        service_result = refresh_token
        if service_result.success?
          :connected
        elsif service_result.errors.data.payload[:error] == 'invalid_request'
          :failed_authorization
        else
          :error
        end
      else
        state
      end
    rescue StandardError
      :error
    end

    # @returns ServiceResult with result to be :error or any type of object with data
    def request_with_token_refresh(oauth_client_token)
      # `yield` needs to returns a ServiceResult:
      #   success: result= any object with data
      #   failure: result= :error or :unauthorized
      yield_service_result = yield(oauth_client_token)

      if yield_service_result.failure? && yield_service_result.result == :unauthorized
        refresh_service_result = refresh_token
        return refresh_service_result if refresh_service_result.failure?

        oauth_client_token.reload
        yield_service_result = yield(oauth_client_token) # Should contain result=<data> in case of success
      end

      yield_service_result
    end

    private

    # Check if a OAuthClientToken already exists and return nil otherwise.
    # Don't handle the case of an expired token.
    def get_existing_token
      # Check if we've got a token in the database and return nil otherwise.
      OAuthClientToken.find_by(user_id: @user, oauth_client_id: @oauth_client.id)
    end

    def request_new_token(options = {})
      rack_access_token = rack_oauth_client(options).access_token!(:body)

      ServiceResult.success(result: rack_access_token)
    rescue Rack::OAuth2::Client::Error => e
      service_result_with_error(:bad_request, e.response, i18n_rack_oauth2_error_message(e))
    rescue Faraday::TimeoutError, Faraday::ConnectionFailed, Faraday::ParsingError, Faraday::SSLError => e
      service_result_with_error(
        :internal_server_error,
        e,
        "#{I18n.t('oauth_client.errors.oauth_returned_http_error')}: #{e.class}: #{e.message.to_html}"
      )
    rescue StandardError => e
      service_result_with_error(
        :error,
        e,
        "#{I18n.t('oauth_client.errors.oauth_returned_standard_error')}: #{e.class}: #{e.message.to_html}"
      )
    end

    def i18n_rack_oauth2_error_message(rack_oauth2_client_exception)
      i18n_key = "oauth_client.errors.rack_oauth2.#{rack_oauth2_client_exception.message}"
      if I18n.exists? i18n_key
        I18n.t(i18n_key)
      else
        "#{I18n.t('oauth_client.errors.oauth_returned_error')}: #{rack_oauth2_client_exception.message.to_html}"
      end
    end

    # Return a fully configured RackOAuth2Client.
    # This client does all the heavy lifting with the OAuth2 protocol.
    def rack_oauth_client(options = {})
      rack_oauth_client = @config.basic_rack_oauth_client

      # Write options, for example authorization_code and refresh_token
      rack_oauth_client.refresh_token = options[:refresh_token] if options[:refresh_token]
      rack_oauth_client.authorization_code = options[:authorization_code] if options[:authorization_code]

      rack_oauth_client
    end

    # Update an OpenProject token based on updated values from a
    # Rack::OAuth2::AccessToken::Bearer after a OAuth2 refresh operation
    def update_oauth_client_token(oauth_client_token, rack_oauth2_access_token)
      success = oauth_client_token.update(
        access_token: rack_oauth2_access_token.access_token,
        refresh_token: rack_oauth2_access_token.refresh_token,
        expires_in: rack_oauth2_access_token.expires_in
      )

      if success
        ServiceResult.success(result: oauth_client_token)
      else
        result = ServiceResult.failure
        result.errors.add(:base, I18n.t('oauth_client.errors.refresh_token_updated_failed'))
        result.add_dependent!(ServiceResult.failure(errors: oauth_client_token.errors))
        result
      end
    end

    def service_result_with_error(code, data, log_message = nil)
      error_data = ::Storages::StorageErrorData.new(source: self, payload: data)
      ServiceResult.failure(result: code,
                            errors: ::Storages::StorageError.new(code:, data: error_data, log_message:))
    end
  end
end

```

Response:
# frozen_string_literal: true

#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2023 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

require 'spec_helper'

RSpec.describe OAuthClients::ConnectionManager, :webmock, type: :model do
  using Storages::Peripherals::ServiceResultRefinements

  let(:user) { create(:user) }

  let(:host) { "https://example.org" }
  let(:storage) { create(:nextcloud_storage, :with_oauth_client, host: "#{host}/") }
  let(:oauth_client) { storage.oauth_client }
  let(:configuration) { storage.oauth_configuration }

  let(:scope) { [:all] } # OAuth2 resources to access, specific to provider
  let(:oauth_client_token) { create(:oauth_client_token, oauth_client:, user:) }

  let(:instance) { described_class.new(user:, configuration:) }

  # The get_authorization_uri method returns the OAuth2 authorization URI as a string. That URI is the starting point for
  # a user to grant OpenProject access to Nextcloud.
  describe '#get_authorization_uri' do
    let(:scope) { nil }
    let(:state) { nil }

    subject { instance.get_authorization_uri(state:) }

    context 'with empty state and scope' do
      shared_examples_for 'returns the authorization URI relative to the host' do
        it 'returns the authorization URI' do
          expect(subject).to be_a String
          expect(subject).to include oauth_client.integration.host
          expect(subject).not_to include "scope"
          expect(subject).not_to include "state"
        end
      end

      context 'when Nextcloud is installed in the server root' do
        it_behaves_like 'returns the authorization URI relative to the host'
      end

      context 'when Nextcloud is installed in a sub-directory' do
        let(:host) { "https://example.org/nextcloud" }

        it_behaves_like 'returns the authorization URI relative to the host'
      end
    end

    context 'with state but empty scope' do
      let(:state) { "https://example.com/page" }

      it 'returns the redirect URL' do
        expect(subject).to be_a String
        expect(subject).to include oauth_client.integration.host
        expect(subject).not_to include "scope"
        expect(subject).to include "&state=https"
      end
    end

    context 'with multiple scopes but empty state' do
      let(:scope) { %i(email profile) }

      it 'returns the redirect URL' do
        allow(configuration).to receive(:scope).and_return(scope)

        expect(subject).to be_a String
        expect(subject).to include oauth_client.integration.host
        expect(subject).not_to include "state"
        expect(subject).to include "&scope=email%20profile"
      end
    end
  end

  # The first step in the OAuth2 flow is to produce a URL for the
  # user to authenticate and authorize access at the OAuth2 provider
  # (Nextcloud).
  describe '#get_access_token' do
    subject { instance.get_access_token }

    context 'with no OAuthClientToken present' do
      it 'returns a redirection URL' do
        expect(subject.success).to be_falsy
        expect(subject.result).to be_a String
        # Details of string are tested above in section #get_authorization_uri
      end
    end

    context 'with no OAuthClientToken present and state parameters' do
      subject { instance.get_access_token(state: "some_state") }

      it 'returns the redirect URL' do
        allow(configuration).to receive(:scope).and_return(%w[email])

        expect(subject.success).to be_falsy
        expect(subject.result).to be_a String
        expect(subject.result).to include oauth_client.integration.host
        expect(subject.result).to include "&state=some_state"
        expect(subject.result).to include "&scope=email"
      end
    end

    context 'with an OAuthClientToken present' do
      before do
        oauth_client_token
      end

      it 'returns the OAuthClientToken' do
        expect(subject).to be_truthy
        expect(subject.result).to be_a OAuthClientToken # The one and only...
        expect(subject.result).to eql oauth_client_token
      end
    end
  end

  # In the second step the Authorization Server (Nextcloud) redirects
  # to a "callback" endpoint on the OAuth2 client (OpenProject):
  # https://<openproject-server>/oauth_clients/8/callback?state=&code=7kRGJ...jG3KZ
  # This callback code basically just calls `code_to_token(code)`.
  # The callback endpoint calls `code_to_token(code)` with the code
  # received and exchanges the code for a bearer+refresh token
  # using a HTTP request.
  describe '#code_to_token', :webmock do
    let(:code) { "7kRGJ...jG3KZ" }

    subject { instance.code_to_token(code) }

    context 'with happy path' do
      before do
        # Simulate a successful authorization returning the tokens
        response_body = {
          access_token: "yjTDZ...RYvRH",
          token_type: "Bearer",
          expires_in: 3600,
          refresh_token: "UwFp...1FROJ",
          user_id: "admin"
        }.to_json
        stub_request(:any, File.join(host, '/index.php/apps/oauth2/api/v1/token'))
          .to_return(status: 200, body: response_body, headers: { "content-type" => "application/json; charset=utf-8" })
      end

      it 'returns a valid ClientToken object and issues an appropriate event' do
        expect(OpenProject::Notifications)
          .to(receive(:send))
          .with(OpenProject::Events::OAUTH_CLIENT_TOKEN_CREATED, integration_type: "Storages::Storage")
        expect(subject.success).to be_truthy
        expect(subject.result).to be_a OAuthClientToken
      end
    end

    context 'with known error' do
      before do
        stub_request(:post, File.join(host, '/index.php/apps/oauth2/api/v1/token'))
          .to_return(status: 400,
                     body: { error: error_message }.to_json,
                     headers: { "content-type" => "application/json; charset=utf-8" })
      end

      shared_examples 'OAuth2 error response' do
        it 'returns a specific error message' do
          expect(subject.success).to be_falsy
          expect(subject.result).to eq(:bad_request)
          expect(subject.error_payload[:error]).to eq(error_message)
        end
      end

      context 'when "invalid_request"' do
        let(:error_message) { 'invalid_request' }

        it_behaves_like 'OAuth2 error response'
      end

      context 'when "invalid_grant"' do
        let(:error_message) { 'invalid_grant' }

        it_behaves_like 'OAuth2 error response'
      end
    end

    context 'with unknown reply' do
      before do
        stub_request(:post, File.join(host, '/index.php/apps/oauth2/api/v1/token'))
          .to_return(status: 400,
                     body: { error: "invalid_requesttt" }.to_json,
                     headers: { "content-type" => "application/json; charset=utf-8" })
      end

      it 'returns an error wrapping the unknown response' do
        expect(subject.success).to be_falsy
        expect(subject.result).to eq(:bad_request)
        expect(subject.error_payload[:error]).to eq('invalid_requesttt')
        expect(subject.error_source).to be_a(described_class)
        expect(subject.errors.log_message).to include I18n.t('oauth_client.errors.oauth_returned_error')
      end
    end

    context 'with reply including JSON syntax error' do
      before do
        stub_request(:post, File.join(host, '/index.php/apps/oauth2/api/v1/token'))
          .to_return(
            status: 400,
            headers: { 'Content-Type' => 'application/json; charset=utf-8' },
            body: "some: very, invalid> <json}"
          )
      end

      it 'returns an error wrapping the parsing error' do
        expect(subject.success).to be_falsy
        expect(subject.result).to eq(:internal_server_error)
        expect(subject.error_payload.class).to be(Faraday::ParsingError)
        expect(subject.error_source).to be_a(described_class)
        expect(subject.errors.log_message).to include I18n.t('oauth_client.errors.oauth_returned_http_error')
      end
    end

    context 'with 500 reply without body' do
      before do
        stub_request(:post, File.join(host, '/index.php/apps/oauth2/api/v1/token'))
          .to_return(status: 500)
      end

      it 'returns an error wrapping the empty error' do
        expect(subject.success).to be_falsy
        expect(subject.result).to eq(:bad_request)
        expect(subject.error_payload[:error]).to eq('Unknown')
      end
    end

    context 'when something is wrong with connection' do
      before do
        stub_request(:post, File.join(host, '/index.php/apps/oauth2/api/v1/token')).to_raise(Faraday::ConnectionFailed)
      end

      it 'returns an error wrapping the server error' do
        expect(subject.success).to be_falsy
        expect(subject.result).to eq(:internal_server_error)
        expect(subject.error_payload.class).to be(Faraday::ConnectionFailed)
        expect(subject.error_source).to be_a(described_class)
        expect(subject.errors.log_message).to include I18n.t('oauth_client.errors.oauth_returned_http_error')
      end
    end

    context 'when something is wrong with SSL' do
      before do
        stub_request(:post, File.join(host, '/index.php/apps/oauth2/api/v1/token')).to_raise(Faraday::SSLError)
      end

      it 'returns an error wrapping the server error' do
        expect(subject.success).to be_falsy
        expect(subject.result).to eq(:internal_server_error)
        expect(subject.error_payload.class).to be(Faraday::SSLError)
        expect(subject.error_source).to be_a(described_class)
        expect(subject.errors.log_message).to include I18n.t('oauth_client.errors.oauth_returned_http_error')
      end
    end

    context 'with timeout returns internal error' do
      before do
        stub_request(:post, File.join(host, '/index.php/apps/oauth2/api/v1/token')).to_timeout
      end

      it 'returns an error wrapping the server timeout' do
        expect(subject.success).to be_falsy
        expect(subject.result).to eq(:internal_server_error)
        expect(subject.error_payload.class).to be(Faraday::ConnectionFailed)
        expect(subject.error_source).to be_a(described_class)
        expect(subject.errors.log_message).to include I18n.t('oauth_client.errors.oauth_returned_http_error')
      end
    end
  end

  describe '#refresh_token' do
    subject { instance.refresh_token }

    context 'without existing OAuthClientToken' do
      it 'returns an error message' do
        expect(subject.success).to be_falsy
        expect(subject.result).to eq(:error)
        expect(subject.errors.log_message)
          .to include I18n.t('oauth_client.errors.refresh_token_called_without_existing_token')
      end
    end

    context 'with existing OAuthClientToken' do
      before { oauth_client_token }

      context 'when token is stale' do
        before do
          oauth_client_token.update_columns(updated_at: 1.day.ago)
        end

        context 'with successful response from OAuth2 provider (happy path)' do
          before do
            # Simulate a successful authorization returning the tokens
            response_body = {
              access_token: "xyjTDZ...RYvRH",
              token_type: "Bearer",
              expires_in: 3601,
              refresh_token: "xUwFp...1FROJ",
              user_id: "admin"
            }.to_json
            stub_request(:any, File.join(host, '/index.php/apps/oauth2/api/v1/token'))
              .to_return(status: 200, body: response_body, headers: { "content-type" => "application/json; charset=utf-8" })
          end

          it 'returns a valid ClientToken object', :webmock do
            expect(subject.success).to be_truthy
            expect(subject.result).to be_a OAuthClientToken
            expect(subject.result.access_token).to eq("xyjTDZ...RYvRH")

            expect(subject.result.expires_in).to be(3601)
          end
        end

        context 'with invalid access_token data' do
          before do
            # Simulate a token too long
            response_body = {
              access_token: nil, # will fail model validation
              token_type: "Bearer",
              expires_in: 3601,
              refresh_token: "xUwFp...1FROJ",
              user_id: "admin"
            }.to_json
            stub_request(:any, File.join(host, '/index.php/apps/oauth2/api/v1/token'))
              .to_return(status: 200, body: response_body, headers: { "content-type" => "application/json; charset=utf-8" })
          end

          it 'returns dependent error from model validation', :webmock do
            expect(subject.success).to be_falsy
            expect(subject.result).to eq(:error)
            expect(subject.error_payload.class).to be(AttrRequired::AttrMissing)
            expect(subject.error_payload.message).to include("'access_token' required.")
          end
        end

        context 'with server error from OAuth2 provider' do
          before do
            stub_request(:any, File.join(host, '/index.php/apps/oauth2/api/v1/token'))
              .to_return(status: 400,
                         body: { error: "invalid_request" }.to_json,
                         headers: { "content-type" => "application/json; charset=utf-8" })
          end

          it 'returns a server error', :webmock do
            expect(subject.success).to be_falsy
            expect(subject.result).to eq(:bad_request)
            expect(subject.error_payload[:error]).to eq('invalid_request')
          end
        end

        context 'with successful response but invalid data' do
          before do
            # Simulate timeout
            stub_request(:any, File.join(host, '/index.php/apps/oauth2/api/v1/token'))
              .to_timeout
          end

          it 'returns an error wrapping a timeout', :webmock do
            expect(subject.success).to be_falsy
            expect(subject.result).to eq(:internal_server_error)
            expect(subject.error_payload.class).to be(Faraday::ConnectionFailed)
            expect(subject.error_source).to be_a(described_class)
            expect(subject.errors.log_message).to include('Faraday::ConnectionFailed: execution expired')
          end
        end

        context 'with parallel requests for refresh', :aggregate_failures do
          after do
            Storages::Storage.destroy_all
            User.destroy_all
            OAuthClientToken.destroy_all
            OAuthClient.destroy_all
          end

          it 'requests token only once and other thread uses new token' do
            response_body1 = {
              access_token: "xyjTDZ...RYvRH",
              token_type: "Bearer",
              expires_in: 3601,
              refresh_token: "xUwFp...1FROJ",
              user_id: "admin"
            }
            response_body2 = response_body1.dup
            response_body2[:access_token] = "differ...RYvRH"
            request_url = File.join(host, '/index.php/apps/oauth2/api/v1/token')
            stub_request(:any, request_url).to_return(
              { status: 200, body: response_body1.to_json, headers: { "content-type" => "application/json; charset=utf-8" } },
              { status: 200, body: response_body2.to_json, headers: { "content-type" => "application/json; charset=utf-8" } }
            )

            result1 = nil
            result2 = nil
            thread1 = Thread.new do
              ApplicationRecord.connection_pool.with_connection do
                result1 = described_class.new(user:, configuration: storage.oauth_configuration).refresh_token.result
              end
            end
            thread2 = Thread.new do
              ApplicationRecord.connection_pool.with_connection do
                result2 = described_class.new(user:, configuration: storage.oauth_configuration).refresh_token.result
              end
            end
            thread1.join
            thread2.join

            expect(result1.access_token).to eq(response_body1[:access_token])
            expect(result2.access_token).to eq(response_body1[:access_token])
            expect(WebMock).to have_requested(:any, request_url).once
          end

          it 'requests token refresh twice if enough time passes between requests' do
            stub_const("OAuthClients::ConnectionManager::TOKEN_IS_FRESH_DURATION", 2.seconds)
            response_body1 = {
              access_token: "xyjTDZ...RYvRH",
              token_type: "Bearer",
              expires_in: 3601,
              refresh_token: "xUwFp...1FROJ",
              user_id: "admin"
            }
            response_body2 = response_body1.dup
            response_body2[:access_token] = "differ...RYvRH"
            request_url = File.join(host, '/index.php/apps/oauth2/api/v1/token')
            headers = { "content-type" => "application/json; charset=utf-8" }
            stub_request(:any, request_url)
              .to_return(status: 200, body: response_body1.to_json, headers:).then
              .to_return(status: 200, body: response_body2.to_json, headers:)

            result1 = nil
            result2 = nil
            thread1 = Thread.new do
              ApplicationRecord.connection_pool.with_connection do
                sleep(3)
                result1 = described_class.new(user:, configuration: storage.oauth_configuration).refresh_token.result
              end
            end
            thread2 = Thread.new do
              ApplicationRecord.connection_pool.with_connection do
                result2 = described_class.new(user:, configuration: storage.oauth_configuration).refresh_token.result
              end
            end
            thread1.join
            thread2.join

            expect([result1.access_token,
                    result2.access_token]).to contain_exactly(response_body1[:access_token], response_body2[:access_token])
            expect(WebMock).to have_requested(:any, request_url).twice
          end
        end
      end

      context 'when token is fresh' do
        it 'does not send refresh request and respond with existing token', :webmock do
          expect(subject.success).to be_truthy
          expect(subject.result).to eq(oauth_client_token)
          expect { subject }.not_to change(oauth_client_token, :access_token)
        end
      end
    end
  end

  describe '#authorization_state' do
    subject { instance.authorization_state }

    context 'without access token present' do
      it 'returns :failed_authorization' do
        expect(subject).to eq :failed_authorization
      end
    end

    context 'with access token present', :webmock do
      before do
        oauth_client_token
      end

      context 'with access token valid' do
        context 'without other errors or exceptions' do
          before do
            allow(configuration).to receive(:authorization_state_check).and_return(:success)
          end

          it 'returns :connected' do
            expect(subject).to eq :connected
          end
        end

        context 'with some other error or exception' do
          before do
            allow(configuration).to receive(:authorization_state_check).and_return(:error)
          end

          it 'returns :error' do
            expect(subject).to eq :error
          end
        end
      end

      context 'with outdated access token' do
        let(:new_oauth_client_token) { create(:oauth_client_token) }
        let(:refresh_service_result) { ServiceResult.success }

        before do
          allow(configuration).to receive(:authorization_state_check).and_return(:refresh_needed)
          allow(instance).to receive(:refresh_token).and_return(refresh_service_result)
        end

        context 'with valid refresh token' do
          it 'refreshes the access token and returns :connected' do
            expect(subject).to eq :connected
            expect(instance).to have_received(:refresh_token)
          end
        end

        context 'with invalid refresh token' do
          let(:refresh_service_result) do
            data = Storages::StorageErrorData.new(source: nil, payload: { error: 'invalid_request' })
            ServiceResult.failure(result: :bad_request,
                                  errors: Storages::StorageError.new(code: :bad_request, data:))
          end

          it 'refreshes the access token and returns :failed_authorization' do
            expect(subject).to eq :failed_authorization
            expect(instance).to have_received(:refresh_token)
          end
        end

        context 'with some other error while refreshing access token' do
          let(:refresh_service_result) { ServiceResult.failure }

          it 'returns :error' do
            expect(subject).to eq :error
            expect(instance).to have_received(:refresh_token)
          end
        end
      end
    end

    context 'with both invalid access token and refresh token', :webmock do
      it 'returns :failed_authorization' do
        expect(subject).to eq :failed_authorization
      end
    end
  end

  describe '#request_with_token_refresh' do
    let(:yield_service_result) { ServiceResult.success }
    let(:refresh_service_result) { ServiceResult.success }

    subject do
      instance.request_with_token_refresh(oauth_client_token) { yield_service_result }
    end

    before do
      allow(instance).to receive(:refresh_token).and_return(refresh_service_result)
      allow(oauth_client_token).to receive(:reload)
    end

    context 'with yield returning :success' do
      it 'returns a ServiceResult with success, without refreshing the token' do
        expect(subject.success).to be_truthy
        expect(instance).not_to have_received(:refresh_token)
        expect(oauth_client_token).not_to have_received(:reload)
      end
    end

    context 'with yield returning :error' do
      let(:yield_service_result) { ServiceResult.failure(result: :error) }

      it 'returns a ServiceResult with success, without refreshing the token' do
        expect(subject.success).to be_falsy
        expect(subject.result).to be :error
        expect(instance).not_to have_received(:refresh_token)
        expect(oauth_client_token).not_to have_received(:reload)
      end
    end

    context 'with yield returning :unauthorized and the refresh returning a with a success' do
      let(:yield_service_result) { ServiceResult.failure(result: :unauthorized) }

      it 'returns a ServiceResult with success, without refresh' do
        expect(subject.success).to be_falsy
        expect(subject.result).to be :unauthorized
        expect(instance).to have_received(:refresh_token)
        expect(oauth_client_token).to have_received(:reload)
      end
    end

    context 'with yield returning :unauthorized and the refresh returning with a :failure' do
      let(:yield_service_result) { ServiceResult.failure(result: :unauthorized) }
      let(:refresh_service_result) do
        data = Storages::StorageErrorData.new(source: nil, payload: { error: 'invalid_request' })
        ServiceResult.failure(result: :error,
                              errors: Storages::StorageError.new(code: :error, data:))
      end

      it 'returns a ServiceResult with success, without refresh' do
        expect(subject.success).to be_falsy
        expect(subject.result).to be :error
        expect(instance).to have_received(:refresh_token)
        expect(oauth_client_token).not_to have_received(:reload)
      end
    end

    context 'with yield returning :unauthorized first time and :success the second time' do
      let(:yield_double_object) { Object.new }
      let(:yield_service_result1) { ServiceResult.failure(result: :unauthorized) }
      let(:yield_service_result2) { ServiceResult.success }
      let(:refresh_service_result) { ServiceResult.success }

      subject do
        instance.request_with_token_refresh(oauth_client_token) { yield_double_object.yield_twice_method }
      end

      before do
        allow(instance).to receive(:refresh_token).and_return refresh_service_result
        allow(yield_double_object)
          .to receive(:yield_twice_method)
                .and_return(
                  yield_service_result1,
                  yield_service_result2
                )
      end

      it 'returns a ServiceResult with success, without refresh' do
        expect(subject.success).to be_truthy
        expect(subject).to be yield_service_result2
        expect(instance).to have_received(:refresh_token)
        expect(oauth_client_token).to have_received(:reload)
        expect(yield_double_object).to have_received(:yield_twice_method).twice
      end
    end
  end
end


Instruction:
Write RSpec test file for following ruby class
```ruby
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2023 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

require "rack/oauth2"
require "uri/http"

module OAuthClients
  class RedirectUriFromStateService
    def initialize(state:, cookies:)
      @state = state
      @cookies = cookies
    end

    def call
      redirect_uri = oauth_state_cookie

      if redirect_uri.present? && ::API::V3::Utilities::PathHelper::ApiV3Path::same_origin?(redirect_uri)
        ServiceResult.success(result: redirect_uri)
      else
        ServiceResult.failure
      end
    end

    private

    def oauth_state_cookie
      return nil if @state.blank?

      state_cookie = @cookies["oauth_state_#{@state}"]
      return nil if state_cookie.blank?

      state_value = MultiJson.load(@cookies["oauth_state_#{@state}"], symbolize_keys: true)
      state_value[:href]
    end
  end
end

```

Response:
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2023 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

require 'spec_helper'
require 'webmock/rspec'

RSpec.describe OAuthClients::RedirectUriFromStateService, type: :model do
  let(:state) { 'asdf123425' }
  let(:redirect_uri) { File.join(API::V3::Utilities::PathHelper::ApiV3Path::root_url, 'foo/bar') }
  let(:cookies) { { "oauth_state_#{state}": { href: redirect_uri }.to_json }.with_indifferent_access }
  let(:instance) { described_class.new(state:, cookies:) }

  describe '#call' do
    subject { instance.call }

    shared_examples 'failed service result' do
      it 'return a failed service result' do
        expect(subject).to be_failure
      end
    end

    context 'when cookie found' do
      context 'when redirect_uri has same origin' do
        it 'returns the redirect URL value from the cookie' do
          expect(subject).to be_success
        end
      end

      context 'when redirect_uri does not share same origin' do
        let(:redirect_uri) { 'https://some-other-origin.com/bla' }

        it_behaves_like 'failed service result'
      end
    end

    context 'when no cookie present' do
      let(:cookies) { {} }

      it_behaves_like 'failed service result'
    end

    context 'when no state present' do
      let(:state) { nil }

      it_behaves_like 'failed service result'
    end
  end
end


Instruction:
Write RSpec test file for following ruby class
```ruby
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2023 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

# The logic for creating storage was extracted from the controller and put into
# a service: https://dev.to/joker666/ruby-on-rails-pattern-service-objects-b19
# Purpose: create and persist a Storages::Storage record
# Used by: Storages::Admin::StoragesController#create, could also be used by the
# API in the future.
# The comments here are also valid for the other *_service.rb files
module OAuthClients
  class CreateService < ::BaseServices::Create
    protected

    def before_perform(params, _service_result)
      OAuthClient.where(integration: params[:integration]).delete_all

      super
    end
  end
end

```

Response:
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2023 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

require 'spec_helper'
require 'services/base_services/behaves_like_create_service'

RSpec.describe OAuthClients::CreateService, type: :model do
  it_behaves_like 'BaseServices create service' do
    let(:factory) { :oauth_client }

    context 'if another oauth client for the given integration exists' do
      let(:storage) { create(:nextcloud_storage) }
      let!(:existing_client) { create(:oauth_client, integration: storage) }
      let!(:model_instance) { build_stubbed(:oauth_client, integration: storage) }
      let(:call_attributes) { { name: 'Death Star', integration: storage } }

      it 'overwrites the existing oauth client' do
        # Test setup still returns success, but `subject` must be initialized
        expect(subject).to be_success
        expect(OAuthClient.where(id: existing_client.id)).not_to exist
      end
    end
  end
end


Instruction:
Write RSpec test file for following ruby class
```ruby
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2023 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

# See also: create_service.rb for comments
module OAuthClients
  class DeleteService < ::BaseServices::Delete
  end
end

```

Response:
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2023 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

require 'spec_helper'
require 'services/base_services/behaves_like_delete_service'

RSpec.describe OAuthClients::DeleteService, type: :model do
  it_behaves_like 'BaseServices delete service' do
    let(:factory) { :oauth_client }
  end
end


Instruction:
Write RSpec test file for following ruby class
```ruby
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2023 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

module Members
  class SetAttributesService < ::BaseServices::SetAttributes
    include Members::Concerns::RoleAssignment
  end
end

```

Response:
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2023 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

require 'spec_helper'

RSpec.describe Members::SetAttributesService, type: :model do
  let(:user) { build_stubbed(:user) }
  let(:contract_class) do
    contract = double('contract_class')

    allow(contract)
      .to receive(:new)
      .with(member, user, options: {})
      .and_return(contract_instance)

    contract
  end
  let(:contract_instance) do
    double('contract_instance', validate: contract_valid, errors: contract_errors)
  end
  let(:contract_valid) { true }
  let(:contract_errors) do
    double('contract_errors')
  end
  let(:member_valid) { true }
  let(:instance) do
    described_class.new(user:,
                        model: member,
                        contract_class:)
  end
  let(:call_attributes) { {} }
  let(:member) do
    build_stubbed(:member)
  end

  describe 'call' do
    let(:call_attributes) do
      {
        project_id: 5,
        user_id: 3
      }
    end

    before do
      allow(member)
        .to receive(:valid?)
        .and_return(member_valid)

      expect(contract_instance)
        .to receive(:validate)
        .and_return(contract_valid)
    end

    subject { instance.call(call_attributes) }

    it 'is successful' do
      expect(subject.success?).to be_truthy
    end

    it 'sets the attributes' do
      subject

      expect(member.attributes.slice(*member.changed).symbolize_keys)
        .to eql call_attributes
    end

    it 'does not persist the member' do
      expect(member)
        .not_to receive(:save)

      subject
    end

    context 'with changes to the roles do' do
      let(:first_role) { build_stubbed(:project_role) }
      let(:second_role) { build_stubbed(:project_role) }
      let(:third_role) { build_stubbed(:project_role) }

      let(:call_attributes) do
        {
          role_ids: [second_role.id, third_role.id]
        }
      end

      context 'with a persisted record' do
        let(:member) do
          build_stubbed(:member, roles: [first_role, second_role])
        end

        it 'adds the new role and marks the other for destruction' do
          expect(subject.result.member_roles.map(&:role_id)).to contain_exactly(first_role.id, second_role.id, third_role.id)
          expect(subject.result.member_roles.detect { _1.role_id == first_role.id }).to be_marked_for_destruction
        end
      end

      context 'with a new record' do
        let(:member) do
          Member.new
        end

        it 'adds the new role' do
          expect(subject.result.member_roles.map(&:role_id)).to contain_exactly(second_role.id, third_role.id)
        end

        context 'with role_ids not all being present' do
          let(:call_attributes) do
            {
              role_ids: [nil, '', second_role.id, third_role.id]
            }
          end

          it 'ignores the empty values' do
            expect(subject.result.member_roles.map(&:role_id)).to contain_exactly(second_role.id, third_role.id)
          end
        end
      end
    end
  end
end


Instruction:
Write RSpec test file for following ruby class
```ruby
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2023 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

class Members::UpdateService < BaseServices::Update
  include Members::Concerns::CleanedUp
  include Members::Concerns::NotificationSender

  around_call :post_process

  private

  def post_process
    service_call = yield

    return unless service_call.success?

    member = service_call.result

    if member.principal.is_a?(Group)
      update_group_roles(member)
    else
      send_notification(member)
    end
  end

  def update_group_roles(member)
    Groups::UpdateRolesService
      .new(member.principal, current_user: user, contract_class: EmptyContract)
      .call(member:, send_notifications: send_notifications?, message: notification_message)
  end

  def event_type
    OpenProject::Events::MEMBER_UPDATED
  end
end

```

Response:
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2023 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

require 'spec_helper'
require 'services/base_services/behaves_like_update_service'

RSpec.describe Members::UpdateService, type: :model do
  it_behaves_like 'BaseServices update service' do
    let(:call_attributes) do
      {
        role_ids: ["2"],
        notification_message: "Wish you where **here**.",
        send_notifications: false
      }
    end

    before do
      allow(OpenProject::Notifications)
        .to receive(:send)
    end

    describe 'if successful' do
      it 'sends a notification' do
        subject

        expect(OpenProject::Notifications)
          .to have_received(:send)
          .with(OpenProject::Events::MEMBER_UPDATED,
                member: model_instance,
                message: call_attributes[:notification_message],
                send_notifications: call_attributes[:send_notifications])
      end
    end

    context 'if the SetAttributeService is unsuccessful' do
      let(:set_attributes_success) { false }

      it 'sends no notifications' do
        subject

        expect(OpenProject::Notifications)
          .not_to have_received(:send)
      end
    end

    context 'when the member is invalid' do
      let(:model_save_result) { false }

      it 'sends no notifications' do
        subject

        expect(OpenProject::Notifications)
          .not_to have_received(:send)
      end
    end
  end
end


Instruction:
Write RSpec test file for following ruby class
```ruby
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2023 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

class Members::CreateService < BaseServices::Create
  include Members::Concerns::NotificationSender

  around_call :post_process

  def post_process
    service_call = yield

    return unless service_call.success?

    member = service_call.result

    add_group_memberships(member)
    send_notification(member)
  end

  protected

  def add_group_memberships(member)
    return unless member.principal.is_a?(Group)

    Groups::CreateInheritedRolesService
      .new(member.principal, current_user: user, contract_class: EmptyContract)
      .call(user_ids: member.principal.user_ids, send_notifications: false, project_ids: [member.project_id])
  end

  def event_type
    OpenProject::Events::MEMBER_CREATED
  end
end

```

Response:
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2023 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

require 'spec_helper'
require 'services/base_services/behaves_like_create_service'

RSpec.describe Members::CreateService, type: :model do
  let(:user1) { build_stubbed(:user) }
  let(:user2) { build_stubbed(:user) }
  let(:group) do
    build_stubbed(:group).tap do |g|
      allow(g)
        .to receive(:user_ids)
              .and_return([user1.id, user2.id])
    end
  end
  let!(:inherited_roles_service) do
    instance_double(Groups::CreateInheritedRolesService).tap do |inherited_roles_service|
      allow(Groups::CreateInheritedRolesService)
        .to receive(:new)
              .and_return(inherited_roles_service)

      allow(inherited_roles_service)
        .to receive(:call)
    end
  end
  let!(:notifications) do
    allow(OpenProject::Notifications)
      .to receive(:send)
  end

  it_behaves_like 'BaseServices create service' do
    let(:call_attributes) do
      {
        project_id: "1",
        user_id: "2",
        role_ids: ["2"],
        notification_message: "Wish you where **here**.",
        send_notifications: true
      }
    end

    describe 'if successful' do
      it 'sends a notification' do
        subject

        expect(OpenProject::Notifications)
          .to have_received(:send)
                .with(OpenProject::Events::MEMBER_CREATED,
                      member: model_instance,
                      message: call_attributes[:notification_message],
                      send_notifications: true)
      end

      describe 'for a group' do
        let!(:model_instance) { build_stubbed(:member, principal: group) }

        it "generates the members and roles for the group's users" do
          subject

          expect(Groups::CreateInheritedRolesService)
            .to have_received(:new)
                  .with(group,
                        current_user: user,
                        contract_class: EmptyContract)

          expect(inherited_roles_service)
            .to have_received(:call)
                  .with(user_ids: group.user_ids,
                        project_ids: [model_instance.project_id],
                        send_notifications: false)
        end
      end
    end

    context 'if the SetAttributeService is unsuccessful' do
      let(:set_attributes_success) { false }

      it 'sends no notification' do
        subject

        expect(OpenProject::Notifications)
          .not_to have_received(:send)
      end

      describe 'for a group' do
        let!(:model_instance) { build_stubbed(:member, principal: group) }

        it "does not create any inherited roles" do
          subject

          expect(Groups::CreateInheritedRolesService)
            .not_to have_received(:new)
        end
      end
    end

    context 'when the member is invalid' do
      let(:model_save_result) { false }

      it 'sends no notification' do
        subject

        expect(OpenProject::Notifications)
          .not_to have_received(:send)
      end

      context 'for a group' do
        let!(:model_instance) { build_stubbed(:member, principal: group) }

        it "does not create any inherited roles" do
          subject

          expect(Groups::CreateInheritedRolesService)
            .not_to have_received(:new)
        end
      end
    end
  end
end


Instruction:
Write RSpec test file for following ruby class
```ruby
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2023 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

class Members::DeleteService < BaseServices::Delete
  include Members::Concerns::CleanedUp

  protected

  def after_perform(service_call)
    super(service_call).tap do |call|
      member = call.result

      cleanup_for_group(member)
      send_notification(member)
    end
  end

  def send_notification(member)
    ::OpenProject::Notifications.send(OpenProject::Events::MEMBER_DESTROYED,
                                      member:)
  end

  def cleanup_for_group(member)
    return unless member.principal.is_a?(Group)

    Groups::CleanupInheritedRolesService
      .new(member.principal, current_user: user, contract_class: EmptyContract)
      .call
  end
end

```

Response:
#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2012-2023 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

require 'spec_helper'
require 'services/base_services/behaves_like_delete_service'

RSpec.describe Members::DeleteService, type: :model do
  it_behaves_like 'BaseServices delete service' do
    let(:principal) { user }
    before do
      model_instance.principal = principal

      allow(OpenProject::Notifications)
        .to receive(:send)
    end

    let!(:cleanup_service_instance) do
      instance = instance_double(Members::CleanupService, call: nil)

      allow(Members::CleanupService)
        .to receive(:new)
        .with(principal, model_instance.project_id)
        .and_return(instance)

      instance
    end

    describe '#call' do
      context 'when contract validates and the model is destroyed successfully' do
        it 'calls the cleanup service' do
          service_call

          expect(cleanup_service_instance)
            .to have_received(:call)
        end

        it 'sends a notification' do
          service_call

          expect(OpenProject::Notifications)
            .to have_received(:send)
            .with(OpenProject::Events::MEMBER_DESTROYED, member: model_instance)
        end

        context 'when the model`s principal is a group' do
          let(:principal) { build_stubbed(:group) }
          let!(:cleanup_inherited_roles_service_instance) do
            instance = instance_double(Groups::CleanupInheritedRolesService, call: nil)

            allow(Groups::CleanupInheritedRolesService)
              .to receive(:new)
              .with(principal,
                    current_user: user,
                    contract_class: EmptyContract)
              .and_return(instance)

            instance
          end

          it 'calls the cleanup inherited roles service' do
            service_call

            expect(cleanup_inherited_roles_service_instance)
              .to have_received(:call)
          end
        end
      end
    end
  end
end


Instruction:
Write RSpec test file for following ruby class
```ruby
# -- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2010-2023 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
# ++

module WorkPackageMembers
  class SetAttributesService < ::BaseServices::SetAttributes
    prepend WorkPackageMembers::Concerns::RoleAssignment

    private

    def set_attributes(params)
      super

      model.change_by_system do
        model.project = model.entity&.project
      end
    end
  end
end

```

Response:
# -- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2010-2023 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
# ++

require 'spec_helper'

RSpec.describe WorkPackageMembers::SetAttributesService, type: :model do
  let(:user) { build_stubbed(:user) }
  let(:work_package) { build_stubbed(:work_package) }
  let(:member) do
    new_member
  end
  let(:new_member) { Member.new }
  let(:existing_member) { build_stubbed(:work_package_member) }

  let(:contract_class) do
    allow(WorkPackageMembers::CreateContract)
      .to receive(:new)
            .with(member, user, options: {})
            .and_return(contract_instance)

    WorkPackageMembers::CreateContract
  end
  let(:contract_instance) do
    instance_double(WorkPackageMembers::CreateContract, validate: contract_valid, errors: contract_errors)
  end
  let(:contract_valid) { true }
  let(:contract_errors) do
    instance_double(ActiveModel::Errors)
  end
  let(:instance) do
    described_class.new(user:,
                        model: member,
                        contract_class:)
  end

  describe 'call' do
    let(:call_attributes) do
      {
        user_id: 3,
        entity: work_package
      }
    end

    before do
      allow(contract_instance)
        .to receive(:validate)
              .and_return(contract_valid)

      allow(member)
        .to receive(:save)
              .and_return true
    end

    subject { instance.call(call_attributes) }

    it 'is successful' do
      expect(subject).to be_success
    end

    it 'does not persist the member' do
      subject

      expect(member)
        .not_to have_received(:save)
    end

    context 'for a new record' do
      it 'sets the attributes and also takes the project_id from the work package' do
        subject

        expect(member.attributes.slice(*member.changed).symbolize_keys)
          .to eql(user_id: 3, entity_id: work_package.id, entity_type: 'WorkPackage', project_id: work_package.project_id)
      end

      it 'marks the project_id to be changed by the system' do
        subject

        expect(member.changed_by_system)
          .to eql('project_id' => [nil, member.project_id])
      end
    end

    # Changing the entity should not really happen in reality but if it does, this is what happens.
    context 'for a persisted record' do
      let(:member) { existing_member }

      it 'sets the attributes and also takes the project_id from the work package' do
        subject

        expect(member.attributes.slice(*member.changed).symbolize_keys)
          .to eql(user_id: 3, entity_id: work_package.id, project_id: work_package.project_id)
      end

      it 'marks the project_id to be changed by the system' do
        subject

        expect(member.changed_by_system)
          .to eql('project_id' => [member.project_id_was, member.project_id])
      end
    end

    context 'if the contract is invalid' do
      let(:contract_valid) { false }

      it 'is unsuccessful' do
        expect(subject).not_to be_success
      end

      it 'returns the errors of the contract' do
        expect(subject.errors).to eql contract_errors
      end

      it 'does not persist the member' do
        subject

        expect(member)
          .not_to have_received(:save)
      end
    end

    context 'with changes to the roles' do
      let(:first_role) { build_stubbed(:project_role) }
      let(:second_role) { build_stubbed(:project_role) }
      let(:third_role) { build_stubbed(:project_role) }

      let(:call_attributes) do
        {
          role_ids: [second_role.id, third_role.id]
        }
      end

      context 'with a persisted record' do
        let(:member) do
          build_stubbed(:work_package_member, roles: [first_role, second_role])
        end

        it 'adds the new role and marks the other for destruction' do
          expect(subject.result.member_roles.map(&:role_id)).to contain_exactly(first_role.id, second_role.id, third_role.id)
          expect(subject.result.member_roles.detect { _1.role_id == first_role.id }).to be_marked_for_destruction
        end

        context 'when a role being assigned is already inherited via a group' do
          let(:member) do
            build_stubbed(:work_package_member, roles: [first_role, second_role, third_role])
          end

          before do
            allow(member.member_roles.detect { _1.role_id == third_role.id })
              .to receive(:inherited_from)
                    .and_return(true)
          end

          it 'still adds the role and marks the ones not added for destruction' do
            membership = subject.result

            expect(membership.member_roles.map(&:role_id))
              .to contain_exactly(first_role.id,
                                  second_role.id,
                                  third_role.id, # One is inherited
                                  third_role.id) # The other one isn't

            expect(membership.member_roles.select(&:marked_for_destruction?).map(&:role_id))
              .to contain_exactly(first_role.id)
          end
        end
      end

      context 'with a new record' do
        let(:member) do
          Member.new
        end

        it 'adds the new role' do
          expect(subject.result.member_roles.map(&:role_id)).to contain_exactly(second_role.id, third_role.id)
        end

        context 'with role_ids not all being present' do
          let(:call_attributes) do
            {
              role_ids: [nil, '', second_role.id, third_role.id]
            }
          end

          it 'ignores the empty values' do
            expect(subject.result.member_roles.map(&:role_id)).to contain_exactly(second_role.id, third_role.id)
          end
        end
      end

      context 'with attempting to sent `roles`' do
        let(:call_attributes) do
          {
            roles: [second_role, third_role]
          }
        end

        context 'with a new record' do
          let(:member) do
            Member.new
          end

          it 'sets the new role' do
            expect(subject.result.roles).to contain_exactly(second_role, third_role)
          end
        end

        context 'with a persisted record' do
          let(:member) do
            build_stubbed(:work_package_member, roles: [second_role])
          end

          it 'raises an error' do
            expect { subject }
              .to raise_error(ArgumentError)
          end
        end
      end
    end
  end
end


Instruction:
Write RSpec test file for following ruby class
```ruby
# -- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2010-2023 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
# ++

class WorkPackageMembers::CreateOrUpdateService
  def initialize(user:, contract_class: nil, contract_options: {})
    self.user = user
    self.contract_class = contract_class
    self.contract_options = contract_options
  end

  def call(entity:, user_id:, **)
    actual_service(entity, user_id)
      .call(entity:, user_id:, **)
  end

  private

  attr_accessor :user, :contract_class, :contract_options

  def actual_service(entity, user_id)
    if (member = Member.find_by(entity:, principal: user_id))
      WorkPackageMembers::UpdateService
        .new(user:, model: member, contract_class:, contract_options:)
    else
      WorkPackageMembers::CreateService
        .new(user:, contract_class:, contract_options:)
    end
  end
end

```

Response:
# frozen_string_literal: true

# -- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2023 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
# ++

require 'spec_helper'

RSpec.describe WorkPackageMembers::CreateOrUpdateService do
  let(:user) { build_stubbed(:user) }
  let(:role) { build_stubbed(:view_work_package_role) }
  let(:work_package) { build_stubbed(:work_package) }
  let(:instance) { described_class.new(user:) }

  let(:params) { { user_id: user, roles: [role], entity: work_package } }
  let(:service_result) { instance_double(ServiceResult) }

  subject(:service_call) { instance.call(**params) }

  before do
    allow(Member)
      .to receive(:find_by)
            .with(entity: work_package, principal: user)
            .and_return(existing_member)
  end

  context 'when the user has no work_package_member for that work package' do
    let(:create_instance) { instance_double(WorkPackageMembers::CreateService) }
    let(:existing_member) { nil }

    it 'calls the WorkPackageMembers::CreateService' do
      allow(WorkPackageMembers::CreateService).to receive(:new).and_return(create_instance)
      allow(create_instance).to receive(:call).and_return(service_result)

      service_call

      expect(create_instance)
        .to have_received(:call)
              .with(**params)
    end
  end

  context 'when the user already has a work_package_member for that work package' do
    let(:update_instance) { instance_double(WorkPackageMembers::UpdateService) }
    let(:existing_member) { build_stubbed(:work_package_member) }

    it 'calls the WorkPackageMembers::UpdateService' do
      allow(WorkPackageMembers::UpdateService).to receive(:new).and_return(update_instance)
      allow(update_instance).to receive(:call).and_return(service_result)

      service_call

      expect(update_instance)
        .to have_received(:call)
              .with(**params)
    end
  end
end


Instruction:
Write RSpec test file for following ruby class
```ruby
# -- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2010-2023 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
# ++

class WorkPackageMembers::UpdateService < BaseServices::Update
  include Members::Concerns::CleanedUp

  protected

  def after_perform(service_call)
    return service_call unless service_call.success?

    work_package_member = service_call.result

    update_group_roles(work_package_member) if work_package_member.principal.is_a?(Group)

    service_call
  end

  def update_group_roles(work_package_member)
    Groups::UpdateRolesService
      .new(work_package_member.principal,
           current_user: user,
           contract_class: EmptyContract)
      .call(member: work_package_member,
            send_notifications: false,
            message: nil)
  end
end

```

Response:
# frozen_string_literal: true

# -- copyright
# OpenProject is an open source project management software.
# Copyright (C) 2023 the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
# ++

require 'spec_helper'
require 'services/base_services/behaves_like_update_service'

RSpec.describe WorkPackageMembers::UpdateService do
  let!(:groups_update_roles_service) do
    instance_double(Groups::UpdateRolesService).tap do |service_double|
      allow(Groups::UpdateRolesService)
        .to receive(:new)
              .and_return(service_double)

      allow(service_double)
        .to receive(:call)
    end
  end

  it_behaves_like 'BaseServices update service' do
    let(:model_class) { Member }
    let!(:model_instance) { build_stubbed(:work_package_member, principal:) }
    let(:principal) { build_stubbed(:user) }
    let(:role) { build_stubbed(:view_work_package_role) }
    let(:call_attributes) { { roles: [role] } }

    context 'when successful' do
      context 'when the member being updates is a User' do
        it "doesn't attempt any group member post-processing" do
          instance_call

          expect(Groups::UpdateRolesService)
            .not_to have_received(:new)
        end
      end

      context 'when the member being updated is a Group' do
        let(:principal) { build_stubbed(:group) }

        it "updates the group member's roles" do
          instance_call

          expect(Groups::UpdateRolesService)
            .to have_received(:new)
                  .with(principal,
                        current_user: user,
                        contract_class: EmptyContract)

          expect(groups_update_roles_service)
            .to have_received(:call)
        end
      end
    end
  end
end