summaryrefslogtreecommitdiff
path: root/spec
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-11-05 00:09:16 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2020-11-05 00:09:16 +0000
commit4fd77e112fac07c5b904668b7d5f500589f8d2d5 (patch)
treea7f82bdc0eaba09f1aaafc484509bfe4b664c954 /spec
parent8a55c3263f1f37fdc9ee772bc0d38133dfe94495 (diff)
downloadgitlab-ce-4fd77e112fac07c5b904668b7d5f500589f8d2d5.tar.gz
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec')
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js4
-rw-r--r--spec/graphql/types/group_invitation_type_spec.rb19
-rw-r--r--spec/graphql/types/invitation_interface_spec.rb43
-rw-r--r--spec/graphql/types/project_invitation_type_spec.rb19
-rw-r--r--spec/lib/api/validations/validators/email_or_email_list_spec.rb28
-rw-r--r--spec/lib/gitlab/path_regex_spec.rb4
-rw-r--r--spec/lib/gitlab/search/sort_options_spec.rb34
-rw-r--r--spec/lib/gitlab/usage_data_spec.rb4
-rw-r--r--spec/migrations/rename_sitemap_namespace_spec.rb30
-rw-r--r--spec/models/concerns/from_union_spec.rb10
-rw-r--r--spec/requests/api/invitations_spec.rb207
-rw-r--r--spec/requests/api/search_spec.rb54
-rw-r--r--spec/services/members/invite_service_spec.rb66
13 files changed, 510 insertions, 12 deletions
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js
index 0ec814e3f15..5d91eafbabf 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js
@@ -120,7 +120,9 @@ describe('MilestoneToken', () => {
wrapper.vm.fetchMilestoneBySearchTerm('foo');
return waitForPromises().then(() => {
- expect(createFlash).toHaveBeenCalledWith('There was a problem fetching milestones.');
+ expect(createFlash).toHaveBeenCalledWith({
+ message: 'There was a problem fetching milestones.',
+ });
});
});
diff --git a/spec/graphql/types/group_invitation_type_spec.rb b/spec/graphql/types/group_invitation_type_spec.rb
new file mode 100644
index 00000000000..dab2d43fc90
--- /dev/null
+++ b/spec/graphql/types/group_invitation_type_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Types::GroupInvitationType do
+ specify { expect(described_class).to expose_permissions_using(Types::PermissionTypes::Group) }
+
+ specify { expect(described_class.graphql_name).to eq('GroupInvitation') }
+
+ specify { expect(described_class).to require_graphql_authorizations(:read_group) }
+
+ it 'has the expected fields' do
+ expected_fields = %w[
+ email access_level created_by created_at updated_at expires_at group
+ ]
+
+ expect(described_class).to include_graphql_fields(*expected_fields)
+ end
+end
diff --git a/spec/graphql/types/invitation_interface_spec.rb b/spec/graphql/types/invitation_interface_spec.rb
new file mode 100644
index 00000000000..8f345c58ca3
--- /dev/null
+++ b/spec/graphql/types/invitation_interface_spec.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Types::InvitationInterface do
+ it 'exposes the expected fields' do
+ expected_fields = %i[
+ email
+ access_level
+ created_by
+ created_at
+ updated_at
+ expires_at
+ user
+ ]
+
+ expect(described_class).to have_graphql_fields(*expected_fields)
+ end
+
+ describe '.resolve_type' do
+ subject { described_class.resolve_type(object, {}) }
+
+ context 'for project member' do
+ let(:object) { build(:project_member) }
+
+ it { is_expected.to be Types::ProjectInvitationType }
+ end
+
+ context 'for group member' do
+ let(:object) { build(:group_member) }
+
+ it { is_expected.to be Types::GroupInvitationType }
+ end
+
+ context 'for an unknown type' do
+ let(:object) { build(:user) }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::BaseError)
+ end
+ end
+ end
+end
diff --git a/spec/graphql/types/project_invitation_type_spec.rb b/spec/graphql/types/project_invitation_type_spec.rb
new file mode 100644
index 00000000000..148a763a5fa
--- /dev/null
+++ b/spec/graphql/types/project_invitation_type_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Types::ProjectInvitationType do
+ specify { expect(described_class).to expose_permissions_using(Types::PermissionTypes::Project) }
+
+ specify { expect(described_class.graphql_name).to eq('ProjectInvitation') }
+
+ specify { expect(described_class).to require_graphql_authorizations(:read_project) }
+
+ it 'has the expected fields' do
+ expected_fields = %w[
+ access_level created_by created_at updated_at expires_at project user
+ ]
+
+ expect(described_class).to include_graphql_fields(*expected_fields)
+ end
+end
diff --git a/spec/lib/api/validations/validators/email_or_email_list_spec.rb b/spec/lib/api/validations/validators/email_or_email_list_spec.rb
new file mode 100644
index 00000000000..ac3111c2319
--- /dev/null
+++ b/spec/lib/api/validations/validators/email_or_email_list_spec.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Validations::Validators::EmailOrEmailList do
+ include ApiValidatorsHelpers
+
+ subject do
+ described_class.new(['email'], {}, false, scope.new)
+ end
+
+ context 'with valid email addresses' do
+ it 'does not raise a validation error' do
+ expect_no_validation_error('test' => 'test@example.org')
+ expect_no_validation_error('test' => 'test1@example.com,test2@example.org')
+ expect_no_validation_error('test' => 'test1@example.com,test2@example.org,test3@example.co.uk')
+ end
+ end
+
+ context 'including any invalid email address' do
+ it 'raises a validation error' do
+ expect_validation_error('test' => 'not')
+ expect_validation_error('test' => '@example.com')
+ expect_validation_error('test' => 'test1@example.com,asdf')
+ expect_validation_error('test' => 'asdf,testa1@example.com,asdf')
+ end
+ end
+end
diff --git a/spec/lib/gitlab/path_regex_spec.rb b/spec/lib/gitlab/path_regex_spec.rb
index a3977528537..9427fdfc0fe 100644
--- a/spec/lib/gitlab/path_regex_spec.rb
+++ b/spec/lib/gitlab/path_regex_spec.rb
@@ -107,7 +107,7 @@ RSpec.describe Gitlab::PathRegex do
end
let(:sitemap_words) do
- %w(sitemap.xml sitemap.xml.gz)
+ %w(sitemap sitemap.xml sitemap.xml.gz)
end
let(:ee_top_level_words) do
@@ -177,7 +177,7 @@ RSpec.describe Gitlab::PathRegex do
# We ban new items in this list, see https://gitlab.com/gitlab-org/gitlab/-/issues/215362
it 'does not allow expansion' do
- expect(described_class::TOP_LEVEL_ROUTES.size).to eq(43)
+ expect(described_class::TOP_LEVEL_ROUTES.size).to eq(44)
end
end
diff --git a/spec/lib/gitlab/search/sort_options_spec.rb b/spec/lib/gitlab/search/sort_options_spec.rb
new file mode 100644
index 00000000000..2044fdfc894
--- /dev/null
+++ b/spec/lib/gitlab/search/sort_options_spec.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+require 'gitlab/search/sort_options'
+
+RSpec.describe ::Gitlab::Search::SortOptions do
+ describe '.sort_and_direction' do
+ context 'using order_by and sort' do
+ it 'returns matched options' do
+ expect(described_class.sort_and_direction('created_at', 'asc')).to eq(:created_at_asc)
+ expect(described_class.sort_and_direction('created_at', 'desc')).to eq(:created_at_desc)
+ end
+ end
+
+ context 'using just sort' do
+ it 'returns matched options' do
+ expect(described_class.sort_and_direction(nil, 'created_asc')).to eq(:created_at_asc)
+ expect(described_class.sort_and_direction(nil, 'created_desc')).to eq(:created_at_desc)
+ end
+ end
+
+ context 'when unknown option' do
+ it 'returns unknown' do
+ expect(described_class.sort_and_direction(nil, 'foo_asc')).to eq(:unknown)
+ expect(described_class.sort_and_direction(nil, 'bar_desc')).to eq(:unknown)
+ expect(described_class.sort_and_direction(nil, 'created_bar')).to eq(:unknown)
+
+ expect(described_class.sort_and_direction('created_at', 'foo')).to eq(:unknown)
+ expect(described_class.sort_and_direction('foo', 'desc')).to eq(:unknown)
+ expect(described_class.sort_and_direction('created_at', nil)).to eq(:unknown)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb
index d3a3f24732c..2e2c1806e51 100644
--- a/spec/lib/gitlab/usage_data_spec.rb
+++ b/spec/lib/gitlab/usage_data_spec.rb
@@ -308,6 +308,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
projects_with_tracing_enabled: 2,
projects_with_error_tracking_enabled: 2
)
+
expect(described_class.usage_activity_by_stage_monitor(described_class.last_28_days_time_period)).to include(
clusters: 1,
clusters_applications_prometheus: 1,
@@ -470,6 +471,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
expect(count_data[:projects_with_prometheus_alerts]).to eq(2)
expect(count_data[:projects_with_terraform_reports]).to eq(2)
expect(count_data[:projects_with_terraform_states]).to eq(2)
+ expect(count_data[:projects_with_alerts_created]).to eq(1)
expect(count_data[:protected_branches]).to eq(2)
expect(count_data[:protected_branches_except_default]).to eq(1)
expect(count_data[:terraform_reports]).to eq(6)
@@ -611,6 +613,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
create(:deployment, :success, deployment_options)
create(:project_snippet, project: project, created_at: n.days.ago)
create(:personal_snippet, created_at: n.days.ago)
+ create(:alert_management_alert, project: project, created_at: n.days.ago)
end
stub_application_setting(self_monitoring_project: project)
@@ -631,6 +634,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
expect(counts_monthly[:snippets]).to eq(2)
expect(counts_monthly[:personal_snippets]).to eq(1)
expect(counts_monthly[:project_snippets]).to eq(1)
+ expect(counts_monthly[:projects_with_alerts_created]).to eq(1)
expect(counts_monthly[:packages]).to eq(1)
expect(counts_monthly[:promoted_issues]).to eq(1)
end
diff --git a/spec/migrations/rename_sitemap_namespace_spec.rb b/spec/migrations/rename_sitemap_namespace_spec.rb
new file mode 100644
index 00000000000..83f0721c600
--- /dev/null
+++ b/spec/migrations/rename_sitemap_namespace_spec.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20201102112206_rename_sitemap_namespace.rb')
+
+RSpec.describe RenameSitemapNamespace do
+ let(:namespaces) { table(:namespaces) }
+ let(:routes) { table(:routes) }
+ let(:sitemap_path) { 'sitemap' }
+
+ it 'correctly run #up and #down' do
+ create_namespace(sitemap_path)
+
+ reversible_migration do |migration|
+ migration.before -> {
+ expect(namespaces.pluck(:path)).to contain_exactly(sitemap_path)
+ }
+
+ migration.after -> {
+ expect(namespaces.pluck(:path)).to contain_exactly(sitemap_path + '0')
+ }
+ end
+ end
+
+ def create_namespace(path)
+ namespaces.create!(name: path, path: path).tap do |namespace|
+ routes.create!(path: namespace.path, name: namespace.name, source_id: namespace.id, source_type: 'Namespace')
+ end
+ end
+end
diff --git a/spec/models/concerns/from_union_spec.rb b/spec/models/concerns/from_union_spec.rb
index bd2893090a8..4f4d948fe48 100644
--- a/spec/models/concerns/from_union_spec.rb
+++ b/spec/models/concerns/from_union_spec.rb
@@ -3,13 +3,5 @@
require 'spec_helper'
RSpec.describe FromUnion do
- [true, false].each do |sql_set_operator|
- context "when sql-set-operators feature flag is #{sql_set_operator}" do
- before do
- stub_feature_flags(sql_set_operators: sql_set_operator)
- end
-
- it_behaves_like 'from set operator', Gitlab::SQL::Union
- end
- end
+ it_behaves_like 'from set operator', Gitlab::SQL::Union
end
diff --git a/spec/requests/api/invitations_spec.rb b/spec/requests/api/invitations_spec.rb
new file mode 100644
index 00000000000..9befd4f533a
--- /dev/null
+++ b/spec/requests/api/invitations_spec.rb
@@ -0,0 +1,207 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Invitations do
+ let(:maintainer) { create(:user, username: 'maintainer_user') }
+ let(:developer) { create(:user) }
+ let(:access_requester) { create(:user) }
+ let(:stranger) { create(:user) }
+ let(:email) { 'email@example.org' }
+
+ let(:project) do
+ create(:project, :public, creator_id: maintainer.id, namespace: maintainer.namespace) do |project|
+ project.add_developer(developer)
+ project.add_maintainer(maintainer)
+ project.request_access(access_requester)
+ end
+ end
+
+ let!(:group) do
+ create(:group, :public) do |group|
+ group.add_developer(developer)
+ group.add_owner(maintainer)
+ group.request_access(access_requester)
+ end
+ end
+
+ def invitations_url(source, user)
+ api("/#{source.model_name.plural}/#{source.id}/invitations", user)
+ end
+
+ shared_examples 'POST /:source_type/:id/invitations' do |source_type|
+ context "with :source_type == #{source_type.pluralize}" do
+ it_behaves_like 'a 404 response when source is private' do
+ let(:route) do
+ post invitations_url(source, stranger),
+ params: { email: email, access_level: Member::MAINTAINER }
+ end
+ end
+
+ context 'when authenticated as a non-member or member with insufficient rights' do
+ %i[access_requester stranger developer].each do |type|
+ context "as a #{type}" do
+ it 'returns 403' do
+ user = public_send(type)
+
+ post invitations_url(source, user), params: { email: email, access_level: Member::MAINTAINER }
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+ end
+ end
+
+ context 'when authenticated as a maintainer/owner' do
+ context 'and new member is already a requester' do
+ it 'does not transform the requester into a proper member' do
+ expect do
+ post api("/#{source_type.pluralize}/#{source.id}/invitations", maintainer),
+ params: { email: email, access_level: Member::MAINTAINER }
+
+ expect(response).to have_gitlab_http_status(:created)
+ end.not_to change { source.members.count }
+ end
+ end
+
+ it 'invites a new member' do
+ expect do
+ post api("/#{source_type.pluralize}/#{source.id}/invitations", maintainer),
+ params: { email: email, access_level: Member::DEVELOPER }
+
+ expect(response).to have_gitlab_http_status(:created)
+ end.to change { source.requesters.count }.by(1)
+ end
+
+ it 'invites a list of new email addresses' do
+ expect do
+ email_list = 'email1@example.com,email2@example.com'
+
+ post api("/#{source_type.pluralize}/#{source.id}/invitations", maintainer),
+ params: { email: email_list, access_level: Member::DEVELOPER }
+
+ expect(response).to have_gitlab_http_status(:created)
+ end.to change { source.requesters.count }.by(2)
+ end
+ end
+
+ context 'access levels' do
+ it 'does not create the member if group level is higher' do
+ parent = create(:group)
+
+ group.update!(parent: parent)
+ project.update!(group: group)
+ parent.add_developer(stranger)
+
+ post api("/#{source_type.pluralize}/#{source.id}/invitations", maintainer),
+ params: { email: stranger.email, access_level: Member::REPORTER }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['message'][stranger.email]).to eq("Access level should be greater than or equal to Developer inherited membership from group #{parent.name}")
+ end
+
+ it 'creates the member if group level is lower' do
+ parent = create(:group)
+
+ group.update!(parent: parent)
+ project.update!(group: group)
+ parent.add_developer(stranger)
+
+ post api("/#{source_type.pluralize}/#{source.id}/invitations", maintainer),
+ params: { email: stranger.email, access_level: Member::MAINTAINER }
+
+ expect(response).to have_gitlab_http_status(:created)
+ end
+ end
+
+ context 'access expiry date' do
+ subject do
+ post api("/#{source_type.pluralize}/#{source.id}/invitations", maintainer),
+ params: { email: email, access_level: Member::DEVELOPER, expires_at: expires_at }
+ end
+
+ context 'when set to a date in the past' do
+ let(:expires_at) { 2.days.ago.to_date }
+
+ it 'does not create a member' do
+ expect do
+ subject
+ end.not_to change { source.members.count }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['message'][email]).to eq('Expires at cannot be a date in the past')
+ end
+ end
+
+ context 'when set to a date in the future' do
+ let(:expires_at) { 2.days.from_now.to_date }
+
+ it 'invites a member' do
+ expect do
+ subject
+ end.to change { source.requesters.count }.by(1)
+
+ expect(response).to have_gitlab_http_status(:created)
+ end
+ end
+ end
+
+ it "returns a message if member already exists" do
+ post api("/#{source_type.pluralize}/#{source.id}/invitations", maintainer),
+ params: { email: maintainer.email, access_level: Member::MAINTAINER }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['message'][maintainer.email]).to eq("Already a member of #{source.name}")
+ end
+
+ it 'returns 404 when the email is not valid' do
+ post api("/#{source_type.pluralize}/#{source.id}/invitations", maintainer),
+ params: { email: '', access_level: Member::MAINTAINER }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['message']).to eq('Email cannot be blank')
+ end
+
+ it 'returns 404 when the email list is not a valid format' do
+ post api("/#{source_type.pluralize}/#{source.id}/invitations", maintainer),
+ params: { email: 'email1@example.com,not-an-email', access_level: Member::MAINTAINER }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['error']).to eq('email contains an invalid email address')
+ end
+
+ it 'returns 400 when email is not given' do
+ post api("/#{source_type.pluralize}/#{source.id}/invitations", maintainer),
+ params: { access_level: Member::MAINTAINER }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+
+ it 'returns 400 when access_level is not given' do
+ post api("/#{source_type.pluralize}/#{source.id}/invitations", maintainer),
+ params: { email: email }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+
+ it 'returns 400 when access_level is not valid' do
+ post invitations_url(source, maintainer),
+ params: { email: email, access_level: non_existing_record_access_level }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+ end
+
+ describe 'POST /projects/:id/invitations' do
+ it_behaves_like 'POST /:source_type/:id/invitations', 'project' do
+ let(:source) { project }
+ end
+ end
+
+ describe 'POST /groups/:id/invitations' do
+ it_behaves_like 'POST /:source_type/:id/invitations', 'group' do
+ let(:source) { group }
+ end
+ end
+end
diff --git a/spec/requests/api/search_spec.rb b/spec/requests/api/search_spec.rb
index de1aedc67a6..8012892a571 100644
--- a/spec/requests/api/search_spec.rb
+++ b/spec/requests/api/search_spec.rb
@@ -23,6 +23,48 @@ RSpec.describe API::Search do
end
end
+ shared_examples 'orderable by created_at' do |scope:|
+ it 'allows ordering results by created_at asc' do
+ get api(endpoint, user), params: { scope: scope, search: 'sortable', order_by: 'created_at', sort: 'asc' }
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(json_response.count).to be > 1
+
+ created_ats = json_response.map { |r| Time.parse(r['created_at']) }
+ expect(created_ats.uniq.count).to be > 1
+
+ expect(created_ats).to eq(created_ats.sort)
+ end
+
+ it 'allows ordering results by created_at desc' do
+ get api(endpoint, user), params: { scope: scope, search: 'sortable', order_by: 'created_at', sort: 'desc' }
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(json_response.count).to be > 1
+
+ created_ats = json_response.map { |r| Time.parse(r['created_at']) }
+ expect(created_ats.uniq.count).to be > 1
+
+ expect(created_ats).to eq(created_ats.sort.reverse)
+ end
+ end
+
+ shared_examples 'issues orderable by created_at' do
+ before do
+ create_list(:issue, 3, title: 'sortable item', project: project)
+ end
+
+ it_behaves_like 'orderable by created_at', scope: :issues
+ end
+
+ shared_examples 'merge_requests orderable by created_at' do
+ before do
+ create_list(:merge_request, 3, :unique_branches, title: 'sortable item', target_project: repo_project, source_project: repo_project)
+ end
+
+ it_behaves_like 'orderable by created_at', scope: :merge_requests
+ end
+
shared_examples 'pagination' do |scope:, search: ''|
it 'returns a different result for each page' do
get api(endpoint, user), params: { scope: scope, search: search, page: 1, per_page: 1 }
@@ -121,6 +163,8 @@ RSpec.describe API::Search do
it_behaves_like 'ping counters', scope: :issues
+ it_behaves_like 'issues orderable by created_at'
+
describe 'pagination' do
before do
create(:issue, project: project, title: 'another issue')
@@ -181,6 +225,8 @@ RSpec.describe API::Search do
it_behaves_like 'ping counters', scope: :merge_requests
+ it_behaves_like 'merge_requests orderable by created_at'
+
describe 'pagination' do
before do
create(:merge_request, source_project: repo_project, title: 'another mr', target_branch: 'another_branch')
@@ -354,6 +400,8 @@ RSpec.describe API::Search do
it_behaves_like 'ping counters', scope: :issues
+ it_behaves_like 'issues orderable by created_at'
+
describe 'pagination' do
before do
create(:issue, project: project, title: 'another issue')
@@ -374,6 +422,8 @@ RSpec.describe API::Search do
it_behaves_like 'ping counters', scope: :merge_requests
+ it_behaves_like 'merge_requests orderable by created_at'
+
describe 'pagination' do
before do
create(:merge_request, source_project: repo_project, title: 'another mr', target_branch: 'another_branch')
@@ -506,6 +556,8 @@ RSpec.describe API::Search do
it_behaves_like 'ping counters', scope: :issues
+ it_behaves_like 'issues orderable by created_at'
+
describe 'pagination' do
before do
create(:issue, project: project, title: 'another issue')
@@ -536,6 +588,8 @@ RSpec.describe API::Search do
it_behaves_like 'ping counters', scope: :merge_requests
+ it_behaves_like 'merge_requests orderable by created_at'
+
describe 'pagination' do
before do
create(:merge_request, source_project: repo_project, title: 'another mr', target_branch: 'another_branch')
diff --git a/spec/services/members/invite_service_spec.rb b/spec/services/members/invite_service_spec.rb
new file mode 100644
index 00000000000..12a1a54696b
--- /dev/null
+++ b/spec/services/members/invite_service_spec.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Members::InviteService do
+ let(:project) { create(:project) }
+ let(:user) { create(:user) }
+ let(:project_user) { create(:user) }
+
+ before do
+ project.add_maintainer(user)
+ end
+
+ it 'adds an existing user to members' do
+ params = { email: project_user.email.to_s, access_level: Gitlab::Access::GUEST }
+ result = described_class.new(user, params).execute(project)
+
+ expect(result[:status]).to eq(:success)
+ expect(project.users).to include project_user
+ end
+
+ it 'creates a new user for an unknown email address' do
+ params = { email: 'email@example.org', access_level: Gitlab::Access::GUEST }
+ result = described_class.new(user, params).execute(project)
+
+ expect(result[:status]).to eq(:success)
+ end
+
+ it 'limits the number of emails to 100' do
+ emails = Array.new(101).map { |n| "email#{n}@example.com" }
+ params = { email: emails, access_level: Gitlab::Access::GUEST }
+
+ result = described_class.new(user, params).execute(project)
+
+ expect(result[:status]).to eq(:error)
+ expect(result[:message]).to eq('Too many users specified (limit is 100)')
+ end
+
+ it 'does not invite an invalid email' do
+ params = { email: project_user.id.to_s, access_level: Gitlab::Access::GUEST }
+ result = described_class.new(user, params).execute(project)
+
+ expect(result[:status]).to eq(:error)
+ expect(result[:message][project_user.id.to_s]).to eq("Invite email is invalid")
+ expect(project.users).not_to include project_user
+ end
+
+ it 'does not invite to an invalid access level' do
+ params = { email: project_user.email, access_level: -1 }
+ result = described_class.new(user, params).execute(project)
+
+ expect(result[:status]).to eq(:error)
+ expect(result[:message][project_user.email]).to eq("Access level is not included in the list")
+ end
+
+ it 'does not add a member with an existing invite' do
+ invited_member = create(:project_member, :invited, project: project)
+
+ params = { email: invited_member.invite_email,
+ access_level: Gitlab::Access::GUEST }
+ result = described_class.new(user, params).execute(project)
+
+ expect(result[:status]).to eq(:error)
+ expect(result[:message][invited_member.invite_email]).to eq("Member already invited to #{project.name}")
+ end
+end