diff options
Diffstat (limited to 'spec/support_specs')
-rw-r--r-- | spec/support_specs/graphql/arguments_spec.rb | 71 | ||||
-rw-r--r-- | spec/support_specs/graphql/field_selection_spec.rb | 62 | ||||
-rw-r--r-- | spec/support_specs/graphql/var_spec.rb | 34 | ||||
-rw-r--r-- | spec/support_specs/helpers/graphql_helpers_spec.rb | 274 | ||||
-rw-r--r-- | spec/support_specs/helpers/stub_feature_flags_spec.rb | 25 | ||||
-rw-r--r-- | spec/support_specs/matchers/exceed_query_limit_helpers_spec.rb | 226 |
6 files changed, 683 insertions, 9 deletions
diff --git a/spec/support_specs/graphql/arguments_spec.rb b/spec/support_specs/graphql/arguments_spec.rb new file mode 100644 index 00000000000..ffb58503a0e --- /dev/null +++ b/spec/support_specs/graphql/arguments_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Graphql::Arguments do + it 'returns a blank string if the arguments are blank' do + args = described_class.new({}) + + expect("#{args}").to be_blank + end + + it 'returns a serialized arguments if the arguments are not blank' do + units = described_class.new({ temp: :CELSIUS, time: :MINUTES }) + args = described_class.new({ temp: 180, time: 45, units: units }) + + expect("#{args}").to eq('temp: 180, time: 45, units: {temp: CELSIUS, time: MINUTES}') + end + + it 'supports merge with +' do + lhs = described_class.new({ a: 1, b: 2 }) + rhs = described_class.new({ b: 3, c: 4 }) + + expect(lhs + rhs).to eq({ a: 1, b: 3, c: 4 }) + end + + it 'supports merge with + and a string' do + lhs = described_class.new({ a: 1, b: 2 }) + rhs = 'x: no' + + expect(lhs + rhs).to eq('a: 1, b: 2, x: no') + end + + it 'supports merge with + and a string when empty' do + lhs = described_class.new({}) + rhs = 'x: no' + + expect(lhs + rhs).to eq('x: no') + end + + it 'supports merge with + and an empty string' do + lhs = described_class.new({ a: 1 }) + rhs = '' + + expect(lhs + rhs).to eq({ a: 1 }) + end + + it 'serializes all values correctly' do + args = described_class.new({ + array: [1, 2.5, "foo", nil, true, false, :BAR, { power: :on }], + hash: { a: 1, b: 2, c: 3 }, + int: 42, + float: 2.7, + string: %q[he said "no"], + enum: :OFF, + null: nil, # we expect this to be omitted - absence is the same as explicit nullness + bool_true: true, + bool_false: false, + var: ::Graphql::Var.new('x', 'Int') + }) + + expect(args.to_s).to eq([ + %q(array: [1,2.5,"foo",null,true,false,BAR,{power: on}]), + %q(hash: {a: 1, b: 2, c: 3}), + 'int: 42, float: 2.7', + %q(string: "he said \\"no\\""), + 'enum: OFF', + 'boolTrue: true, boolFalse: false', + 'var: $x' + ].join(', ')) + end +end diff --git a/spec/support_specs/graphql/field_selection_spec.rb b/spec/support_specs/graphql/field_selection_spec.rb new file mode 100644 index 00000000000..8818e59e598 --- /dev/null +++ b/spec/support_specs/graphql/field_selection_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Graphql::FieldSelection do + it 'can report on the paths that are selected' do + selection = described_class.new({ + 'foo' => nil, + 'bar' => nil, + 'quux' => { + 'a' => nil, + 'b' => { 'x' => nil, 'y' => nil } + }, + 'qoox' => { + 'q' => nil, + 'r' => { 's' => { 't' => nil } } + } + }) + + expect(selection.paths).to include( + %w[foo], + %w[quux a], + %w[quux b x], + %w[qoox r s t] + ) + end + + it 'can serialize a field selection nicely' do + selection = described_class.new({ + 'foo' => nil, + 'bar' => nil, + 'quux' => { + 'a' => nil, + 'b' => { 'x' => nil, 'y' => nil } + }, + 'qoox' => { + 'q' => nil, + 'r' => { 's' => { 't' => nil } } + } + }) + + expect(selection.to_s).to eq(<<~FRAG.strip) + foo + bar + quux { + a + b { + x + y + } + } + qoox { + q + r { + s { + t + } + } + } + FRAG + end +end diff --git a/spec/support_specs/graphql/var_spec.rb b/spec/support_specs/graphql/var_spec.rb new file mode 100644 index 00000000000..f708f01a11e --- /dev/null +++ b/spec/support_specs/graphql/var_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Graphql::Var do + subject(:var) { described_class.new('foo', 'Int') } + + it 'associates a name with a type and an initially empty value' do + expect(var).to have_attributes( + name: 'foo', + type: 'Int', + value: be_nil + ) + end + + it 'has a correct signature' do + expect(var).to have_attributes(sig: '$foo: Int') + end + + it 'implements to_graphql_value as $name' do + expect(var.to_graphql_value).to eq('$foo') + end + + it 'can set a value using with, returning a new object' do + with_value = var.with(42) + + expect(with_value).to have_attributes(name: 'foo', type: 'Int', value: 42) + expect(var).to have_attributes(value: be_nil) + end + + it 'returns an object suitable for passing to post_graphql(variables:)' do + expect(var.with(17).to_h).to eq('foo' => 17) + end +end diff --git a/spec/support_specs/helpers/graphql_helpers_spec.rb b/spec/support_specs/helpers/graphql_helpers_spec.rb index bc777621674..a9fe5b8d196 100644 --- a/spec/support_specs/helpers/graphql_helpers_spec.rb +++ b/spec/support_specs/helpers/graphql_helpers_spec.rb @@ -5,6 +5,278 @@ require 'spec_helper' RSpec.describe GraphqlHelpers do include GraphqlHelpers + # Normalize irrelevant whitespace to make comparison easier + def norm(query) + query.tr("\n", ' ').gsub(/\s+/, ' ').strip + end + + describe 'graphql_dig_at' do + it 'transforms symbol keys to graphql field names' do + data = { 'camelCased' => 'names' } + + expect(graphql_dig_at(data, :camel_cased)).to eq('names') + end + + it 'supports integer indexing' do + data = { 'array' => [:boom, { 'id' => :hooray! }, :boom] } + + expect(graphql_dig_at(data, :array, 1, :id)).to eq(:hooray!) + end + + it 'gracefully degrades to nil' do + data = { 'project' => { 'mergeRequest' => nil } } + + expect(graphql_dig_at(data, :project, :merge_request, :id)).to be_nil + end + + it 'supports implicitly flat-mapping traversals' do + data = { + 'foo' => { + 'nodes' => [ + { 'bar' => { 'nodes' => [{ 'id' => 1 }, { 'id' => 2 }] } }, + { 'bar' => { 'nodes' => [{ 'id' => 3 }, { 'id' => 4 }] } }, + { 'bar' => nil } + ] + }, + 'irrelevant' => 'the field is a red-herring' + } + + expect(graphql_dig_at(data, :foo, :nodes, :bar, :nodes, :id)).to eq([1, 2, 3, 4]) + end + end + + describe 'var' do + it 'allocates a fresh name for each var' do + a = var('Int') + b = var('Int') + + expect(a.name).not_to eq(b.name) + end + + it 'can be used to construct correct signatures' do + a = var('Int') + b = var('String!') + + q = with_signature([a, b], '{ foo bar }') + + expect(q).to eq("query(#{a.to_graphql_value}: Int, #{b.to_graphql_value}: String!) { foo bar }") + end + + it 'can be used to pass arguments to fields' do + a = var('ID!') + + q = graphql_query_for(:project, { full_path: a }, :id) + + expect(norm(q)).to eq("{ project(fullPath: #{a.to_graphql_value}){ id } }") + end + + it 'can associate values with variables' do + a = var('Int') + + expect(a.with(3).to_h).to eq(a.name => 3) + end + + it 'does not mutate the variable when providing a value' do + a = var('Int') + three = a.with(3) + + expect(three.value).to eq(3) + expect(a.value).to be_nil + end + + it 'can associate many values with variables' do + a = var('Int').with(3) + b = var('String').with('foo') + + expect(serialize_variables([a, b])).to eq({ a.name => 3, b.name => 'foo' }.to_json) + end + end + + describe '.query_nodes' do + it 'can produce a basic connection selection' do + selection = query_nodes(:users) + + expected = query_graphql_path([:users, :nodes], all_graphql_fields_for('User', max_depth: 1)) + + expect(selection).to eq(expected) + end + + it 'allows greater depth' do + selection = query_nodes(:users, max_depth: 2) + + expected = query_graphql_path([:users, :nodes], all_graphql_fields_for('User', max_depth: 2)) + + expect(selection).to eq(expected) + end + + it 'accepts fields' do + selection = query_nodes(:users, :id) + + expected = query_graphql_path([:users, :nodes], :id) + + expect(selection).to eq(expected) + end + + it 'accepts arguments' do + args = { username: 'foo' } + selection = query_nodes(:users, args: args) + + expected = query_graphql_path([[:users, args], :nodes], all_graphql_fields_for('User', max_depth: 1)) + + expect(selection).to eq(expected) + end + + it 'accepts arguments and fields' do + selection = query_nodes(:users, :id, args: { username: 'foo' }) + + expected = query_graphql_path([[:users, { username: 'foo' }], :nodes], :id) + + expect(selection).to eq(expected) + end + + it 'accepts explicit type name' do + selection = query_nodes(:members, of: 'User') + + expected = query_graphql_path([:members, :nodes], all_graphql_fields_for('User', max_depth: 1)) + + expect(selection).to eq(expected) + end + + it 'can optionally provide pagination info' do + selection = query_nodes(:users, include_pagination_info: true) + + expected = query_graphql_path([:users, "#{page_info_selection} nodes"], all_graphql_fields_for('User', max_depth: 1)) + + expect(selection).to eq(expected) + end + end + + describe '.query_graphql_path' do + it 'can build nested paths' do + selection = query_graphql_path(%i[foo bar wibble_wobble], :id) + + expected = norm(<<-GQL) + foo{ + bar{ + wibbleWobble{ + id + } + } + } + GQL + + expect(norm(selection)).to eq(expected) + end + + it 'can insert arguments at any point' do + selection = query_graphql_path( + [:foo, [:bar, { quux: true }], [:wibble_wobble, { eccentricity: :HIGH }]], + :id + ) + + expected = norm(<<-GQL) + foo{ + bar(quux: true){ + wibbleWobble(eccentricity: HIGH){ + id + } + } + } + GQL + + expect(norm(selection)).to eq(expected) + end + end + + describe '.attributes_to_graphql' do + it 'can serialize hashes to literal arguments' do + x = var('Int') + args = { + an_array: [1, nil, "foo", true, [:foo, :bar]], + a_hash: { + nested: true, + value: "bar" + }, + an_int: 42, + a_float: 0.1, + a_string: "wibble", + an_enum: :LOW, + null: nil, + a_bool: false, + a_var: x + } + + literal = attributes_to_graphql(args) + + expect(norm(literal)).to eq(norm(<<~EXP)) + anArray: [1,null,"foo",true,[foo,bar]], + aHash: {nested: true, value: "bar"}, + anInt: 42, + aFloat: 0.1, + aString: "wibble", + anEnum: LOW, + aBool: false, + aVar: #{x.to_graphql_value} + EXP + end + end + + describe '.all_graphql_fields_for' do + it 'returns a FieldSelection' do + selection = all_graphql_fields_for('User', max_depth: 1) + + expect(selection).to be_a(::Graphql::FieldSelection) + end + + it 'returns nil if the depth is too shallow' do + selection = all_graphql_fields_for('User', max_depth: 0) + + expect(selection).to be_nil + end + + it 'can select just the scalar fields' do + selection = all_graphql_fields_for('User', max_depth: 1) + paths = selection.paths.map(&:join) + + # A sample, tested using include to save churn as fields are added + expect(paths) + .to include(*%w[avatarUrl email groupCount id location name state username webPath webUrl]) + + expect(selection.paths).to all(have_attributes(size: 1)) + end + + it 'selects only as far as 3 levels by default' do + selection = all_graphql_fields_for('User') + + expect(selection.paths).to all(have_attributes(size: (be <= 3))) + + # Representative sample + expect(selection.paths).to include( + %w[userPermissions createSnippet], + %w[todos nodes id], + %w[starredProjects nodes name], + %w[authoredMergeRequests count], + %w[assignedMergeRequests pageInfo startCursor] + ) + end + + it 'selects only as far as requested' do + selection = all_graphql_fields_for('User', max_depth: 2) + + expect(selection.paths).to all(have_attributes(size: (be <= 2))) + end + + it 'omits fields that have required arguments' do + selection = all_graphql_fields_for('DesignCollection', max_depth: 3) + + expect(selection.paths).not_to be_empty + + expect(selection.paths).not_to include( + %w[designAtVersion id] + ) + end + end + describe '.graphql_mutation' do shared_examples 'correct mutation definition' do it 'returns correct mutation definition' do @@ -15,7 +287,7 @@ RSpec.describe GraphqlHelpers do } } MUTATION - variables = %q({"updateAlertStatusInput":{"projectPath":"test/project"}}) + variables = { "updateAlertStatusInput" => { "projectPath" => "test/project" } } is_expected.to eq(GraphqlHelpers::MutationDefinition.new(query, variables)) end diff --git a/spec/support_specs/helpers/stub_feature_flags_spec.rb b/spec/support_specs/helpers/stub_feature_flags_spec.rb index 57dd3015f5e..8629e895fd1 100644 --- a/spec/support_specs/helpers/stub_feature_flags_spec.rb +++ b/spec/support_specs/helpers/stub_feature_flags_spec.rb @@ -5,19 +5,20 @@ require 'spec_helper' RSpec.describe StubFeatureFlags do let_it_be(:dummy_feature_flag) { :dummy_feature_flag } - # We inject dummy feature flag defintion - # to ensure that we strong validate it's usage - # as well - before(:all) do - definition = Feature::Definition.new( + let_it_be(:dummy_definition) do + Feature::Definition.new( nil, name: dummy_feature_flag, type: 'development', - # we allow ambigious usage of `default_enabled:` - default_enabled: [false, true] + default_enabled: false ) + end - Feature::Definition.definitions[dummy_feature_flag] = definition + # We inject dummy feature flag defintion + # to ensure that we strong validate it's usage + # as well + before(:all) do + Feature::Definition.definitions[dummy_feature_flag] = dummy_definition end after(:all) do @@ -47,6 +48,10 @@ RSpec.describe StubFeatureFlags do it { expect(Feature.disabled?(feature_name)).not_to eq(expected_result) } context 'default_enabled does not impact feature state' do + before do + allow(dummy_definition).to receive(:default_enabled).and_return(true) + end + it { expect(Feature.enabled?(feature_name, default_enabled: true)).to eq(expected_result) } it { expect(Feature.disabled?(feature_name, default_enabled: true)).not_to eq(expected_result) } end @@ -79,6 +84,10 @@ RSpec.describe StubFeatureFlags do it { expect(Feature.disabled?(feature_name, actor(tested_actor))).not_to eq(expected_result) } context 'default_enabled does not impact feature state' do + before do + allow(dummy_definition).to receive(:default_enabled).and_return(true) + end + it { expect(Feature.enabled?(feature_name, actor(tested_actor), default_enabled: true)).to eq(expected_result) } it { expect(Feature.disabled?(feature_name, actor(tested_actor), default_enabled: true)).not_to eq(expected_result) } end diff --git a/spec/support_specs/matchers/exceed_query_limit_helpers_spec.rb b/spec/support_specs/matchers/exceed_query_limit_helpers_spec.rb index 15b846f28cb..6d8d9ba0754 100644 --- a/spec/support_specs/matchers/exceed_query_limit_helpers_spec.rb +++ b/spec/support_specs/matchers/exceed_query_limit_helpers_spec.rb @@ -22,6 +22,232 @@ RSpec.describe ExceedQueryLimitHelpers do end end + describe '#diff_query_group_message' do + it 'prints a group helpfully' do + test_matcher = TestMatcher.new + suffixes = { + 'WHERE x = z' => [1, 1], + 'WHERE x = y' => [1, 2], + 'LIMIT 1' => [1, 0] + } + + message = test_matcher.diff_query_group_message('SELECT * FROM foo', suffixes) + + expect(message).to eq(<<~MSG.chomp) + SELECT * FROM foo... + -- (expected: 1, got: 1) + WHERE x = z + -- (expected: 1, got: 2) + WHERE x = y + -- (expected: 1, got: 0) + LIMIT 1 + MSG + end + end + + describe '#diff_query_counts' do + let(:expected) do + ActiveRecord::QueryRecorder.new do + TestQueries.where(version: 'foobar').to_a + TestQueries.where(version: 'also foobar and baz').to_a + TestQueries.count + TestQueries.first + TestQueries.where(version: 'foobar').to_a + TestQueries.where(version: 'x').update_all(version: 'y') + TestQueries.where(version: 'foobar').count + TestQueries.where(version: 'z').delete_all + end + end + + let(:actual) do + ActiveRecord::QueryRecorder.new do + TestQueries.where(version: 'foobar').to_a + TestQueries.where(version: 'also foobar and baz').to_a + TestQueries.count + TestQueries.create!(version: 'x') + TestQueries.where(version: 'foobar').to_a + TestQueries.where(version: 'x').update_all(version: 'y') + TestQueries.where(version: 'foobar').count + TestQueries.count + TestQueries.where(version: 'y').update_all(version: 'z') + TestQueries.where(version: 'z').delete_all + end + end + + it 'merges two query counts' do + test_matcher = TestMatcher.new + + diff = test_matcher.diff_query_counts( + test_matcher.count_queries(expected), + test_matcher.count_queries(actual) + ) + + expect(diff).to eq({ + "SELECT \"schema_migrations\".* FROM \"schema_migrations\"" => { + "ORDER BY \"schema_migrations\".\"version\" ASC LIMIT 1" => [1, 0] + }, + "SELECT COUNT(*) FROM \"schema_migrations\"" => { "" => [1, 2] }, + "UPDATE \"schema_migrations\"" => { + "SET \"version\" = 'z' WHERE \"schema_migrations\".\"version\" = 'y'" => [0, 1] + }, + "SAVEPOINT active_record_1" => { "" => [0, 1] }, + "INSERT INTO \"schema_migrations\" (\"version\")" => { + "VALUES ('x') RETURNING \"version\"" => [0, 1] + }, + "RELEASE SAVEPOINT active_record_1" => { "" => [0, 1] } + }) + end + + it 'can show common queries if so desired' do + test_matcher = TestMatcher.new.show_common_queries + + diff = test_matcher.diff_query_counts( + test_matcher.count_queries(expected), + test_matcher.count_queries(actual) + ) + + expect(diff).to eq({ + "SELECT \"schema_migrations\".* FROM \"schema_migrations\"" => { + "WHERE \"schema_migrations\".\"version\" = 'foobar'" => [2, 2], + "WHERE \"schema_migrations\".\"version\" = 'also foobar and baz'" => [1, 1], + "ORDER BY \"schema_migrations\".\"version\" ASC LIMIT 1" => [1, 0] + }, + "SELECT COUNT(*) FROM \"schema_migrations\"" => { + "" => [1, 2], + "WHERE \"schema_migrations\".\"version\" = 'foobar'" => [1, 1] + }, + "UPDATE \"schema_migrations\"" => { + "SET \"version\" = 'y' WHERE \"schema_migrations\".\"version\" = 'x'" => [1, 1], + "SET \"version\" = 'z' WHERE \"schema_migrations\".\"version\" = 'y'" => [0, 1] + }, + "DELETE FROM \"schema_migrations\"" => { + "WHERE \"schema_migrations\".\"version\" = 'z'" => [1, 1] + }, + "SAVEPOINT active_record_1" => { + "" => [0, 1] + }, + "INSERT INTO \"schema_migrations\" (\"version\")" => { + "VALUES ('x') RETURNING \"version\"" => [0, 1] + }, + "RELEASE SAVEPOINT active_record_1" => { + "" => [0, 1] + } + }) + end + end + + describe '#count_queries' do + it 'handles queries with suffixes over multiple lines' do + test_matcher = TestMatcher.new + + recorder = ActiveRecord::QueryRecorder.new do + TestQueries.find_by(version: %w(foo bar baz).join("\n")) + TestQueries.find_by(version: %w(foo biz baz).join("\n")) + TestQueries.find_by(version: %w(foo bar baz).join("\n")) + end + + recorder.count + + expect(test_matcher.count_queries(recorder)).to eq({ + 'SELECT "schema_migrations".* FROM "schema_migrations"' => { + %Q[WHERE "schema_migrations"."version" = 'foo\nbar\nbaz' LIMIT 1] => 2, + %Q[WHERE "schema_migrations"."version" = 'foo\nbiz\nbaz' LIMIT 1] => 1 + } + }) + end + + it 'can aggregate queries' do + test_matcher = TestMatcher.new + + recorder = ActiveRecord::QueryRecorder.new do + TestQueries.where(version: 'foobar').to_a + TestQueries.where(version: 'also foobar and baz').to_a + TestQueries.count + TestQueries.create!(version: 'x') + TestQueries.first + TestQueries.where(version: 'foobar').to_a + TestQueries.where(version: 'x').update_all(version: 'y') + TestQueries.where(version: 'foobar').count + TestQueries.count + TestQueries.where(version: 'y').update_all(version: 'z') + TestQueries.where(version: 'z').delete_all + end + + recorder.count + + expect(test_matcher.count_queries(recorder)).to eq({ + 'SELECT "schema_migrations".* FROM "schema_migrations"' => { + %q[WHERE "schema_migrations"."version" = 'foobar'] => 2, + %q[WHERE "schema_migrations"."version" = 'also foobar and baz'] => 1, + %q[ORDER BY "schema_migrations"."version" ASC LIMIT 1] => 1 + }, + 'SELECT COUNT(*) FROM "schema_migrations"' => { + "" => 2, + %q[WHERE "schema_migrations"."version" = 'foobar'] => 1 + }, + 'SAVEPOINT active_record_1' => { "" => 1 }, + 'INSERT INTO "schema_migrations" ("version")' => { + %q[VALUES ('x') RETURNING "version"] => 1 + }, + 'RELEASE SAVEPOINT active_record_1' => { "" => 1 }, + 'UPDATE "schema_migrations"' => { + %q[SET "version" = 'y' WHERE "schema_migrations"."version" = 'x'] => 1, + %q[SET "version" = 'z' WHERE "schema_migrations"."version" = 'y'] => 1 + }, + 'DELETE FROM "schema_migrations"' => { + %q[WHERE "schema_migrations"."version" = 'z'] => 1 + } + }) + end + end + + it 'can count queries' do + test_matcher = TestMatcher.new + test_matcher.verify_count do + TestQueries.where(version: 'foobar').to_a + TestQueries.where(version: 'also foobar and baz').to_a + TestQueries.first + TestQueries.count + end + + expect(test_matcher.actual_count).to eq(4) + end + + it 'can select specific queries' do + test_matcher = TestMatcher.new.for_query(/foobar/) + test_matcher.verify_count do + TestQueries.where(version: 'foobar').to_a + TestQueries.where(version: 'also foobar and baz').to_a + TestQueries.first + TestQueries.count + end + + expect(test_matcher.actual_count).to eq(2) + end + + it 'can ignore specific queries' do + test_matcher = TestMatcher.new.ignoring(/foobar/) + test_matcher.verify_count do + TestQueries.where(version: 'foobar').to_a + TestQueries.where(version: 'also foobar and baz').to_a + TestQueries.first + end + + expect(test_matcher.actual_count).to eq(1) + end + + it 'can perform inclusion and exclusion' do + test_matcher = TestMatcher.new.for_query(/foobar/).ignoring(/baz/) + test_matcher.verify_count do + TestQueries.where(version: 'foobar').to_a + TestQueries.where(version: 'also foobar and baz').to_a + TestQueries.first + TestQueries.count + end + + expect(test_matcher.actual_count).to eq(1) + end + it 'does not contain marginalia annotations' do test_matcher = TestMatcher.new test_matcher.verify_count do |