Back to Repositories

Validating Mutually Exclusive Parameters in ruby-grape/grape

This test suite validates the ExactlyOneOfValidator functionality in Grape API, ensuring proper validation of mutually exclusive parameters in various request contexts and nested structures.

Test Coverage Overview

The test suite provides comprehensive coverage of the ExactlyOneOfValidator implementation in Grape API. It validates parameter exclusivity across multiple scenarios:

  • Basic parameter validation
  • Nested parameter validation in hashes and arrays
  • Custom error message handling
  • Required vs optional parameter contexts
  • Deep nesting validation scenarios

Implementation Analysis

The testing approach utilizes RSpec’s behavior-driven development patterns with detailed context blocks and shared examples. Key implementation aspects include:

  • Dynamic API class creation for test isolation
  • Error handling verification
  • Parameter transformation testing
  • Nested data structure validation

Technical Details

Testing infrastructure includes:

  • RSpec test framework
  • Grape API validation framework
  • JSON response parsing
  • HTTP status code verification
  • Custom error message handling

Best Practices Demonstrated

The test suite exemplifies several testing best practices:

  • Comprehensive edge case coverage
  • Clear test organization and naming
  • Isolated test scenarios
  • Thorough validation of error states
  • Consistent assertion patterns

ruby-grape/grape

spec/grape/validations/validators/exactly_one_of_validator_spec.rb

            
# frozen_string_literal: true

describe Grape::Validations::Validators::ExactlyOneOfValidator do
  let_it_be(:app) do
    Class.new(Grape::API) do
      rescue_from Grape::Exceptions::ValidationErrors do |e|
        error!(e.errors.transform_keys! { |key| key.join(',') }, 400)
      end

      params do
        optional :beer
        optional :wine
        optional :grapefruit
        exactly_one_of :beer, :wine, :grapefruit
      end
      post do
      end

      params do
        optional :beer
        optional :wine
        optional :grapefruit
        optional :other
        exactly_one_of :beer, :wine, :grapefruit
      end
      post 'mixed-params' do
      end

      params do
        optional :beer
        optional :wine
        optional :grapefruit
        exactly_one_of :beer, :wine, :grapefruit, message: 'you should choose one'
      end
      post '/custom-message' do
      end

      params do
        requires :item, type: Hash do
          optional :beer
          optional :wine
          optional :grapefruit
          exactly_one_of :beer, :wine, :grapefruit
        end
      end
      post '/nested-hash' do
      end

      params do
        optional :item, type: Hash do
          optional :beer
          optional :wine
          optional :grapefruit
          exactly_one_of :beer, :wine, :grapefruit
        end
      end
      post '/nested-optional-hash' do
      end

      params do
        requires :items, type: Array do
          optional :beer
          optional :wine
          optional :grapefruit
          exactly_one_of :beer, :wine, :grapefruit
        end
      end
      post '/nested-array' do
      end

      params do
        requires :items, type: Array do
          requires :nested_items, type: Array do
            optional :beer, :wine, :grapefruit, type: Grape::API::Boolean
            exactly_one_of :beer, :wine, :grapefruit
          end
        end
      end
      post '/deeply-nested-array' do
      end
    end
  end

  describe '#validate!' do
    subject(:validate) { post path, params }

    context 'when all params are present' do
      let(:path) { '/' }
      let(:params) { { beer: true, wine: true, grapefruit: true } }

      it 'returns a validation error' do
        validate
        expect(last_response.status).to eq 400
        expect(JSON.parse(last_response.body)).to eq(
          'beer,wine,grapefruit' => ['are mutually exclusive']
        )
      end

      context 'mixed with other params' do
        let(:path) { '/mixed-params' }
        let(:params) { { beer: true, wine: true, grapefruit: true, other: true } }

        it 'returns a validation error' do
          validate
          expect(last_response.status).to eq 400
          expect(JSON.parse(last_response.body)).to eq(
            'beer,wine,grapefruit' => ['are mutually exclusive']
          )
        end
      end
    end

    context 'when a subset of params are present' do
      let(:path) { '/' }
      let(:params) { { beer: true, grapefruit: true } }

      it 'returns a validation error' do
        validate
        expect(last_response.status).to eq 400
        expect(JSON.parse(last_response.body)).to eq(
          'beer,grapefruit' => ['are mutually exclusive']
        )
      end
    end

    context 'when custom message is specified' do
      let(:path) { '/custom-message' }
      let(:params) { { beer: true, wine: true } }

      it 'returns a validation error' do
        validate
        expect(last_response.status).to eq 400
        expect(JSON.parse(last_response.body)).to eq(
          'beer,wine' => ['you should choose one']
        )
      end
    end

    context 'when exacly one param is present' do
      let(:path) { '/' }
      let(:params) { { beer: true, somethingelse: true } }

      it 'does not return a validation error' do
        validate
        expect(last_response.status).to eq 201
      end
    end

    context 'when none of the params are present' do
      let(:path) { '/' }
      let(:params) { { somethingelse: true } }

      it 'returns a validation error' do
        validate
        expect(last_response.status).to eq 400
        expect(JSON.parse(last_response.body)).to eq(
          'beer,wine,grapefruit' => ['are missing, exactly one parameter must be provided']
        )
      end
    end

    context 'when params are nested inside required hash' do
      let(:path) { '/nested-hash' }
      let(:params) { { item: { beer: true, wine: true } } }

      it 'returns a validation error with full names of the params' do
        validate
        expect(last_response.status).to eq 400
        expect(JSON.parse(last_response.body)).to eq(
          'item[beer],item[wine]' => ['are mutually exclusive']
        )
      end
    end

    context 'when params are nested inside optional hash' do
      let(:path) { '/nested-optional-hash' }

      context 'when params are passed' do
        let(:params) { { item: { beer: true, wine: true } } }

        it 'returns a validation error with full names of the params' do
          validate
          expect(last_response.status).to eq 400
          expect(JSON.parse(last_response.body)).to eq(
            'item[beer],item[wine]' => ['are mutually exclusive']
          )
        end
      end

      context 'when params are empty' do
        let(:params) { { other: true } }

        it 'does not return a validation error' do
          validate
          expect(last_response.status).to eq 201
        end
      end
    end

    context 'when params are nested inside array' do
      let(:path) { '/nested-array' }
      let(:params) { { items: [{ beer: true, wine: true }, { wine: true, grapefruit: true }] } }

      it 'returns a validation error with full names of the params' do
        validate
        expect(last_response.status).to eq 400
        expect(JSON.parse(last_response.body)).to eq(
          'items[0][beer],items[0][wine]' => [
            'are mutually exclusive'
          ],
          'items[1][wine],items[1][grapefruit]' => [
            'are mutually exclusive'
          ]
        )
      end
    end

    context 'when params are deeply nested' do
      let(:path) { '/deeply-nested-array' }
      let(:params) { { items: [{ nested_items: [{ beer: true, wine: true }] }] } }

      it 'returns a validation error with full names of the params' do
        validate
        expect(last_response.status).to eq 400
        expect(JSON.parse(last_response.body)).to eq(
          'items[0][nested_items][0][beer],items[0][nested_items][0][wine]' => [
            'are mutually exclusive'
          ]
        )
      end
    end
  end
end