diff options
Diffstat (limited to 'spec/support/shared_examples/models')
12 files changed, 1049 insertions, 276 deletions
diff --git a/spec/support/shared_examples/models/chat_service_shared_examples.rb b/spec/support/shared_examples/models/chat_service_shared_examples.rb index 0a1c27b32db..ad237ad9f49 100644 --- a/spec/support/shared_examples/models/chat_service_shared_examples.rb +++ b/spec/support/shared_examples/models/chat_service_shared_examples.rb @@ -198,6 +198,7 @@ RSpec.shared_examples "chat service" do |service_name| message: "user created page: Awesome wiki_page" } end + let(:wiki_page) { create(:wiki_page, wiki: project.wiki, **opts) } let(:sample_data) { Gitlab::DataBuilder::WikiPage.build(wiki_page, user, "create") } @@ -250,6 +251,7 @@ RSpec.shared_examples "chat service" do |service_name| project: project, status: status, sha: project.commit.sha, ref: project.default_branch) end + let(:sample_data) { Gitlab::DataBuilder::Pipeline.build(pipeline) } context "with failed pipeline" do diff --git a/spec/support/shared_examples/models/cluster_application_helm_cert_shared_examples.rb b/spec/support/shared_examples/models/cluster_application_helm_cert_shared_examples.rb index 239588d3b2f..394253fb699 100644 --- a/spec/support/shared_examples/models/cluster_application_helm_cert_shared_examples.rb +++ b/spec/support/shared_examples/models/cluster_application_helm_cert_shared_examples.rb @@ -28,46 +28,16 @@ RSpec.shared_examples 'cluster application helm specs' do |application_name| describe '#files' do subject { application.files } - context 'managed_apps_local_tiller feature flag is disabled' do - before do - stub_feature_flags(managed_apps_local_tiller: false) - end - - context 'when the helm application does not have a ca_cert' do - before do - application.cluster.application_helm.ca_cert = nil - end - - it 'does not include cert files when there is no ca_cert entry' do - expect(subject).not_to include(:'ca.pem', :'cert.pem', :'key.pem') - end - end - - it 'includes cert files when there is a ca_cert entry' do - expect(subject).to include(:'ca.pem', :'cert.pem', :'key.pem') - expect(subject[:'ca.pem']).to eq(application.cluster.application_helm.ca_cert) - - cert = OpenSSL::X509::Certificate.new(subject[:'cert.pem']) - expect(cert.not_after).to be < 60.minutes.from_now - end + it 'does not include cert files' do + expect(subject).not_to include(:'ca.pem', :'cert.pem', :'key.pem') end - context 'managed_apps_local_tiller feature flag is enabled' do - before do - stub_feature_flags(managed_apps_local_tiller: application.cluster.clusterable) - end + context 'when cluster does not have helm installed' do + let(:application) { create(application_name, :no_helm_installed) } it 'does not include cert files' do expect(subject).not_to include(:'ca.pem', :'cert.pem', :'key.pem') end - - context 'when cluster does not have helm installed' do - let(:application) { create(application_name, :no_helm_installed) } - - it 'does not include cert files' do - expect(subject).not_to include(:'ca.pem', :'cert.pem', :'key.pem') - end - end end end end diff --git a/spec/support/shared_examples/models/cluster_application_initial_status_shared_examples.rb b/spec/support/shared_examples/models/cluster_application_initial_status_shared_examples.rb index 7f0c60d4204..55e458db512 100644 --- a/spec/support/shared_examples/models/cluster_application_initial_status_shared_examples.rb +++ b/spec/support/shared_examples/models/cluster_application_initial_status_shared_examples.rb @@ -6,46 +6,8 @@ RSpec.shared_examples 'cluster application initial status specs' do subject { described_class.new(cluster: cluster) } - context 'local tiller feature flag is disabled' do - before do - stub_feature_flags(managed_apps_local_tiller: false) - end - - it 'sets a default status' do - expect(subject.status_name).to be(:not_installable) - end - end - - context 'local tiller feature flag is enabled' do - before do - stub_feature_flags(managed_apps_local_tiller: cluster.clusterable) - end - - it 'sets a default status' do - expect(subject.status_name).to be(:installable) - end - end - - context 'when application helm is scheduled' do - before do - stub_feature_flags(managed_apps_local_tiller: false) - - create(:clusters_applications_helm, :scheduled, cluster: cluster) - end - - it 'defaults to :not_installable' do - expect(subject.status_name).to be(:not_installable) - end - end - - context 'when application helm is installed' do - before do - create(:clusters_applications_helm, :installed, cluster: cluster) - end - - it 'sets a default status' do - expect(subject.status_name).to be(:installable) - end + it 'sets a default status' do + expect(subject.status_name).to be(:installable) end end end diff --git a/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb b/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb index f80ca235220..7603787a54e 100644 --- a/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb +++ b/spec/support/shared_examples/models/cluster_application_status_shared_examples.rb @@ -48,43 +48,21 @@ RSpec.shared_examples 'cluster application status specs' do |application_name| expect(subject).to be_installed end - context 'managed_apps_local_tiller feature flag disabled' do - before do - stub_feature_flags(managed_apps_local_tiller: false) - end - - it 'updates helm version' do - subject.cluster.application_helm.update!(version: '1.2.3') + it 'does not update the helm version' do + subject.cluster.application_helm.update!(version: '1.2.3') + expect do subject.make_installed! subject.cluster.application_helm.reload - - expect(subject.cluster.application_helm.version).to eq(Gitlab::Kubernetes::Helm::HELM_VERSION) - end + end.not_to change { subject.cluster.application_helm.version } end - context 'managed_apps_local_tiller feature flag enabled' do - before do - stub_feature_flags(managed_apps_local_tiller: subject.cluster.clusterable) - end - - it 'does not update the helm version' do - subject.cluster.application_helm.update!(version: '1.2.3') - - expect do - subject.make_installed! - - subject.cluster.application_helm.reload - end.not_to change { subject.cluster.application_helm.version } - end - - context 'the cluster has no helm installed' do - subject { create(application_name, :installing, :no_helm_installed) } + context 'the cluster has no helm installed' do + subject { create(application_name, :installing, :no_helm_installed) } - it 'runs without errors' do - expect { subject.make_installed! }.not_to raise_error - end + it 'runs without errors' do + expect { subject.make_installed! }.not_to raise_error end end @@ -97,43 +75,21 @@ RSpec.shared_examples 'cluster application status specs' do |application_name| expect(subject).to be_updated end - context 'managed_apps_local_tiller feature flag disabled' do - before do - stub_feature_flags(managed_apps_local_tiller: false) - end - - it 'updates helm version' do - subject.cluster.application_helm.update!(version: '1.2.3') + it 'does not update the helm version' do + subject.cluster.application_helm.update!(version: '1.2.3') + expect do subject.make_installed! subject.cluster.application_helm.reload - - expect(subject.cluster.application_helm.version).to eq(Gitlab::Kubernetes::Helm::HELM_VERSION) - end + end.not_to change { subject.cluster.application_helm.version } end - context 'managed_apps_local_tiller feature flag enabled' do - before do - stub_feature_flags(managed_apps_local_tiller: true) - end - - it 'does not update the helm version' do - subject.cluster.application_helm.update!(version: '1.2.3') - - expect do - subject.make_installed! - - subject.cluster.application_helm.reload - end.not_to change { subject.cluster.application_helm.version } - end - - context 'the cluster has no helm installed' do - subject { create(application_name, :updating, :no_helm_installed) } + context 'the cluster has no helm installed' do + subject { create(application_name, :updating, :no_helm_installed) } - it 'runs without errors' do - expect { subject.make_installed! }.not_to raise_error - end + it 'runs without errors' do + expect { subject.make_installed! }.not_to raise_error end end end @@ -185,62 +141,26 @@ RSpec.shared_examples 'cluster application status specs' do |application_name| expect(subject).to be_installed end - context 'local tiller flag enabled' do - before do - stub_feature_flags(managed_apps_local_tiller: true) - end - - context 'helm record does not exist' do - subject { build(application_name, :installing, :no_helm_installed) } - - it 'does not create a helm record' do - subject.make_externally_installed! - - subject.cluster.reload - expect(subject.cluster.application_helm).to be_nil - end - end - - context 'helm record exists' do - subject { build(application_name, :installing, cluster: old_helm.cluster) } + context 'helm record does not exist' do + subject { build(application_name, :installing, :no_helm_installed) } - it 'does not update helm version' do - subject.make_externally_installed! + it 'does not create a helm record' do + subject.make_externally_installed! - subject.cluster.application_helm.reload - - expect(subject.cluster.application_helm.version).to eq('1.2.3') - end + subject.cluster.reload + expect(subject.cluster.application_helm).to be_nil end end - context 'local tiller flag disabled' do - before do - stub_feature_flags(managed_apps_local_tiller: false) - end - - context 'helm record does not exist' do - subject { build(application_name, :installing, :no_helm_installed) } - - it 'creates a helm record' do - subject.make_externally_installed! - - subject.cluster.reload - expect(subject.cluster.application_helm).to be_present - expect(subject.cluster.application_helm).to be_persisted - end - end - - context 'helm record exists' do - subject { build(application_name, :installing, cluster: old_helm.cluster) } + context 'helm record exists' do + subject { build(application_name, :installing, cluster: old_helm.cluster) } - it 'does not update helm version' do - subject.make_externally_installed! + it 'does not update helm version' do + subject.make_externally_installed! - subject.cluster.application_helm.reload + subject.cluster.application_helm.reload - expect(subject.cluster.application_helm.version).to eq('1.2.3') - end + expect(subject.cluster.application_helm.version).to eq('1.2.3') end end @@ -262,6 +182,14 @@ RSpec.shared_examples 'cluster application status specs' do |application_name| expect(subject).to be_installed end + + it 'clears #status_reason' do + expect(subject.status_reason).not_to be_nil + + subject.make_externally_installed! + + expect(subject.status_reason).to be_nil + end end end @@ -292,6 +220,14 @@ RSpec.shared_examples 'cluster application status specs' do |application_name| expect(subject).to be_uninstalled end + + it 'clears #status_reason' do + expect(subject.status_reason).not_to be_nil + + subject.make_externally_uninstalled! + + expect(subject.status_reason).to be_nil + end end end diff --git a/spec/support/shared_examples/models/concerns/counter_attribute_shared_examples.rb b/spec/support/shared_examples/models/concerns/counter_attribute_shared_examples.rb new file mode 100644 index 00000000000..99a09993900 --- /dev/null +++ b/spec/support/shared_examples/models/concerns/counter_attribute_shared_examples.rb @@ -0,0 +1,176 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.shared_examples_for CounterAttribute do |counter_attributes| + it 'defines a Redis counter_key' do + expect(model.counter_key(:counter_name)) + .to eq("project:{#{model.project_id}}:counters:CounterAttributeModel:#{model.id}:counter_name") + end + + it 'defines a method to store counters' do + expect(model.class.counter_attributes.to_a).to eq(counter_attributes) + end + + counter_attributes.each do |attribute| + describe attribute do + describe '#delayed_increment_counter', :redis do + let(:increment) { 10 } + + subject { model.delayed_increment_counter(attribute, increment) } + + context 'when attribute is a counter attribute' do + where(:increment) { [10, -3] } + + with_them do + it 'increments the counter in Redis' do + subject + + Gitlab::Redis::SharedState.with do |redis| + counter = redis.get(model.counter_key(attribute)) + expect(counter).to eq(increment.to_s) + end + end + + it 'does not increment the counter for the record' do + expect { subject }.not_to change { model.reset.read_attribute(attribute) } + end + + it 'schedules a worker to flush counter increments asynchronously' do + expect(FlushCounterIncrementsWorker).to receive(:perform_in) + .with(CounterAttribute::WORKER_DELAY, model.class.name, model.id, attribute) + .and_call_original + + subject + end + end + + context 'when increment is 0' do + let(:increment) { 0 } + + it 'does nothing' do + expect(FlushCounterIncrementsWorker).not_to receive(:perform_in) + expect(model).not_to receive(:update!) + + subject + end + end + end + + context 'when attribute is not a counter attribute' do + it 'delegates to ActiveRecord update!' do + expect { model.delayed_increment_counter(:unknown_attribute, 10) } + .to raise_error(ActiveModel::MissingAttributeError) + end + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(efficient_counter_attribute: false) + end + + it 'delegates to ActiveRecord update!' do + expect { subject } + .to change { model.reset.read_attribute(attribute) }.by(increment) + end + + it 'does not increment the counter in Redis' do + subject + + Gitlab::Redis::SharedState.with do |redis| + counter = redis.get(model.counter_key(attribute)) + expect(counter).to be_nil + end + end + end + end + end + end + + describe '.flush_increments_to_database!', :redis do + let(:incremented_attribute) { counter_attributes.first } + + subject { model.flush_increments_to_database!(incremented_attribute) } + + it 'obtains an exclusive lease during processing' do + expect(model) + .to receive(:in_lock) + .with(model.counter_lock_key(incremented_attribute), ttl: described_class::WORKER_LOCK_TTL) + .and_call_original + + subject + end + + context 'when there is a counter to flush' do + before do + model.delayed_increment_counter(incremented_attribute, 10) + model.delayed_increment_counter(incremented_attribute, -3) + end + + it 'updates the record' do + expect { subject }.to change { model.reset.read_attribute(incremented_attribute) }.by(7) + end + + it 'removes the increment entry from Redis' do + Gitlab::Redis::SharedState.with do |redis| + key_exists = redis.exists(model.counter_key(incremented_attribute)) + expect(key_exists).to be_truthy + end + + subject + + Gitlab::Redis::SharedState.with do |redis| + key_exists = redis.exists(model.counter_key(incremented_attribute)) + expect(key_exists).to be_falsey + end + end + end + + context 'when there are no counters to flush' do + context 'when there are no counters in the relative :flushed key' do + it 'does not change the record' do + expect { subject }.not_to change { model.reset.attributes } + end + end + + # This can be the case where updating counters in the database fails with error + # and retrying the worker will retry flushing the counters but the main key has + # disappeared and the increment has been moved to the "<...>:flushed" key. + context 'when there are counters in the relative :flushed key' do + before do + Gitlab::Redis::SharedState.with do |redis| + redis.incrby(model.counter_flushed_key(incremented_attribute), 10) + end + end + + it 'updates the record' do + expect { subject }.to change { model.reset.read_attribute(incremented_attribute) }.by(10) + end + + it 'deletes the relative :flushed key' do + subject + + Gitlab::Redis::SharedState.with do |redis| + key_exists = redis.exists(model.counter_flushed_key(incremented_attribute)) + expect(key_exists).to be_falsey + end + end + end + end + + context 'when deleting :flushed key fails' do + before do + Gitlab::Redis::SharedState.with do |redis| + redis.incrby(model.counter_flushed_key(incremented_attribute), 10) + + expect(redis).to receive(:del).and_raise('could not delete key') + end + end + + it 'does a rollback of the counter update' do + expect { subject }.to raise_error('could not delete key') + + expect(model.reset.read_attribute(incremented_attribute)).to eq(0) + end + end + end +end diff --git a/spec/support/shared_examples/models/concerns/file_store_mounter_shared_examples.rb b/spec/support/shared_examples/models/concerns/file_store_mounter_shared_examples.rb new file mode 100644 index 00000000000..4cb087c47ad --- /dev/null +++ b/spec/support/shared_examples/models/concerns/file_store_mounter_shared_examples.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'mounted file in local store' do + it 'is stored locally' do + expect(subject.file_store).to be(ObjectStorage::Store::LOCAL) + expect(subject.file).to be_file_storage + expect(subject.file.object_store).to eq(ObjectStorage::Store::LOCAL) + end +end + +RSpec.shared_examples 'mounted file in object store' do + it 'is stored remotely' do + expect(subject.file_store).to eq(ObjectStorage::Store::REMOTE) + expect(subject.file).not_to be_file_storage + expect(subject.file.object_store).to eq(ObjectStorage::Store::REMOTE) + end +end diff --git a/spec/support/shared_examples/models/concerns/timebox_shared_examples.rb b/spec/support/shared_examples/models/concerns/timebox_shared_examples.rb index 32d502af5a2..15ca1f56bd0 100644 --- a/spec/support/shared_examples/models/concerns/timebox_shared_examples.rb +++ b/spec/support/shared_examples/models/concerns/timebox_shared_examples.rb @@ -3,7 +3,8 @@ RSpec.shared_examples 'a timebox' do |timebox_type| let(:project) { create(:project, :public) } let(:group) { create(:group) } - let(:timebox) { create(timebox_type, project: project) } + let(:timebox_args) { [] } + let(:timebox) { create(timebox_type, *timebox_args, project: project) } let(:issue) { create(:issue, project: project) } let(:user) { create(:user) } let(:timebox_table_name) { timebox_type.to_s.pluralize.to_sym } @@ -12,7 +13,7 @@ RSpec.shared_examples 'a timebox' do |timebox_type| context 'with a project' do it_behaves_like 'AtomicInternalId' do let(:internal_id_attribute) { :iid } - let(:instance) { build(timebox_type, project: build(:project), group: nil) } + let(:instance) { build(timebox_type, *timebox_args, project: build(:project), group: nil) } let(:scope) { :project } let(:scope_attrs) { { project: instance.project } } let(:usage) { timebox_table_name } @@ -22,7 +23,7 @@ RSpec.shared_examples 'a timebox' do |timebox_type| context 'with a group' do it_behaves_like 'AtomicInternalId' do let(:internal_id_attribute) { :iid } - let(:instance) { build(timebox_type, project: nil, group: build(:group)) } + let(:instance) { build(timebox_type, *timebox_args, project: nil, group: build(:group)) } let(:scope) { :group } let(:scope_attrs) { { namespace: instance.group } } let(:usage) { timebox_table_name } @@ -37,14 +38,14 @@ RSpec.shared_examples 'a timebox' do |timebox_type| describe 'start_date' do it 'adds an error when start_date is greater then due_date' do - timebox = build(timebox_type, start_date: Date.tomorrow, due_date: Date.yesterday) + timebox = build(timebox_type, *timebox_args, start_date: Date.tomorrow, due_date: Date.yesterday) expect(timebox).not_to be_valid expect(timebox.errors[:due_date]).to include("must be greater than start date") end it 'adds an error when start_date is greater than 9999-12-31' do - timebox = build(timebox_type, start_date: Date.new(10000, 1, 1)) + timebox = build(timebox_type, *timebox_args, start_date: Date.new(10000, 1, 1)) expect(timebox).not_to be_valid expect(timebox.errors[:start_date]).to include("date must not be after 9999-12-31") @@ -53,7 +54,7 @@ RSpec.shared_examples 'a timebox' do |timebox_type| describe 'due_date' do it 'adds an error when due_date is greater than 9999-12-31' do - timebox = build(timebox_type, due_date: Date.new(10000, 1, 1)) + timebox = build(timebox_type, *timebox_args, due_date: Date.new(10000, 1, 1)) expect(timebox).not_to be_valid expect(timebox.errors[:due_date]).to include("date must not be after 9999-12-31") @@ -64,7 +65,7 @@ RSpec.shared_examples 'a timebox' do |timebox_type| it { is_expected.to validate_presence_of(:title) } it 'is invalid if title would be empty after sanitation' do - timebox = build(timebox_type, project: project, title: '<img src=x onerror=prompt(1)>') + timebox = build(timebox_type, *timebox_args, project: project, title: '<img src=x onerror=prompt(1)>') expect(timebox).not_to be_valid expect(timebox.errors[:title]).to include("can't be blank") @@ -73,7 +74,7 @@ RSpec.shared_examples 'a timebox' do |timebox_type| describe '#timebox_type_check' do it 'is invalid if it has both project_id and group_id' do - timebox = build(timebox_type, group: group) + timebox = build(timebox_type, *timebox_args, group: group) timebox.project = project expect(timebox).not_to be_valid @@ -98,7 +99,7 @@ RSpec.shared_examples 'a timebox' do |timebox_type| end context "per group" do - let(:timebox) { create(timebox_type, group: group) } + let(:timebox) { create(timebox_type, *timebox_args, group: group) } before do project.update(group: group) @@ -111,7 +112,7 @@ RSpec.shared_examples 'a timebox' do |timebox_type| end it "does not accept the same title of a child project timebox" do - create(timebox_type, project: group.projects.first) + create(timebox_type, *timebox_args, project: group.projects.first) new_timebox = described_class.new(group: group, title: timebox.title) @@ -143,7 +144,7 @@ RSpec.shared_examples 'a timebox' do |timebox_type| end context 'when project_id is not present' do - let(:timebox) { build(timebox_type, group: group) } + let(:timebox) { build(timebox_type, *timebox_args, group: group) } it 'returns false' do expect(timebox.project_timebox?).to be_falsey @@ -153,7 +154,7 @@ RSpec.shared_examples 'a timebox' do |timebox_type| describe '#group_timebox?' do context 'when group_id is present' do - let(:timebox) { build(timebox_type, group: group) } + let(:timebox) { build(timebox_type, *timebox_args, group: group) } it 'returns true' do expect(timebox.group_timebox?).to be_truthy @@ -168,7 +169,7 @@ RSpec.shared_examples 'a timebox' do |timebox_type| end describe '#safe_title' do - let(:timebox) { create(timebox_type, title: "<b>foo & bar -> 2.2</b>") } + let(:timebox) { create(timebox_type, *timebox_args, title: "<b>foo & bar -> 2.2</b>") } it 'normalizes the title for use as a slug' do expect(timebox.safe_title).to eq('foo-bar-22') @@ -177,7 +178,7 @@ RSpec.shared_examples 'a timebox' do |timebox_type| describe '#resource_parent' do context 'when group is present' do - let(:timebox) { build(timebox_type, group: group) } + let(:timebox) { build(timebox_type, *timebox_args, group: group) } it 'returns the group' do expect(timebox.resource_parent).to eq(group) @@ -192,7 +193,7 @@ RSpec.shared_examples 'a timebox' do |timebox_type| end describe "#title" do - let(:timebox) { create(timebox_type, title: "<b>foo & bar -> 2.2</b>") } + let(:timebox) { create(timebox_type, *timebox_args, title: "<b>foo & bar -> 2.2</b>") } it "sanitizes title" do expect(timebox.title).to eq("foo & bar -> 2.2") @@ -203,28 +204,28 @@ RSpec.shared_examples 'a timebox' do |timebox_type| context "per project" do it "is true for projects with MRs enabled" do project = create(:project, :merge_requests_enabled) - timebox = create(timebox_type, project: project) + timebox = create(timebox_type, *timebox_args, project: project) expect(timebox.merge_requests_enabled?).to be_truthy end it "is false for projects with MRs disabled" do project = create(:project, :repository_enabled, :merge_requests_disabled) - timebox = create(timebox_type, project: project) + timebox = create(timebox_type, *timebox_args, project: project) expect(timebox.merge_requests_enabled?).to be_falsey end it "is false for projects with repository disabled" do project = create(:project, :repository_disabled) - timebox = create(timebox_type, project: project) + timebox = create(timebox_type, *timebox_args, project: project) expect(timebox.merge_requests_enabled?).to be_falsey end end context "per group" do - let(:timebox) { create(timebox_type, group: group) } + let(:timebox) { create(timebox_type, *timebox_args, group: group) } it "is always true for groups, for performance reasons" do expect(timebox.merge_requests_enabled?).to be_truthy @@ -234,7 +235,7 @@ RSpec.shared_examples 'a timebox' do |timebox_type| describe '#to_ability_name' do it 'returns timebox' do - timebox = build(timebox_type) + timebox = build(timebox_type, *timebox_args) expect(timebox.to_ability_name).to eq(timebox_type.to_s) end diff --git a/spec/support/shared_examples/models/issuable_hook_data_shared_examples.rb b/spec/support/shared_examples/models/issuable_hook_data_shared_examples.rb index 21ab9b06c33..13ffc1b7f87 100644 --- a/spec/support/shared_examples/models/issuable_hook_data_shared_examples.rb +++ b/spec/support/shared_examples/models/issuable_hook_data_shared_examples.rb @@ -38,6 +38,7 @@ RSpec.shared_examples 'issuable hook data' do |kind| title_html: %w[foo bar] } end + let(:data) { builder.build(user: user, changes: changes) } it 'populates the :changes hash' do diff --git a/spec/support/shared_examples/models/relative_positioning_shared_examples.rb b/spec/support/shared_examples/models/relative_positioning_shared_examples.rb index 99e62ebf422..e4668926d74 100644 --- a/spec/support/shared_examples/models/relative_positioning_shared_examples.rb +++ b/spec/support/shared_examples/models/relative_positioning_shared_examples.rb @@ -1,11 +1,11 @@ # frozen_string_literal: true RSpec.shared_examples 'a class that supports relative positioning' do - let(:item1) { create(factory, default_params) } - let(:item2) { create(factory, default_params) } - let(:new_item) { create(factory, default_params) } + let(:item1) { create_item } + let(:item2) { create_item } + let(:new_item) { create_item } - def create_item(params) + def create_item(params = {}) create(factory, params.merge(default_params)) end @@ -16,31 +16,119 @@ RSpec.shared_examples 'a class that supports relative positioning' do end describe '.move_nulls_to_end' do + let(:item3) { create_item } + it 'moves items with null relative_position to the end' do + item1.update!(relative_position: 1000) + item2.update!(relative_position: nil) + item3.update!(relative_position: nil) + + items = [item1, item2, item3] + expect(described_class.move_nulls_to_end(items)).to be(2) + + expect(items.sort_by(&:relative_position)).to eq(items) + expect(item1.relative_position).to be(1000) + expect(item1.prev_relative_position).to be_nil + expect(item1.next_relative_position).to eq(item2.relative_position) + expect(item2.next_relative_position).to eq(item3.relative_position) + expect(item3.next_relative_position).to be_nil + end + + it 'preserves relative position' do item1.update!(relative_position: nil) item2.update!(relative_position: nil) described_class.move_nulls_to_end([item1, item2]) - expect(item2.prev_relative_position).to eq item1.relative_position - expect(item1.prev_relative_position).to eq nil - expect(item2.next_relative_position).to eq nil + expect(item1.relative_position).to be < item2.relative_position end it 'moves the item near the start position when there are no existing positions' do item1.update!(relative_position: nil) described_class.move_nulls_to_end([item1]) - - expect(item1.relative_position).to eq(described_class::START_POSITION + described_class::IDEAL_DISTANCE) + expect(item1.reset.relative_position).to eq(described_class::START_POSITION + described_class::IDEAL_DISTANCE) end it 'does not perform any moves if all items have their relative_position set' do item1.update!(relative_position: 1) - expect(item1).not_to receive(:save) + expect(described_class.move_nulls_to_start([item1])).to be(0) + expect(item1.reload.relative_position).to be(1) + end + + it 'manages to move nulls to the end even if there is a sequence at the end' do + bunch = create_items_with_positions(run_at_end) + item1.update!(relative_position: nil) described_class.move_nulls_to_end([item1]) + + items = [*bunch, item1] + items.each(&:reset) + + expect(items.map(&:relative_position)).to all(be_valid_position) + expect(items.sort_by(&:relative_position)).to eq(items) + end + + it 'does not have an N+1 issue' do + create_items_with_positions(10..12) + + a, b, c, d, e, f = create_items_with_positions([nil, nil, nil, nil, nil, nil]) + + baseline = ActiveRecord::QueryRecorder.new do + described_class.move_nulls_to_end([a, e]) + end + + expect { described_class.move_nulls_to_end([b, c, d]) } + .not_to exceed_query_limit(baseline) + + expect { described_class.move_nulls_to_end([f]) } + .not_to exceed_query_limit(baseline.count) + end + end + + describe '.move_nulls_to_start' do + let(:item3) { create_item } + + it 'moves items with null relative_position to the start' do + item1.update!(relative_position: nil) + item2.update!(relative_position: nil) + item3.update!(relative_position: 1000) + + items = [item1, item2, item3] + expect(described_class.move_nulls_to_start(items)).to be(2) + items.map(&:reload) + + expect(items.sort_by(&:relative_position)).to eq(items) + expect(item1.prev_relative_position).to eq nil + expect(item1.next_relative_position).to eq item2.relative_position + expect(item2.next_relative_position).to eq item3.relative_position + expect(item3.next_relative_position).to eq nil + expect(item3.relative_position).to be(1000) + end + + it 'moves the item near the start position when there are no existing positions' do + item1.update!(relative_position: nil) + + described_class.move_nulls_to_start([item1]) + + expect(item1.relative_position).to eq(described_class::START_POSITION - described_class::IDEAL_DISTANCE) + end + + it 'preserves relative position' do + item1.update!(relative_position: nil) + item2.update!(relative_position: nil) + + described_class.move_nulls_to_start([item1, item2]) + + expect(item1.relative_position).to be < item2.relative_position + end + + it 'does not perform any moves if all items have their relative_position set' do + item1.update!(relative_position: 1) + + expect(described_class.move_nulls_to_start([item1])).to be(0) + expect(item1.reload.relative_position).to be(1) end end @@ -52,8 +140,8 @@ RSpec.shared_examples 'a class that supports relative positioning' do describe '#prev_relative_position' do it 'returns previous position if there is an item above' do - item1.update(relative_position: 5) - item2.update(relative_position: 15) + item1.update!(relative_position: 5) + item2.update!(relative_position: 15) expect(item2.prev_relative_position).to eq item1.relative_position end @@ -65,8 +153,8 @@ RSpec.shared_examples 'a class that supports relative positioning' do describe '#next_relative_position' do it 'returns next position if there is an item below' do - item1.update(relative_position: 5) - item2.update(relative_position: 15) + item1.update!(relative_position: 5) + item2.update!(relative_position: 15) expect(item1.next_relative_position).to eq item2.relative_position end @@ -76,9 +164,172 @@ RSpec.shared_examples 'a class that supports relative positioning' do end end + describe '#find_next_gap_before' do + context 'there is no gap' do + let(:items) { create_items_with_positions(run_at_start) } + + it 'returns nil' do + items.each do |item| + expect(item.send(:find_next_gap_before)).to be_nil + end + end + end + + context 'there is a sequence ending at MAX_POSITION' do + let(:items) { create_items_with_positions(run_at_end) } + + let(:gaps) do + items.map { |item| item.send(:find_next_gap_before) } + end + + it 'can find the gap at the start for any item in the sequence' do + gap = { start: items.first.relative_position, end: RelativePositioning::MIN_POSITION } + + expect(gaps).to all(eq(gap)) + end + + it 'respects lower bounds' do + gap = { start: items.first.relative_position, end: 10 } + new_item.update!(relative_position: 10) + + expect(gaps).to all(eq(gap)) + end + end + + specify do + item1.update!(relative_position: 5) + + (0..10).each do |pos| + item2.update!(relative_position: pos) + + gap = item2.send(:find_next_gap_before) + + expect(gap[:start]).to be <= item2.relative_position + expect((gap[:end] - gap[:start]).abs).to be >= RelativePositioning::MIN_GAP + expect(gap[:start]).to be_valid_position + expect(gap[:end]).to be_valid_position + end + end + + it 'deals with there not being any items to the left' do + create_items_with_positions([1, 2, 3]) + new_item.update!(relative_position: 0) + + expect(new_item.send(:find_next_gap_before)).to eq(start: 0, end: RelativePositioning::MIN_POSITION) + end + + it 'finds the next gap to the left, skipping adjacent values' do + create_items_with_positions([1, 9, 10]) + new_item.update!(relative_position: 11) + + expect(new_item.send(:find_next_gap_before)).to eq(start: 9, end: 1) + end + + it 'finds the next gap to the left' do + create_items_with_positions([2, 10]) + + new_item.update!(relative_position: 15) + expect(new_item.send(:find_next_gap_before)).to eq(start: 15, end: 10) + + new_item.update!(relative_position: 11) + expect(new_item.send(:find_next_gap_before)).to eq(start: 10, end: 2) + + new_item.update!(relative_position: 9) + expect(new_item.send(:find_next_gap_before)).to eq(start: 9, end: 2) + + new_item.update!(relative_position: 5) + expect(new_item.send(:find_next_gap_before)).to eq(start: 5, end: 2) + end + end + + describe '#find_next_gap_after' do + context 'there is no gap' do + let(:items) { create_items_with_positions(run_at_end) } + + it 'returns nil' do + items.each do |item| + expect(item.send(:find_next_gap_after)).to be_nil + end + end + end + + context 'there is a sequence starting at MIN_POSITION' do + let(:items) { create_items_with_positions(run_at_start) } + + let(:gaps) do + items.map { |item| item.send(:find_next_gap_after) } + end + + it 'can find the gap at the end for any item in the sequence' do + gap = { start: items.last.relative_position, end: RelativePositioning::MAX_POSITION } + + expect(gaps).to all(eq(gap)) + end + + it 'respects upper bounds' do + gap = { start: items.last.relative_position, end: 10 } + new_item.update!(relative_position: 10) + + expect(gaps).to all(eq(gap)) + end + end + + specify do + item1.update!(relative_position: 5) + + (0..10).each do |pos| + item2.update!(relative_position: pos) + + gap = item2.send(:find_next_gap_after) + + expect(gap[:start]).to be >= item2.relative_position + expect((gap[:end] - gap[:start]).abs).to be >= RelativePositioning::MIN_GAP + expect(gap[:start]).to be_valid_position + expect(gap[:end]).to be_valid_position + end + end + + it 'deals with there not being any items to the right' do + create_items_with_positions([1, 2, 3]) + new_item.update!(relative_position: 5) + + expect(new_item.send(:find_next_gap_after)).to eq(start: 5, end: RelativePositioning::MAX_POSITION) + end + + it 'finds the next gap to the right, skipping adjacent values' do + create_items_with_positions([1, 2, 10]) + new_item.update!(relative_position: 0) + + expect(new_item.send(:find_next_gap_after)).to eq(start: 2, end: 10) + end + + it 'finds the next gap to the right' do + create_items_with_positions([2, 10]) + + new_item.update!(relative_position: 0) + expect(new_item.send(:find_next_gap_after)).to eq(start: 0, end: 2) + + new_item.update!(relative_position: 1) + expect(new_item.send(:find_next_gap_after)).to eq(start: 2, end: 10) + + new_item.update!(relative_position: 3) + expect(new_item.send(:find_next_gap_after)).to eq(start: 3, end: 10) + + new_item.update!(relative_position: 5) + expect(new_item.send(:find_next_gap_after)).to eq(start: 5, end: 10) + end + end + describe '#move_before' do + let(:item3) { create(factory, default_params) } + it 'moves item before' do - [item2, item1].each(&:move_to_end) + [item2, item1].each do |item| + item.move_to_end + item.save! + end + + expect(item1.relative_position).to be > item2.relative_position item1.move_before(item2) @@ -86,12 +337,10 @@ RSpec.shared_examples 'a class that supports relative positioning' do end context 'when there is no space' do - let(:item3) { create(factory, default_params) } - before do - item1.update(relative_position: 1000) - item2.update(relative_position: 1001) - item3.update(relative_position: 1002) + item1.update!(relative_position: 1000) + item2.update!(relative_position: 1001) + item3.update!(relative_position: 1002) end it 'moves items correctly' do @@ -100,6 +349,73 @@ RSpec.shared_examples 'a class that supports relative positioning' do expect(item3.relative_position).to be_between(item1.reload.relative_position, item2.reload.relative_position).exclusive end end + + it 'can move the item before an item at the start' do + item1.update!(relative_position: RelativePositioning::START_POSITION) + + new_item.move_before(item1) + + expect(new_item.relative_position).to be_valid_position + expect(new_item.relative_position).to be < item1.reload.relative_position + end + + it 'can move the item before an item at MIN_POSITION' do + item1.update!(relative_position: RelativePositioning::MIN_POSITION) + + new_item.move_before(item1) + + expect(new_item.relative_position).to be >= RelativePositioning::MIN_POSITION + expect(new_item.relative_position).to be < item1.reload.relative_position + end + + it 'can move the item before an item bunched up at MIN_POSITION' do + item1, item2, item3 = create_items_with_positions(run_at_start) + + new_item.move_before(item3) + new_item.save! + + items = [item1, item2, new_item, item3] + + items.each do |item| + expect(item.reset.relative_position).to be_valid_position + end + + expect(items.sort_by(&:relative_position)).to eq(items) + end + + context 'leap-frogging to the left' do + before do + start = RelativePositioning::START_POSITION + item1.update!(relative_position: start - RelativePositioning::IDEAL_DISTANCE * 0) + item2.update!(relative_position: start - RelativePositioning::IDEAL_DISTANCE * 1) + item3.update!(relative_position: start - RelativePositioning::IDEAL_DISTANCE * 2) + end + + let(:item3) { create(factory, default_params) } + + def leap_frog(steps) + a = item1 + b = item2 + + steps.times do |i| + a.move_before(b) + a.save! + a, b = b, a + end + end + + it 'can leap-frog STEPS - 1 times before needing to rebalance' do + # This is less efficient than going right, due to the flooring of + # integer division + expect { leap_frog(RelativePositioning::STEPS - 1) } + .not_to change { item3.reload.relative_position } + end + + it 'rebalances after leap-frogging STEPS times' do + expect { leap_frog(RelativePositioning::STEPS) } + .to change { item3.reload.relative_position } + end + end end describe '#move_after' do @@ -115,9 +431,17 @@ RSpec.shared_examples 'a class that supports relative positioning' do let(:item3) { create(factory, default_params) } before do - item1.update(relative_position: 1000) - item2.update(relative_position: 1001) - item3.update(relative_position: 1002) + item1.update!(relative_position: 1000) + item2.update!(relative_position: 1001) + item3.update!(relative_position: 1002) + end + + it 'can move the item after an item at MAX_POSITION' do + item1.update!(relative_position: RelativePositioning::MAX_POSITION) + + new_item.move_after(item1) + expect(new_item.relative_position).to be_valid_position + expect(new_item.relative_position).to be > item1.reset.relative_position end it 'moves items correctly' do @@ -126,12 +450,96 @@ RSpec.shared_examples 'a class that supports relative positioning' do expect(item1.relative_position).to be_between(item2.reload.relative_position, item3.reload.relative_position).exclusive end end + + it 'can move the item after an item bunched up at MAX_POSITION' do + item1, item2, item3 = create_items_with_positions(run_at_end) + + new_item.move_after(item1) + new_item.save! + + items = [item1, new_item, item2, item3] + + items.each do |item| + expect(item.reset.relative_position).to be_valid_position + end + + expect(items.sort_by(&:relative_position)).to eq(items) + end + + context 'leap-frogging' do + before do + start = RelativePositioning::START_POSITION + item1.update!(relative_position: start + RelativePositioning::IDEAL_DISTANCE * 0) + item2.update!(relative_position: start + RelativePositioning::IDEAL_DISTANCE * 1) + item3.update!(relative_position: start + RelativePositioning::IDEAL_DISTANCE * 2) + end + + let(:item3) { create(factory, default_params) } + + def leap_frog(steps) + a = item1 + b = item2 + + steps.times do |i| + a.move_after(b) + a.save! + a, b = b, a + end + end + + it 'can leap-frog STEPS times before needing to rebalance' do + expect { leap_frog(RelativePositioning::STEPS) } + .not_to change { item3.reload.relative_position } + end + + it 'rebalances after leap-frogging STEPS+1 times' do + expect { leap_frog(RelativePositioning::STEPS + 1) } + .to change { item3.reload.relative_position } + end + end + end + + describe '#move_to_start' do + before do + [item1, item2].each do |item1| + item1.move_to_start && item1.save! + end + end + + it 'moves item to the end' do + new_item.move_to_start + + expect(new_item.relative_position).to be < item2.relative_position + end + + it 'rebalances when there is already an item at the MIN_POSITION' do + item2.update!(relative_position: RelativePositioning::MIN_POSITION) + + new_item.move_to_start + item2.reset + + expect(new_item.relative_position).to be < item2.relative_position + expect(new_item.relative_position).to be >= RelativePositioning::MIN_POSITION + end + + it 'deals with a run of elements at the start' do + item1.update!(relative_position: RelativePositioning::MIN_POSITION + 1) + item2.update!(relative_position: RelativePositioning::MIN_POSITION) + + new_item.move_to_start + item1.reset + item2.reset + + expect(item2.relative_position).to be < item1.relative_position + expect(new_item.relative_position).to be < item2.relative_position + expect(new_item.relative_position).to be >= RelativePositioning::MIN_POSITION + end end describe '#move_to_end' do before do [item1, item2].each do |item1| - item1.move_to_end && item1.save + item1.move_to_end && item1.save! end end @@ -140,12 +548,44 @@ RSpec.shared_examples 'a class that supports relative positioning' do expect(new_item.relative_position).to be > item2.relative_position end + + it 'rebalances when there is already an item at the MAX_POSITION' do + item2.update!(relative_position: RelativePositioning::MAX_POSITION) + + new_item.move_to_end + item2.reset + + expect(new_item.relative_position).to be > item2.relative_position + expect(new_item.relative_position).to be <= RelativePositioning::MAX_POSITION + end + + it 'deals with a run of elements at the end' do + item1.update!(relative_position: RelativePositioning::MAX_POSITION - 1) + item2.update!(relative_position: RelativePositioning::MAX_POSITION) + + new_item.move_to_end + item1.reset + item2.reset + + expect(item2.relative_position).to be > item1.relative_position + expect(new_item.relative_position).to be > item2.relative_position + expect(new_item.relative_position).to be <= RelativePositioning::MAX_POSITION + end end describe '#move_between' do before do - [item1, item2].each do |item1| - item1.move_to_end && item1.save + [item1, item2].each do |item| + item.move_to_end && item.save! + end + end + + shared_examples 'moves item between' do + it 'moves the middle item to between left and right' do + expect do + middle.move_between(left, right) + middle.save! + end.to change { between_exclusive?(left, middle, right) }.from(false).to(true) end end @@ -169,26 +609,26 @@ RSpec.shared_examples 'a class that supports relative positioning' do end it 'positions items even when after and before positions are the same' do - item2.update relative_position: item1.relative_position + item2.update! relative_position: item1.relative_position new_item.move_between(item1, item2) + [item1, item2].each(&:reset) expect(new_item.relative_position).to be > item1.relative_position expect(item1.relative_position).to be < item2.relative_position end - it 'positions items between other two if distance is 1' do - item2.update relative_position: item1.relative_position + 1 - - new_item.move_between(item1, item2) + context 'the two items are next to each other' do + let(:left) { item1 } + let(:middle) { new_item } + let(:right) { create_item(relative_position: item1.relative_position + 1) } - expect(new_item.relative_position).to be > item1.relative_position - expect(item1.relative_position).to be < item2.relative_position + it_behaves_like 'moves item between' end it 'positions item in the middle of other two if distance is big enough' do - item1.update relative_position: 6000 - item2.update relative_position: 10000 + item1.update! relative_position: 6000 + item2.update! relative_position: 10000 new_item.move_between(item1, item2) @@ -196,7 +636,8 @@ RSpec.shared_examples 'a class that supports relative positioning' do end it 'positions item closer to the middle if we are at the very top' do - item2.update relative_position: 6000 + item1.update!(relative_position: 6001) + item2.update!(relative_position: 6000) new_item.move_between(nil, item2) @@ -204,51 +645,53 @@ RSpec.shared_examples 'a class that supports relative positioning' do end it 'positions item closer to the middle if we are at the very bottom' do - new_item.update relative_position: 1 - item1.update relative_position: 6000 - item2.destroy + new_item.update!(relative_position: 1) + item1.update!(relative_position: 6000) + item2.update!(relative_position: 5999) new_item.move_between(item1, nil) expect(new_item.relative_position).to eq(6000 + RelativePositioning::IDEAL_DISTANCE) end - it 'positions item in the middle of other two if distance is not big enough' do - item1.update relative_position: 100 - item2.update relative_position: 400 + it 'positions item in the middle of other two' do + item1.update! relative_position: 100 + item2.update! relative_position: 400 new_item.move_between(item1, item2) expect(new_item.relative_position).to eq(250) end - it 'positions item in the middle of other two is there is no place' do - item1.update relative_position: 100 - item2.update relative_position: 101 + context 'there is no space' do + let(:middle) { new_item } + let(:left) { create_item(relative_position: 100) } + let(:right) { create_item(relative_position: 101) } - new_item.move_between(item1, item2) - - expect(new_item.relative_position).to be_between(item1.relative_position, item2.relative_position).exclusive + it_behaves_like 'moves item between' end - it 'uses rebalancing if there is no place' do - item1.update relative_position: 100 - item2.update relative_position: 101 - item3 = create_item(relative_position: 102) - new_item.update relative_position: 103 + context 'there is a bunch of items' do + let(:items) { create_items_with_positions(100..104) } + let(:left) { items[1] } + let(:middle) { items[3] } + let(:right) { items[2] } - new_item.move_between(item2, item3) - new_item.save! + it_behaves_like 'moves item between' + + it 'handles bunches correctly' do + middle.move_between(left, right) + middle.save! - expect(new_item.relative_position).to be_between(item2.relative_position, item3.relative_position).exclusive - expect(item1.reload.relative_position).not_to eq(100) + expect(items.first.reset.relative_position).to be < middle.relative_position + end end - it 'positions item right if we pass none-sequential parameters' do - item1.update relative_position: 99 - item2.update relative_position: 101 + it 'positions item right if we pass non-sequential parameters' do + item1.update! relative_position: 99 + item2.update! relative_position: 101 item3 = create_item(relative_position: 102) - new_item.update relative_position: 103 + new_item.update! relative_position: 103 new_item.move_between(item1, item3) new_item.save! @@ -280,6 +723,12 @@ RSpec.shared_examples 'a class that supports relative positioning' do expect(positions).to eq([90, 95, 96, 102]) end + it 'raises an error if there is no space' do + items = create_items_with_positions(run_at_start) + + expect { items.last.move_sequence_before }.to raise_error(RelativePositioning::NoSpaceLeft) + end + it 'finds a gap if there are unused positions' do items = create_items_with_positions([100, 101, 102]) @@ -287,7 +736,8 @@ RSpec.shared_examples 'a class that supports relative positioning' do items.last.save! positions = items.map { |item| item.reload.relative_position } - expect(positions).to eq([50, 51, 102]) + + expect(positions.last - positions.second).to be > RelativePositioning::MIN_GAP end end @@ -309,7 +759,33 @@ RSpec.shared_examples 'a class that supports relative positioning' do items.first.save! positions = items.map { |item| item.reload.relative_position } - expect(positions).to eq([100, 601, 602]) + expect(positions.second - positions.first).to be > RelativePositioning::MIN_GAP end + + it 'raises an error if there is no space' do + items = create_items_with_positions(run_at_end) + + expect { items.first.move_sequence_after }.to raise_error(RelativePositioning::NoSpaceLeft) + end + end + + def be_valid_position + be_between(RelativePositioning::MIN_POSITION, RelativePositioning::MAX_POSITION) + end + + def between_exclusive?(left, middle, right) + a, b, c = [left, middle, right].map { |item| item.reset.relative_position } + return false if a.nil? || b.nil? + return a < b if c.nil? + + a < b && b < c + end + + def run_at_end(size = 3) + (RelativePositioning::MAX_POSITION - size)..RelativePositioning::MAX_POSITION + end + + def run_at_start(size = 3) + (RelativePositioning::MIN_POSITION..).take(size) end end diff --git a/spec/support/shared_examples/models/resource_event_shared_examples.rb b/spec/support/shared_examples/models/resource_event_shared_examples.rb new file mode 100644 index 00000000000..c0158f9b24b --- /dev/null +++ b/spec/support/shared_examples/models/resource_event_shared_examples.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.shared_examples 'a resource event' do + let_it_be(:user1) { create(:user) } + let_it_be(:user2) { create(:user) } + + let_it_be(:issue1) { create(:issue, author: user1) } + let_it_be(:issue2) { create(:issue, author: user1) } + let_it_be(:issue3) { create(:issue, author: user2) } + + describe 'importable' do + it { is_expected.to respond_to(:importing?) } + it { is_expected.to respond_to(:imported?) } + end + + describe 'validations' do + it { is_expected.not_to allow_value(nil).for(:user) } + + context 'when importing' do + before do + allow(subject).to receive(:importing?).and_return(true) + end + + it { is_expected.to allow_value(nil).for(:user) } + end + end + + describe 'associations' do + it { is_expected.to belong_to(:user) } + end + + describe '.created_after' do + let!(:created_at1) { 1.day.ago } + let!(:created_at2) { 2.days.ago } + let!(:created_at3) { 3.days.ago } + + let!(:event1) { create(described_class.name.underscore.to_sym, issue: issue1, created_at: created_at1) } + let!(:event2) { create(described_class.name.underscore.to_sym, issue: issue2, created_at: created_at2) } + let!(:event3) { create(described_class.name.underscore.to_sym, issue: issue2, created_at: created_at3) } + + it 'returns the expected events' do + events = described_class.created_after(created_at3) + + expect(events).to contain_exactly(event1, event2) + end + + it 'returns no events if time is after last record time' do + events = described_class.created_after(1.minute.ago) + + expect(events).to be_empty + end + end +end + +RSpec.shared_examples 'a resource event for issues' do + let_it_be(:user1) { create(:user) } + let_it_be(:user2) { create(:user) } + + let_it_be(:issue1) { create(:issue, author: user1) } + let_it_be(:issue2) { create(:issue, author: user1) } + let_it_be(:issue3) { create(:issue, author: user2) } + + describe 'associations' do + it { is_expected.to belong_to(:issue) } + end + + describe '.by_issue' do + let_it_be(:event1) { create(described_class.name.underscore.to_sym, issue: issue1) } + let_it_be(:event2) { create(described_class.name.underscore.to_sym, issue: issue2) } + let_it_be(:event3) { create(described_class.name.underscore.to_sym, issue: issue1) } + + it 'returns the expected records for an issue with events' do + events = described_class.by_issue(issue1) + + expect(events).to contain_exactly(event1, event3) + end + + it 'returns the expected records for an issue with no events' do + events = described_class.by_issue(issue3) + + expect(events).to be_empty + end + end + + describe '.by_issue_ids_and_created_at_earlier_or_equal_to' do + let_it_be(:event1) { create(described_class.name.underscore.to_sym, issue: issue1, created_at: '2020-03-10') } + let_it_be(:event2) { create(described_class.name.underscore.to_sym, issue: issue2, created_at: '2020-03-10') } + let_it_be(:event3) { create(described_class.name.underscore.to_sym, issue: issue1, created_at: '2020-03-12') } + + it 'returns the expected records for an issue with events' do + events = described_class.by_issue_ids_and_created_at_earlier_or_equal_to([issue1.id, issue2.id], '2020-03-11 23:59:59') + + expect(events).to contain_exactly(event1, event2) + end + + it 'returns the expected records for an issue with no events' do + events = described_class.by_issue_ids_and_created_at_earlier_or_equal_to(issue3, '2020-03-12') + + expect(events).to be_empty + end + end + + if described_class.method_defined?(:issuable) + describe '#issuable' do + let_it_be(:event1) { create(described_class.name.underscore.to_sym, issue: issue2) } + + it 'returns the expected issuable' do + expect(event1.issuable).to eq(issue2) + end + end + end +end + +RSpec.shared_examples 'a resource event for merge requests' do + let_it_be(:user1) { create(:user) } + let_it_be(:user2) { create(:user) } + + let_it_be(:merge_request1) { create(:merge_request, author: user1) } + let_it_be(:merge_request2) { create(:merge_request, author: user1) } + let_it_be(:merge_request3) { create(:merge_request, author: user2) } + + describe 'associations' do + it { is_expected.to belong_to(:merge_request) } + end + + describe '.by_merge_request' do + let_it_be(:event1) { create(described_class.name.underscore.to_sym, merge_request: merge_request1) } + let_it_be(:event2) { create(described_class.name.underscore.to_sym, merge_request: merge_request2) } + let_it_be(:event3) { create(described_class.name.underscore.to_sym, merge_request: merge_request1) } + + it 'returns the expected records for an issue with events' do + events = described_class.by_merge_request(merge_request1) + + expect(events).to contain_exactly(event1, event3) + end + + it 'returns the expected records for an issue with no events' do + events = described_class.by_merge_request(merge_request3) + + expect(events).to be_empty + end + end + + if described_class.method_defined?(:issuable) + describe '#issuable' do + let_it_be(:event1) { create(described_class.name.underscore.to_sym, merge_request: merge_request2) } + + it 'returns the expected issuable' do + expect(event1.issuable).to eq(merge_request2) + end + end + end +end diff --git a/spec/support/shared_examples/models/resource_timebox_event_shared_examples.rb b/spec/support/shared_examples/models/resource_timebox_event_shared_examples.rb new file mode 100644 index 00000000000..07552b62cdd --- /dev/null +++ b/spec/support/shared_examples/models/resource_timebox_event_shared_examples.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.shared_examples 'timebox resource event validations' do + describe 'validations' do + context 'when issue and merge_request are both nil' do + subject { build(described_class.name.underscore.to_sym, issue: nil, merge_request: nil) } + + it { is_expected.not_to be_valid } + end + + context 'when issue and merge_request are both set' do + subject { build(described_class.name.underscore.to_sym, issue: build(:issue), merge_request: build(:merge_request)) } + + it { is_expected.not_to be_valid } + end + + context 'when issue is set' do + subject { create(described_class.name.underscore.to_sym, issue: create(:issue), merge_request: nil) } + + it { is_expected.to be_valid } + end + + context 'when merge_request is set' do + subject { create(described_class.name.underscore.to_sym, issue: nil, merge_request: create(:merge_request)) } + + it { is_expected.to be_valid } + end + end +end + +RSpec.shared_examples 'timebox resource event states' do + describe 'states' do + [Issue, MergeRequest].each do |klass| + klass.available_states.each do |state| + it "supports state #{state.first} for #{klass.name.underscore}" do + model = create(klass.name.underscore, state: state[0]) + key = model.class.name.underscore + event = build(described_class.name.underscore.to_sym, key => model, state: model.state) + + expect(event.state).to eq(state[0]) + end + end + end + end +end + +RSpec.shared_examples 'queryable timebox action resource event' do |expected_results_for_actions| + [Issue, MergeRequest].each do |klass| + expected_results_for_actions.each do |action, expected_result| + it "is #{expected_result} for action #{action} on #{klass.name.underscore}" do + model = build(klass.name.underscore) + key = model.class.name.underscore + event = build(described_class.name.underscore.to_sym, key => model, action: action) + + expect(event.send(query_method)).to eq(expected_result) + end + end + end +end + +RSpec.shared_examples 'timebox resource event actions' do + describe '#added?' do + it_behaves_like 'queryable timebox action resource event', { add: true, remove: false } do + let(:query_method) { :add? } + end + end + + describe '#removed?' do + it_behaves_like 'queryable timebox action resource event', { add: false, remove: true } do + let(:query_method) { :remove? } + end + end +end diff --git a/spec/support/shared_examples/models/update_project_statistics_shared_examples.rb b/spec/support/shared_examples/models/update_project_statistics_shared_examples.rb index 7d70df82ec7..7f0da19996e 100644 --- a/spec/support/shared_examples/models/update_project_statistics_shared_examples.rb +++ b/spec/support/shared_examples/models/update_project_statistics_shared_examples.rb @@ -17,11 +17,14 @@ RSpec.shared_examples 'UpdateProjectStatistics' do context 'when creating' do it 'updates the project statistics' do - delta = read_attribute + delta0 = reload_stat - expect { subject.save! } - .to change { reload_stat } - .by(delta) + subject.save! + + delta1 = reload_stat + + expect(delta1).to eq(delta0 + read_attribute) + expect(delta1).to be > delta0 end it 'schedules a namespace statistics worker' do @@ -80,15 +83,14 @@ RSpec.shared_examples 'UpdateProjectStatistics' do end it 'updates the project statistics' do - delta = -read_attribute + delta0 = reload_stat - expect(ProjectStatistics) - .to receive(:increment_statistic) - .and_call_original + subject.destroy! - expect { subject.destroy! } - .to change { reload_stat } - .by(delta) + delta1 = reload_stat + + expect(delta1).to eq(delta0 - read_attribute) + expect(delta1).to be < delta0 end it 'schedules a namespace statistics worker' do |