diff --git a/CHANGELOG.md b/CHANGELOG.md index e2e91ffab..6da3aa3fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,7 @@ * [#2894](https://github.com/ruby-grape/grape/pull/2894): Read the request method once in `default_status` instead of asking through `post?` and `delete?` - [@ericproulx](https://github.com/ericproulx). * [#2895](https://github.com/ruby-grape/grape/pull/2895): Cut per-request work out of the endpoint, validation and router paths: scan the router's compiled union by capture number rather than by name, skip the Array boxing in `AttributesIterator` for a flat scope, read a coerced attribute once instead of three times, and resolve the formatter's config and the coercers' type checks once at build time - [@ericproulx](https://github.com/ericproulx). * [#2900](https://github.com/ruby-grape/grape/pull/2900): Remove the deprecations announced in 3.2 and 3.3: `Grape::Router.normalize_path`, Hash access on middleware `Options` and their `DEFAULT_OPTIONS` constants, the positional options Hash for `auth`/`http_basic`/`desc`, a Hash returned from a `rescue_from` handler, and `@option` on validators (see UPGRADING) - [@ericproulx](https://github.com/ericproulx). +* [#2896](https://github.com/ruby-grape/grape/pull/2896): Bring test suite line coverage to 100% - [@dblock](https://github.com/dblock). * Your contribution here. #### Fixes diff --git a/spec/grape/api/instance_spec.rb b/spec/grape/api/instance_spec.rb index 22bfc1148..2af3df688 100644 --- a/spec/grape/api/instance_spec.rb +++ b/spec/grape/api/instance_spec.rb @@ -105,4 +105,16 @@ def app expect(last_response.body).to eq 'Not found! (2)' end end + + describe '.cascade?' do + subject(:an_instance) do + Class.new(Grape::API::Instance) do + cascade true + end + end + + it 'returns the configured cascade setting' do + expect(an_instance.compile!.cascade?).to be(true) + end + end end diff --git a/spec/grape/api_spec.rb b/spec/grape/api_spec.rb index 13c491dbd..d5c3666eb 100644 --- a/spec/grape/api_spec.rb +++ b/spec/grape/api_spec.rb @@ -2090,6 +2090,17 @@ def hello expect(last_response.body).to eql 'Hello, world.' end + it 'includes all known helpers in scope when called with no args and no block' do + subject.helpers do + def hello + 'Hello, world.' + end + end + + new_mod = subject.helpers + expect(new_mod.instance_methods).to include(:hello) + end + it 'is scopable' do subject.helpers do def generic diff --git a/spec/grape/dsl/request_response_spec.rb b/spec/grape/dsl/request_response_spec.rb index 790c24ad4..67bfeefbb 100644 --- a/spec/grape/dsl/request_response_spec.rb +++ b/spec/grape/dsl/request_response_spec.rb @@ -134,6 +134,12 @@ expect(subject.inheritable_setting.all_rescue_handler).to eq(with_block) end + it 'converts a String :with option to a Symbol' do + subject.rescue_from :all, with: 'my_handler' + expect(subject.inheritable_setting.rescue_all?).to be(true) + expect(subject.inheritable_setting.all_rescue_handler).to eq(:my_handler) + end + it 'abort if :with option value is not Symbol, String or Proc' do expect { subject.rescue_from :all, with: 1234 }.to raise_error(ArgumentError, "with: #{integer_class_name}, expected Symbol, String or Proc") end diff --git a/spec/grape/endpoint_spec.rb b/spec/grape/endpoint_spec.rb index 5c6521c2f..7e4bcb6eb 100644 --- a/spec/grape/endpoint_spec.rb +++ b/spec/grape/endpoint_spec.rb @@ -1042,6 +1042,9 @@ def memoized subject.before do # Placeholder end + subject.params do + optional :id, type: Integer + end subject.get do 'hello' end @@ -1064,7 +1067,10 @@ def memoized have_attributes(name: 'endpoint_run_filters.grape', payload: { endpoint: a_kind_of(described_class), filters: a_collection_containing_exactly(an_instance_of(Proc)), type: :before }), - have_attributes(name: 'endpoint_render.grape', payload: { endpoint: a_kind_of(described_class) }), + have_attributes(name: 'endpoint_run_validators.grape', payload: { endpoint: a_kind_of(described_class), + validators: an_instance_of(Array), + request: a_kind_of(Grape::Request) }), + have_attributes(name: 'endpoint_render.grape', payload: { endpoint: a_kind_of(described_class) }), have_attributes(name: 'endpoint_run.grape', payload: { endpoint: a_kind_of(described_class), env: an_instance_of(Hash) }), have_attributes(name: 'format_response.grape', payload: { env: an_instance_of(Hash), @@ -1078,7 +1084,10 @@ def memoized have_attributes(name: 'endpoint_run_filters.grape', payload: { endpoint: a_kind_of(described_class), filters: a_collection_containing_exactly(an_instance_of(Proc)), type: :before }), - have_attributes(name: 'endpoint_render.grape', payload: { endpoint: a_kind_of(described_class) }), + have_attributes(name: 'endpoint_run_validators.grape', payload: { endpoint: a_kind_of(described_class), + validators: an_instance_of(Array), + request: a_kind_of(Grape::Request) }), + have_attributes(name: 'endpoint_render.grape', payload: { endpoint: a_kind_of(described_class) }), have_attributes(name: 'format_response.grape', payload: { env: an_instance_of(Hash), formatter: a_kind_of(Module) }) ) @@ -1126,5 +1135,20 @@ def memoized it 'does not raise an error' do expect { subject }.not_to raise_error end + + context 'when the endpoint has handled a request (env is set)' do + it 'includes the route origin in the inspect output' do + inspect_output = nil + api = Class.new(Grape::API) do + get('/hello') do + inspect_output = inspect + 'world' + end + end + env = Rack::MockRequest.env_for('/hello') + api.call(env) + expect(inspect_output).to eq("#{described_class} in '/hello' endpoint") + end + end end end diff --git a/spec/grape/error_formatter/base_spec.rb b/spec/grape/error_formatter/base_spec.rb new file mode 100644 index 000000000..5bcc20cd1 --- /dev/null +++ b/spec/grape/error_formatter/base_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +describe Grape::ErrorFormatter::Base do + describe '.format_structured_message' do + it 'raises NotImplementedError' do + expect { described_class.format_structured_message({}) }.to raise_error(NotImplementedError) + end + end +end diff --git a/spec/grape/exceptions/validation_errors_spec.rb b/spec/grape/exceptions/validation_errors_spec.rb index 731ae800c..8c0839f9b 100644 --- a/spec/grape/exceptions/validation_errors_spec.rb +++ b/spec/grape/exceptions/validation_errors_spec.rb @@ -37,6 +37,13 @@ end end + describe '#to_json' do + it 'returns the JSON representation of #as_json' do + error = described_class.new(exceptions: [validation_error]) + expect(error.to_json).to eq(error.as_json.to_json) + end + end + describe '#full_messages' do context 'with errors' do subject { described_class.new(exceptions: [validation_error_1, validation_error_2]).full_messages } diff --git a/spec/grape/exceptions/validation_spec.rb b/spec/grape/exceptions/validation_spec.rb index c36f3244f..ab147129b 100644 --- a/spec/grape/exceptions/validation_spec.rb +++ b/spec/grape/exceptions/validation_spec.rb @@ -52,4 +52,11 @@ expect(error.message).to eq('raw message') end end + + describe '#as_json' do + it 'returns the string representation of the error' do + error = described_class.new(params: ['id'], message: 'raw message') + expect(error.as_json).to eq(error.to_s) + end + end end diff --git a/spec/grape/formatter/base_spec.rb b/spec/grape/formatter/base_spec.rb new file mode 100644 index 000000000..c88321365 --- /dev/null +++ b/spec/grape/formatter/base_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +describe Grape::Formatter::Base do + describe '.call' do + it 'raises NotImplementedError' do + expect { described_class.call({}, {}) }.to raise_error(NotImplementedError) + end + end +end diff --git a/spec/grape/formatter/json_spec.rb b/spec/grape/formatter/json_spec.rb new file mode 100644 index 000000000..401279eb9 --- /dev/null +++ b/spec/grape/formatter/json_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +describe Grape::Formatter::Json do + describe '.call' do + it 'returns the string representation of a Grape::PrecompiledJson object' do + precompiled = Grape::PrecompiledJson.new('{"a":1}') + expect(described_class.call(precompiled, {})).to eq('{"a":1}') + end + + it 'calls #to_json when the object responds to it' do + object = { a: 1 } + expect(described_class.call(object, {})).to eq(object.to_json) + end + + it 'falls back to Grape::Json.dump when the object does not respond to #to_json' do + object = Object.new + allow(object).to receive(:respond_to?).with(:to_json).and_return(false) + expect(described_class.call(object, {})).to eq(Grape::Json.dump(object)) + end + end +end diff --git a/spec/grape/formatter/serializable_hash_spec.rb b/spec/grape/formatter/serializable_hash_spec.rb new file mode 100644 index 000000000..86d549d40 --- /dev/null +++ b/spec/grape/formatter/serializable_hash_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +describe Grape::Formatter::SerializableHash do + describe '.call' do + it 'returns the string representation of a Grape::PrecompiledJson object' do + precompiled = Grape::PrecompiledJson.new('{"a":1}') + expect(described_class.call(precompiled, {})).to eq('{"a":1}') + end + + it 'returns a String object unchanged' do + expect(described_class.call('already a string', {})).to eq('already a string') + end + + it 'serializes an object responding to #serializable_hash' do + object = Class.new do + def serializable_hash + { a: 1 } + end + end.new + expect(described_class.call(object, {})).to eq(Grape::Json.dump(a: 1)) + end + + it 'serializes an Array of objects responding to #serializable_hash' do + klass = Class.new do + def initialize(value) + @value = value + end + + def serializable_hash + { value: @value } + end + end + objects = [klass.new(1), klass.new(2)] + expect(described_class.call(objects, {})).to eq(Grape::Json.dump([{ value: 1 }, { value: 2 }])) + end + + it 'serializes a Hash, recursively serializing its values' do + object = Class.new do + def serializable_hash + { a: 1 } + end + end.new + expect(described_class.call({ nested: object }, {})).to eq(Grape::Json.dump(nested: { a: 1 })) + end + + it 'serializes a Hash, leaving non-serializable leaf values untouched' do + expect(described_class.call({ plain: 'value' }, {})).to eq(Grape::Json.dump(plain: 'value')) + end + + it 'calls #to_json when the object responds to it and is not otherwise serializable' do + object = 1234 + expect(described_class.call(object, {})).to eq(object.to_json) + end + + it 'falls back to Grape::Json.dump when the object is not serializable and does not respond to #to_json' do + object = Object.new + allow(object).to receive(:respond_to?).and_call_original + allow(object).to receive(:respond_to?).with(:to_json).and_return(false) + expect(described_class.call(object, {})).to eq(Grape::Json.dump(object)) + end + end +end diff --git a/spec/grape/middleware/base_spec.rb b/spec/grape/middleware/base_spec.rb index 8bd9f59e8..55c441179 100644 --- a/spec/grape/middleware/base_spec.rb +++ b/spec/grape/middleware/base_spec.rb @@ -251,6 +251,23 @@ def after expect(last_response.headers['X-Test-Before']).to eq('Hi') expect(last_response.headers['X-Test-After']).to eq('Bye') end + + context 'when the downstream app returns a Rack::Response' do + let(:app) do + context = self + + Rack::Builder.app do + use context.example_ware + run ->(_) { Rack::Response.new('Yeah', 200, {}) } + end + end + + it 'merges the header onto the Rack::Response' do + get '/' + expect(last_response.headers['X-Test-Before']).to eq('Hi') + expect(last_response.headers['X-Test-After']).to eq('Bye') + end + end end context 'header overwrite' do diff --git a/spec/grape/middleware/error_spec.rb b/spec/grape/middleware/error_spec.rb index fdcfa785f..0c1efda9f 100644 --- a/spec/grape/middleware/error_spec.rb +++ b/spec/grape/middleware/error_spec.rb @@ -460,4 +460,32 @@ def initialize end end end + + describe '#error!' do + it 'sets the status and renders a formatted error response' do + env = Rack::MockRequest.env_for('/') + endpoint = Spec::Support::EndpointFaker::FakerAPI.endpoints.first + env[Grape::Env::API_ENDPOINT] = endpoint + middleware = described_class.new(->(_env) {}) + middleware.instance_variable_set(:@env, env) + + expect(endpoint).to receive(:status).with(422) + response = middleware.__send__(:error!, 'failure', 422) + expect(response.status).to eq(422) + expect(response.body).to eq(['failure']) + end + end + + describe '#error?' do + subject(:middleware) { described_class.new(->(_env) {}) } + + it 'returns true for a Grape::Exceptions::ErrorResponse' do + response = Grape::Exceptions::ErrorResponse.new(message: 'oops', status: 500, headers: {}) + expect(middleware.__send__(:error?, response)).to be true + end + + it 'returns false for any other object' do + expect(middleware.__send__(:error?, 'not an error')).to be false + end + end end diff --git a/spec/grape/middleware/filter_spec.rb b/spec/grape/middleware/filter_spec.rb new file mode 100644 index 000000000..531ec5b0b --- /dev/null +++ b/spec/grape/middleware/filter_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +describe Grape::Middleware::Filter do + let(:before_proc) { -> {} } + let(:after_proc) { -> {} } + let(:app) { ->(_env) { [200, {}, ['Hi there.']] } } + let(:middleware) { described_class.new(app, before: before_proc, after: after_proc) } + + describe '#before' do + it 'instance_evals the :before option against the app' do + expect(app).to receive(:instance_eval) do |&block| + expect(block).to eq(before_proc) + end + middleware.before + end + + context 'when no :before option is given' do + let(:middleware) { described_class.new(app) } + + it 'does nothing' do + expect(app).not_to receive(:instance_eval) + middleware.before + end + end + end + + describe '#after' do + it 'instance_evals the :after option against the app' do + expect(app).to receive(:instance_eval) do |&block| + expect(block).to eq(after_proc) + end + middleware.after + end + + context 'when no :after option is given' do + let(:middleware) { described_class.new(app) } + + it 'does nothing' do + expect(app).not_to receive(:instance_eval) + middleware.after + end + end + end +end diff --git a/spec/grape/middleware/formatter_spec.rb b/spec/grape/middleware/formatter_spec.rb index 65b8877f4..c3ea04ddb 100644 --- a/spec/grape/middleware/formatter_spec.rb +++ b/spec/grape/middleware/formatter_spec.rb @@ -281,6 +281,20 @@ def to_xml expect(subject.env[Rack::RACK_REQUEST_FORM_HASH]['is_boolean']).to be true expect(subject.env[Rack::RACK_REQUEST_FORM_HASH]['string']).to eq('thing') end + + it "merges into a pre-existing rack.request.form_hash when parsing the body from #{method}" do + subject.call( + Rack::PATH_INFO => '/info', + Rack::REQUEST_METHOD => method, + 'CONTENT_TYPE' => content_type, + Rack::RACK_INPUT => io, + 'CONTENT_LENGTH' => io.length.to_s, + Rack::RACK_REQUEST_FORM_HASH => { 'existing' => 'value' } + ) + expect(subject.env[Rack::RACK_REQUEST_FORM_HASH]['existing']).to eq('value') + expect(subject.env[Rack::RACK_REQUEST_FORM_HASH]['is_boolean']).to be true + expect(subject.env[Rack::RACK_REQUEST_FORM_HASH]['string']).to eq('thing') + end end context 'when Content-Type is not supported' do diff --git a/spec/grape/middleware/stack/middleware_spec.rb b/spec/grape/middleware/stack/middleware_spec.rb index ef0f76e0a..3fe877b04 100644 --- a/spec/grape/middleware/stack/middleware_spec.rb +++ b/spec/grape/middleware/stack/middleware_spec.rb @@ -2,6 +2,28 @@ describe Grape::Middleware::Stack::Middleware do let(:middleware_class) { Class.new } + let(:foo_middleware) { Class.new } + let(:bar_middleware) { Class.new } + + describe '#==' do + it 'compares equal to another Middleware wrapping the same class' do + first = described_class.new(foo_middleware, [], nil) + second = described_class.new(foo_middleware, [42], proc {}) + expect(first).to eq(second) + end + + it 'compares unequal to another Middleware wrapping a different class' do + first = described_class.new(foo_middleware, [], nil) + second = described_class.new(bar_middleware, [], nil) + expect(first).not_to eq(second) + end + end + + describe '#inspect' do + it "returns the wrapped class's #to_s" do + expect(described_class.new(foo_middleware, [], nil).inspect).to eq(foo_middleware.to_s) + end + end describe '#hash' do it 'matches for two entries wrapping the same class' do diff --git a/spec/grape/namespace_spec.rb b/spec/grape/namespace_spec.rb new file mode 100644 index 000000000..bd2f9e907 --- /dev/null +++ b/spec/grape/namespace_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +describe Grape::Namespace do + subject(:namespace) { described_class.new('foo', requirements: { id: /\d+/ }, desc: 'bar') } + + describe '#==' do + it 'is equal to another Namespace with the same space, requirements, and options' do + other = described_class.new('foo', requirements: { id: /\d+/ }, desc: 'bar') + expect(namespace).to eq(other) + end + + it 'is not equal to another Namespace with a different space' do + other = described_class.new('bar', requirements: { id: /\d+/ }, desc: 'bar') + expect(namespace).not_to eq(other) + end + + it 'is not equal to a non-Namespace object' do + expect(namespace).not_to eq('foo') + end + end + + describe '#hash' do + it 'is the same for two Namespaces with the same space, requirements, and options' do + other = described_class.new('foo', requirements: { id: /\d+/ }, desc: 'bar') + expect(namespace.hash).to eq(other.hash) + end + + it 'differs for Namespaces with different spaces' do + other = described_class.new('bar', requirements: { id: /\d+/ }, desc: 'bar') + expect(namespace.hash).not_to eq(other.hash) + end + end +end diff --git a/spec/grape/params_builder/base_spec.rb b/spec/grape/params_builder/base_spec.rb new file mode 100644 index 000000000..938d1b7c7 --- /dev/null +++ b/spec/grape/params_builder/base_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +describe Grape::ParamsBuilder::Base do + describe '.call' do + it 'raises NotImplementedError' do + expect { described_class.call({}) }.to raise_error(NotImplementedError) + end + end +end diff --git a/spec/grape/parser/base_spec.rb b/spec/grape/parser/base_spec.rb new file mode 100644 index 000000000..666caecc1 --- /dev/null +++ b/spec/grape/parser/base_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +describe Grape::Parser::Base do + describe '.call' do + it 'raises NotImplementedError' do + expect { described_class.call({}, {}) }.to raise_error(NotImplementedError) + end + end +end diff --git a/spec/grape/router/mustermann_pattern_spec.rb b/spec/grape/router/mustermann_pattern_spec.rb new file mode 100644 index 000000000..b084bc32e --- /dev/null +++ b/spec/grape/router/mustermann_pattern_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +describe Grape::Router::MustermannPattern do + describe '{name} capture syntax' do + it 'captures a single path segment' do + pattern = described_class.new('/foo/{bar}') + expect(pattern.params('/foo/baz')).to eq('bar' => 'baz') + end + end + + describe '{+name} named splat syntax' do + it 'captures the remainder of the path as a single named value' do + pattern = described_class.new('/foo/{+bar}') + expect(pattern.params('/foo/a/b')).to eq('bar' => 'a/b') + end + + context 'when the named splat is literally called "splat"' do + it 'captures the remainder of the path as an Array, like the plain splat node' do + pattern = described_class.new('/foo/{+splat}') + expect(pattern.params('/foo/a/b')).to eq('splat' => ['a/b']) + end + end + end +end diff --git a/spec/grape/router/pattern/path_spec.rb b/spec/grape/router/pattern/path_spec.rb index fd5341d8a..79fdf8267 100644 --- a/spec/grape/router/pattern/path_spec.rb +++ b/spec/grape/router/pattern/path_spec.rb @@ -95,4 +95,11 @@ def path_settings(**attrs) end end end + + describe '#to_s' do + it 'concatenates the origin and the suffix' do + path = described_class.new('/foo', nil, path_settings) + expect(path.to_s).to eq("#{path.origin}#{path.suffix}") + end + end end diff --git a/spec/grape/router_spec.rb b/spec/grape/router_spec.rb index 5b0efe14b..8352f841a 100644 --- a/spec/grape/router_spec.rb +++ b/spec/grape/router_spec.rb @@ -134,6 +134,15 @@ def response_body expect(status).to eq(404) expect(headers['X-Cascade']).to eq('pass') end + + it 'marks the request as cascaded when only an ANY route responds' do + append_route(cascading, '*') + router.compile! + + status, headers, = router.call(Rack::MockRequest.env_for('/hello')) + expect(status).to eq(404) + expect(headers['X-Cascade']).to eq('pass') + end end # Regression: routing args were seeded once (`||=`) and merged in place, so diff --git a/spec/grape/serve_stream/file_body_spec.rb b/spec/grape/serve_stream/file_body_spec.rb index 44725217d..7971a3e7a 100644 --- a/spec/grape/serve_stream/file_body_spec.rb +++ b/spec/grape/serve_stream/file_body_spec.rb @@ -1,6 +1,37 @@ # frozen_string_literal: true describe Grape::ServeStream::FileBody do + describe '#to_path' do + it 'returns the path' do + expect(described_class.new('/tmp/a').to_path).to eq('/tmp/a') + end + end + + describe '#each' do + it 'yields the file contents in chunks' do + Tempfile.create('grape-file-body-spec') do |file| + file.write('hello world') + file.flush + + chunks = [] + # rubocop:disable Style/MapIntoArray -- FileBody#each is not Enumerable, so #map is unavailable here. + described_class.new(file.path).each { |chunk| chunks << chunk } + # rubocop:enable Style/MapIntoArray + expect(chunks.join).to eq('hello world') + end + end + end + + describe '#==' do + it 'is true for two bodies with an equal path' do + expect(described_class.new('/tmp/a')).to eq(described_class.new(+'/tmp/a')) + end + + it 'is false for two bodies with a different path' do + expect(described_class.new('/tmp/a')).not_to eq(described_class.new('/tmp/b')) + end + end + describe '#hash' do it 'matches for two bodies with an equal path' do expect(described_class.new('/tmp/a').hash).to eq described_class.new(+'/tmp/a').hash diff --git a/spec/grape/serve_stream/sendfile_response_spec.rb b/spec/grape/serve_stream/sendfile_response_spec.rb new file mode 100644 index 000000000..fdc8c4b07 --- /dev/null +++ b/spec/grape/serve_stream/sendfile_response_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +describe Grape::ServeStream::SendfileResponse do + subject(:response) { described_class.new(body) } + + context 'when the body responds to #to_path' do + let(:body) { Grape::ServeStream::FileBody.new('/tmp/a') } + + describe '#respond_to?' do + it 'is true for :to_path' do + expect(response.respond_to?(:to_path)).to be true + end + end + + describe '#to_path' do + it "delegates to the body's #to_path" do + expect(response.to_path).to eq('/tmp/a') + end + end + end + + context 'when the body does not respond to #to_path' do + let(:body) { 'plain string body' } + + describe '#respond_to?' do + it 'is false for :to_path' do + expect(response.respond_to?(:to_path)).to be false + end + + it 'falls back to the default behavior for other methods' do + expect(response.respond_to?(:to_s)).to be true + end + end + end +end diff --git a/spec/grape/util/lazy/value_array_spec.rb b/spec/grape/util/lazy/value_array_spec.rb new file mode 100644 index 000000000..c7e662cd2 --- /dev/null +++ b/spec/grape/util/lazy/value_array_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +describe Grape::Util::Lazy::ValueArray do + describe '#evaluate' do + it 'evaluates every element of the array' do + value_array = described_class.new([1, 2, { a: 1 }]) + expect(value_array.evaluate).to eq([1, 2, { 'a' => 1 }]) + end + end + + describe '#[]' do + it 'returns the Lazy::Value at the given index' do + value_array = described_class.new([1, 2]) + expect(value_array[0].evaluate).to eq(1) + end + + it 'returns a nil Lazy::Value for an out-of-bounds index' do + value_array = described_class.new([1, 2]) + expect(value_array[10].evaluate).to be_nil + end + end +end diff --git a/spec/grape/util/lazy/value_enumerable_spec.rb b/spec/grape/util/lazy/value_enumerable_spec.rb new file mode 100644 index 000000000..4f687fe2d --- /dev/null +++ b/spec/grape/util/lazy/value_enumerable_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +describe Grape::Util::Lazy::ValueEnumerable do + describe '#[]=' do + it 'wraps a Hash value in a ValueHash' do + value_array = Grape::Util::Lazy::ValueArray.new([{ a: 1 }]) + expect(value_array[0]).to be_a(Grape::Util::Lazy::ValueHash) + end + + it 'wraps an Array value in a ValueArray' do + value_array = Grape::Util::Lazy::ValueArray.new([[1, 2]]) + expect(value_array[0]).to be_a(described_class) + end + + it 'wraps any other value in a plain Value' do + value_array = Grape::Util::Lazy::ValueArray.new([1]) + expect(value_array[0]).to be_a(Grape::Util::Lazy::Value) + end + end + + describe '#fetch' do + it 'reduces a list of access keys down to the reached node' do + value_hash = Grape::Util::Lazy::ValueHash.new(a: { b: 1 }) + expect(value_hash.fetch(%i[a b]).evaluate).to eq(1) + end + end +end diff --git a/spec/grape/validations/attributes_iterator_spec.rb b/spec/grape/validations/attributes_iterator_spec.rb new file mode 100644 index 000000000..f0085ed53 --- /dev/null +++ b/spec/grape/validations/attributes_iterator_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +describe Grape::Validations::AttributesIterator do + describe '#yield_attributes' do + it 'raises NotImplementedError' do + scope = instance_double(Grape::Validations::ParamsScope, array_depth: 0) + iterator = described_class.new([], scope) + expect { iterator.__send__(:yield_attributes, {}) }.to raise_error(NotImplementedError) + end + end +end diff --git a/spec/grape/validations/params_scope_spec.rb b/spec/grape/validations/params_scope_spec.rb index 5b81015f8..099e9a6f0 100644 --- a/spec/grape/validations/params_scope_spec.rb +++ b/spec/grape/validations/params_scope_spec.rb @@ -1966,4 +1966,33 @@ def initialize(value) end end end + + describe Grape::Validations::ParamsScope::Attr do + let(:scope) { instance_double(Grape::Validations::ParamsScope) } + + describe '.attr_key' do + it 'returns a plain value unchanged' do + expect(described_class.attr_key(:id)).to eq(:id) + end + + it 'recurses into the #key of a nested Attr' do + inner = described_class.new(:id, scope) + outer = described_class.new(inner, scope) + expect(described_class.attr_key(outer)).to eq(:id) + end + + it 'transforms the values of a Hash of nested declared params' do + inner = described_class.new(:id, scope) + result = described_class.attr_key(nested: [inner]) + expect(result).to eq(nested: [:id]) + end + end + + describe '.attrs_keys' do + it 'maps declared params to their keys' do + declared_params = [described_class.new(:id, scope), described_class.new(:name, scope)] + expect(described_class.attrs_keys(declared_params)).to eq(%i[id name]) + end + end + end end diff --git a/spec/grape/validations/types/dry_type_coercer_spec.rb b/spec/grape/validations/types/dry_type_coercer_spec.rb new file mode 100644 index 000000000..35d81c2ce --- /dev/null +++ b/spec/grape/validations/types/dry_type_coercer_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +describe Grape::Validations::Types::DryTypeCoercer do + describe '.collection_coercer_for' do + it 'returns ArrayCoercer for an Array instance' do + expect(described_class.collection_coercer_for([])).to eq(Grape::Validations::Types::ArrayCoercer) + end + + it 'returns SetCoercer for a Set instance' do + expect(described_class.collection_coercer_for(Set.new)).to eq(Grape::Validations::Types::SetCoercer) + end + + it 'raises an ArgumentError for any other type' do + expect { described_class.collection_coercer_for({}) }.to raise_error(ArgumentError, /Unknown type/) + end + end +end diff --git a/spec/grape/validations/types/variant_collection_coercer_spec.rb b/spec/grape/validations/types/variant_collection_coercer_spec.rb new file mode 100644 index 000000000..506a6eae3 --- /dev/null +++ b/spec/grape/validations/types/variant_collection_coercer_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +describe Grape::Validations::Types::VariantCollectionCoercer do + describe '#to_s' do + it 'renders an Array type as Array[...]' do + coercer = described_class.new([Integer, String]) + expect(coercer.to_s).to eq('Array[Integer, String]') + end + + it 'renders a Set type as Set[...]' do + coercer = described_class.new(Set[Integer, String]) + expect(coercer.to_s).to eq('Set[Integer, String]') + end + end + + describe '#call' do + it 'returns nil for a non-Array value' do + coercer = described_class.new([Integer, String]) + expect(coercer.call('not an array')).to be_nil + end + + it 'coerces each member via the member coercer when no method is given' do + coercer = described_class.new([Integer, String]) + expect(coercer.call(%w[1 abc])).to eq([1, 'abc']) + end + + it 'coerces the whole collection via the given method' do + method = ->(value) { value.map(&:upcase) } + coercer = described_class.new([String], method) + expect(coercer.call(%w[a b])).to eq(%w[A B]) + end + + it 'returns a Set when the declared types are a Set' do + coercer = described_class.new(Set[Integer, String]) + expect(coercer.call(%w[1 abc])).to eq(Set[1, 'abc']) + end + end +end diff --git a/spec/grape/validations/validators/base_spec.rb b/spec/grape/validations/validators/base_spec.rb new file mode 100644 index 000000000..e997a8bb6 --- /dev/null +++ b/spec/grape/validations/validators/base_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +describe Grape::Validations::Validators::Base do + describe '#validate_param!' do + it 'raises NotImplementedError' do + scope = Grape::Validations::ParamsScope.new(api: Class.new(Grape::API)) + validator = described_class.new(:id, {}, false, scope, {}) + expect { validator.__send__(:validate_param!, :id, { id: 'value' }) }.to raise_error(NotImplementedError) + end + end +end diff --git a/spec/grape/validations/validators/default_validator_spec.rb b/spec/grape/validations/validators/default_validator_spec.rb index 831f15b28..44e4bbc79 100644 --- a/spec/grape/validations/validators/default_validator_spec.rb +++ b/spec/grape/validations/validators/default_validator_spec.rb @@ -552,4 +552,27 @@ def app expect(JSON.parse(last_response.body)).to eq(expected) end end + + describe 'default value that is not duplicable' do + let(:app) do + Class.new(Grape::API) do + require 'singleton' + singleton_class = Class.new { include Singleton } + + default_format :json + + params do + optional :callback, default: singleton_class.instance + end + get '/non_duplicable_default' do + { callback: params[:callback].class.name } + end + end + end + + it 'uses the singleton object directly, without attempting to #dup it' do + get '/non_duplicable_default' + expect(last_response.status).to eq(200) + end + end end diff --git a/spec/grape/validations_spec.rb b/spec/grape/validations_spec.rb index e5e902504..ebe51b742 100644 --- a/spec/grape/validations_spec.rb +++ b/spec/grape/validations_spec.rb @@ -104,6 +104,21 @@ def define_optional_using end end + context 'optional :none, except: using Grape::Entity documentation' do + before do + documentation = { field_a: { type: String }, field_b: { type: String } } + subject.params do + optional :none, except: :field_a, using: documentation + end + subject.get('/optional_none') { 'optional none works' } + end + + it 'still requires the excepted field to be optional (unaffected by the :none context)' do + get '/optional_none', field_a: 'woof' + expect(last_response.status).to eq(200) + end + end + context 'required' do before do subject.params do diff --git a/spec/integration/multi_json/json_spec.rb b/spec/integration/multi_json/json_spec.rb index 7a7dd309d..93531b2fc 100644 --- a/spec/integration/multi_json/json_spec.rb +++ b/spec/integration/multi_json/json_spec.rb @@ -27,4 +27,16 @@ expect(JSON.parse(response.body)).to eq('received' => 'hi') end end + + describe '.dump' do + it 'serializes an object to a JSON string via the active multi_json backend' do + expect(JSON.parse(subject.dump(a: 1))).to eq('a' => 1) + end + end + + describe '.parse' do + it 'deserializes a JSON string via the active multi_json backend' do + expect(subject.parse('{"a":1}')).to eq('a' => 1) + end + end end