diff options
author | Grzegorz Bizon <grzesiek.bizon@gmail.com> | 2017-03-21 14:22:56 +0100 |
---|---|---|
committer | Grzegorz Bizon <grzesiek.bizon@gmail.com> | 2017-03-21 14:22:56 +0100 |
commit | c5912ecd73560b730eda625c77d900ca23ab16d5 (patch) | |
tree | 8f7288b6209fb7e542e5d3bf867138ea6bde7faf /spec | |
parent | 53d332d3c73f8a883fa54d8eaaf91f92da73c33f (diff) | |
parent | 1e5888d115df1973cd5af0aa95013dbbf29ddefd (diff) | |
download | gitlab-ce-c5912ecd73560b730eda625c77d900ca23ab16d5.tar.gz |
Merge branch 'master' into feature/multi-level-container-registry-images
* master: (1327 commits)
Merge branch 'render-json-leak' into 'security'
Merge branch 'ssrf' into 'security'
Merge branch 'ssrf' into 'security'
Merge branch 'fix-links-target-blank' into 'security'
Merge branch '28058-hide-emails-in-atom-feeds' into 'security'
Fix karma test
Reset filters after click
Handle Route#name being nil after an update
Only add frontend code coverage instrumentation when generating coverage report
fix recompile assets step in 9.0 upgrade guide to use yarn
Undo explicit conversion to Integer
Make level_value accept string integers
Make feature spec more robust
Removed d3.js from the main application.js bundle
Extend compound status for manual actions specs
Update css to be nice and tidy.
Fix pipeline status for transition between stages
add an index to the ghost column
Return 404 in project issues API endpoint when project cannot be found
Improve rename projects migration
...
Conflicts:
doc/ci/docker/using_docker_build.md
spec/lib/gitlab/import_export/all_models.yml
Diffstat (limited to 'spec')
666 files changed, 25680 insertions, 5967 deletions
diff --git a/spec/config/mail_room_spec.rb b/spec/config/mail_room_spec.rb index 0b8ff006d22..092048a6259 100644 --- a/spec/config/mail_room_spec.rb +++ b/spec/config/mail_room_spec.rb @@ -1,20 +1,36 @@ require 'spec_helper' describe 'mail_room.yml' do - let(:config_path) { 'config/mail_room.yml' } - let(:configuration) { YAML.load(ERB.new(File.read(config_path)).result) } - before(:each) { clear_raw_config } - after(:each) { clear_raw_config } + include StubENV - context 'when incoming email is disabled' do - before do - ENV['MAIL_ROOM_GITLAB_CONFIG_FILE'] = Rails.root.join('spec/fixtures/config/mail_room_disabled.yml').to_s - Gitlab::MailRoom.reset_config! - end + let(:mailroom_config_path) { 'config/mail_room.yml' } + let(:gitlab_config_path) { 'config/mail_room.yml' } + let(:redis_config_path) { 'config/resque.yml' } - after do - ENV['MAIL_ROOM_GITLAB_CONFIG_FILE'] = nil - end + let(:configuration) do + vars = { + 'MAIL_ROOM_GITLAB_CONFIG_FILE' => absolute_path(gitlab_config_path), + 'GITLAB_REDIS_CONFIG_FILE' => absolute_path(redis_config_path) + } + cmd = "puts ERB.new(File.read(#{absolute_path(mailroom_config_path).inspect})).result" + + output, status = Gitlab::Popen.popen(%W(ruby -rerb -e #{cmd}), absolute_path('config'), vars) + raise "Error interpreting #{mailroom_config_path}: #{output}" unless status.zero? + + YAML.load(output) + end + + before(:each) do + stub_env('GITLAB_REDIS_CONFIG_FILE', absolute_path(redis_config_path)) + clear_redis_raw_config + end + + after(:each) do + clear_redis_raw_config + end + + context 'when incoming email is disabled' do + let(:gitlab_config_path) { 'spec/fixtures/config/mail_room_disabled.yml' } it 'contains no configuration' do expect(configuration[:mailboxes]).to be_nil @@ -22,21 +38,12 @@ describe 'mail_room.yml' do end context 'when incoming email is enabled' do - let(:redis_config) { Rails.root.join('spec/fixtures/config/redis_new_format_host.yml') } - let(:gitlab_redis) { Gitlab::Redis.new(Rails.env) } - - before do - ENV['MAIL_ROOM_GITLAB_CONFIG_FILE'] = Rails.root.join('spec/fixtures/config/mail_room_enabled.yml').to_s - Gitlab::MailRoom.reset_config! - end + let(:gitlab_config_path) { 'spec/fixtures/config/mail_room_enabled.yml' } + let(:redis_config_path) { 'spec/fixtures/config/redis_new_format_host.yml' } - after do - ENV['MAIL_ROOM_GITLAB_CONFIG_FILE'] = nil - end + let(:gitlab_redis) { Gitlab::Redis.new(Rails.env) } it 'contains the intended configuration' do - stub_const('Gitlab::Redis::CONFIG_FILE', redis_config) - expect(configuration[:mailboxes].length).to eq(1) mailbox = configuration[:mailboxes].first @@ -66,9 +73,13 @@ describe 'mail_room.yml' do end end - def clear_raw_config + def clear_redis_raw_config Gitlab::Redis.remove_instance_variable(:@_raw_config) rescue NameError # raised if @_raw_config was not set; ignore end + + def absolute_path(path) + Rails.root.join(path).to_s + end end diff --git a/spec/controllers/admin/application_settings_controller_spec.rb b/spec/controllers/admin/application_settings_controller_spec.rb new file mode 100644 index 00000000000..84a1ce773a1 --- /dev/null +++ b/spec/controllers/admin/application_settings_controller_spec.rb @@ -0,0 +1,28 @@ +require 'spec_helper' + +describe Admin::ApplicationSettingsController do + include StubENV + + let(:admin) { create(:admin) } + + before do + sign_in(admin) + stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false') + end + + describe 'PATCH #update' do + it 'updates the default_project_visibility for string value' do + patch :update, application_setting: { default_project_visibility: "20" } + + expect(response).to redirect_to(admin_application_settings_path) + expect(ApplicationSetting.current.default_project_visibility).to eq Gitlab::VisibilityLevel::PUBLIC + end + + it 'falls back to default with default_project_visibility setting is omitted' do + patch :update, application_setting: {} + + expect(response).to redirect_to(admin_application_settings_path) + expect(ApplicationSetting.current.default_project_visibility).to eq Gitlab::VisibilityLevel::PRIVATE + end + end +end diff --git a/spec/controllers/admin/applications_controller_spec.rb b/spec/controllers/admin/applications_controller_spec.rb new file mode 100644 index 00000000000..e311b8a63b2 --- /dev/null +++ b/spec/controllers/admin/applications_controller_spec.rb @@ -0,0 +1,65 @@ +require 'spec_helper' + +describe Admin::ApplicationsController do + let(:admin) { create(:admin) } + let(:application) { create(:oauth_application, owner_id: nil, owner_type: nil) } + + before do + sign_in(admin) + end + + describe 'GET #new' do + it 'renders the application form' do + get :new + + expect(response).to render_template :new + expect(assigns[:scopes]).to be_kind_of(Doorkeeper::OAuth::Scopes) + end + end + + describe 'GET #edit' do + it 'renders the application form' do + get :edit, id: application.id + + expect(response).to render_template :edit + expect(assigns[:scopes]).to be_kind_of(Doorkeeper::OAuth::Scopes) + end + end + + describe 'POST #create' do + it 'creates the application' do + expect do + post :create, doorkeeper_application: attributes_for(:application) + end.to change { Doorkeeper::Application.count }.by(1) + + application = Doorkeeper::Application.last + + expect(response).to redirect_to(admin_application_path(application)) + end + + it 'renders the application form on errors' do + expect do + post :create, doorkeeper_application: attributes_for(:application).merge(redirect_uri: nil) + end.not_to change { Doorkeeper::Application.count } + + expect(response).to render_template :new + expect(assigns[:scopes]).to be_kind_of(Doorkeeper::OAuth::Scopes) + end + end + + describe 'PATCH #update' do + it 'updates the application' do + patch :update, id: application.id, doorkeeper_application: { redirect_uri: 'http://example.com/' } + + expect(response).to redirect_to(admin_application_path(application)) + expect(application.reload.redirect_uri).to eq 'http://example.com/' + end + + it 'renders the application form on errors' do + patch :update, id: application.id, doorkeeper_application: { redirect_uri: nil } + + expect(response).to render_template :edit + expect(assigns[:scopes]).to be_kind_of(Doorkeeper::OAuth::Scopes) + end + end +end diff --git a/spec/controllers/blob_controller_spec.rb b/spec/controllers/blob_controller_spec.rb index 2fcb4a6a528..44e011fd3a8 100644 --- a/spec/controllers/blob_controller_spec.rb +++ b/spec/controllers/blob_controller_spec.rb @@ -19,8 +19,8 @@ describe Projects::BlobController do before do get(:show, - namespace_id: project.namespace.to_param, - project_id: project.to_param, + namespace_id: project.namespace, + project_id: project, id: id) end @@ -50,8 +50,8 @@ describe Projects::BlobController do before do get(:show, - namespace_id: project.namespace.to_param, - project_id: project.to_param, + namespace_id: project.namespace, + project_id: project, id: id) controller.instance_variable_set(:@blob, nil) end diff --git a/spec/controllers/ci/projects_controller_spec.rb b/spec/controllers/ci/projects_controller_spec.rb deleted file mode 100644 index 86f01f437a2..00000000000 --- a/spec/controllers/ci/projects_controller_spec.rb +++ /dev/null @@ -1,74 +0,0 @@ -require 'spec_helper' - -describe Ci::ProjectsController do - let(:visibility) { :public } - let!(:project) { create(:empty_project, visibility, ci_id: 1) } - let(:ci_id) { project.ci_id } - - describe '#index' do - context 'user signed in' do - before do - sign_in(create(:user)) - get(:index) - end - - it 'redirects to /' do - expect(response).to redirect_to(root_path) - end - end - - context 'user not signed in' do - before { get(:index) } - - it 'redirects to sign in page' do - expect(response).to redirect_to(new_user_session_path) - end - end - end - - ## - # Specs for *deprecated* CI badge - # - describe '#badge' do - shared_examples 'badge provider' do - it 'shows badge' do - expect(response.status).to eq 200 - expect(response.headers) - .to include('Content-Type' => 'image/svg+xml') - end - end - - context 'user not signed in' do - before { get(:badge, id: ci_id) } - - context 'project has no ci_id reference' do - let(:ci_id) { 123 } - - it 'returns 404' do - expect(response.status).to eq 404 - end - end - - context 'project is public' do - let(:visibility) { :public } - it_behaves_like 'badge provider' - end - - context 'project is private' do - let(:visibility) { :private } - it_behaves_like 'badge provider' - end - end - - context 'user signed in' do - let(:user) { create(:user) } - before { sign_in(user) } - before { get(:badge, id: ci_id) } - - context 'private is internal' do - let(:visibility) { :internal } - it_behaves_like 'badge provider' - end - end - end -end diff --git a/spec/controllers/dashboard/todos_controller_spec.rb b/spec/controllers/dashboard/todos_controller_spec.rb index 7072bd5e87c..71a4a2c43c7 100644 --- a/spec/controllers/dashboard/todos_controller_spec.rb +++ b/spec/controllers/dashboard/todos_controller_spec.rb @@ -49,4 +49,18 @@ describe Dashboard::TodosController do expect(json_response).to eq({ "count" => "1", "done_count" => "0" }) end end + + describe 'PATCH #bulk_restore' do + let(:todos) { create_list(:todo, 2, :done, user: user, project: project, author: author) } + + it 'restores the todos to pending state' do + patch :bulk_restore, ids: todos.map(&:id) + + todos.each do |todo| + expect(todo.reload).to be_pending + end + expect(response).to have_http_status(200) + expect(json_response).to eq({ 'count' => '2', 'done_count' => '0' }) + end + end end diff --git a/spec/controllers/health_check_controller_spec.rb b/spec/controllers/health_check_controller_spec.rb index cfe18dd4b6c..58c16cc57e6 100644 --- a/spec/controllers/health_check_controller_spec.rb +++ b/spec/controllers/health_check_controller_spec.rb @@ -64,8 +64,8 @@ describe HealthCheckController do context 'when a service is down and an access token is provided' do before do - allow(HealthCheck::Utils).to receive(:process_checks).with('standard').and_return('The server is on fire') - allow(HealthCheck::Utils).to receive(:process_checks).with('email').and_return('Email is on fire') + allow(HealthCheck::Utils).to receive(:process_checks).with(['standard']).and_return('The server is on fire') + allow(HealthCheck::Utils).to receive(:process_checks).with(['email']).and_return('Email is on fire') end it 'supports passing the token in the header' do diff --git a/spec/controllers/profiles/keys_controller_spec.rb b/spec/controllers/profiles/keys_controller_spec.rb index f7219690722..61e4fae46fb 100644 --- a/spec/controllers/profiles/keys_controller_spec.rb +++ b/spec/controllers/profiles/keys_controller_spec.rb @@ -3,16 +3,6 @@ require 'spec_helper' describe Profiles::KeysController do let(:user) { create(:user) } - describe '#new' do - before { sign_in(user) } - - it 'redirects to #index' do - get :new - - expect(response).to redirect_to(profile_keys_path) - end - end - describe "#get_keys" do describe "non existant user" do it "does not generally work" do diff --git a/spec/controllers/profiles/notifications_controller_spec.rb b/spec/controllers/profiles/notifications_controller_spec.rb deleted file mode 100644 index 58caf7999cf..00000000000 --- a/spec/controllers/profiles/notifications_controller_spec.rb +++ /dev/null @@ -1,45 +0,0 @@ -require 'spec_helper' - -describe Profiles::NotificationsController do - let(:user) do - create(:user) do |user| - user.emails.create(email: 'original@example.com') - user.emails.create(email: 'new@example.com') - user.update(notification_email: 'original@example.com') - user.save! - end - end - - describe 'GET show' do - it 'renders' do - sign_in(user) - - get :show - - expect(response).to render_template :show - end - end - - describe 'POST update' do - it 'updates only permitted attributes' do - sign_in(user) - - put :update, user: { notification_email: 'new@example.com', notified_of_own_activity: true, admin: true } - - user.reload - expect(user.notification_email).to eq('new@example.com') - expect(user.notified_of_own_activity).to eq(true) - expect(user.admin).to eq(false) - expect(controller).to set_flash[:notice].to('Notification settings saved') - end - - it 'shows an error message if the params are invalid' do - sign_in(user) - - put :update, user: { notification_email: '' } - - expect(user.reload.notification_email).to eq('original@example.com') - expect(controller).to set_flash[:alert].to('Failed to save new settings') - end - end -end diff --git a/spec/controllers/profiles/personal_access_tokens_spec.rb b/spec/controllers/profiles/personal_access_tokens_spec.rb index 45534a3a587..dfed1de2046 100644 --- a/spec/controllers/profiles/personal_access_tokens_spec.rb +++ b/spec/controllers/profiles/personal_access_tokens_spec.rb @@ -2,48 +2,55 @@ require 'spec_helper' describe Profiles::PersonalAccessTokensController do let(:user) { create(:user) } + let(:token_attributes) { attributes_for(:personal_access_token) } + + before { sign_in(user) } describe '#create' do def created_token PersonalAccessToken.order(:created_at).last end - before { sign_in(user) } - - it "allows creation of a token" do + it "allows creation of a token with scopes" do name = FFaker::Product.brand + scopes = %w[api read_user] - post :create, personal_access_token: { name: name } + post :create, personal_access_token: token_attributes.merge(scopes: scopes, name: name) expect(created_token).not_to be_nil expect(created_token.name).to eq(name) - expect(created_token.expires_at).to be_nil + expect(created_token.scopes).to eq(scopes) expect(PersonalAccessToken.active).to include(created_token) end it "allows creation of a token with an expiry date" do - expires_at = 5.days.from_now + expires_at = 5.days.from_now.to_date - post :create, personal_access_token: { name: FFaker::Product.brand, expires_at: expires_at } + post :create, personal_access_token: token_attributes.merge(expires_at: expires_at) expect(created_token).not_to be_nil - expect(created_token.expires_at.to_i).to eq(expires_at.to_i) + expect(created_token.expires_at).to eq(expires_at) end + end - context "scopes" do - it "allows creation of a token with scopes" do - post :create, personal_access_token: { name: FFaker::Product.brand, scopes: ['api', 'read_user'] } + describe '#index' do + let!(:active_personal_access_token) { create(:personal_access_token, user: user) } + let!(:inactive_personal_access_token) { create(:personal_access_token, :revoked, user: user) } + let!(:impersonation_personal_access_token) { create(:personal_access_token, :impersonation, user: user) } - expect(created_token).not_to be_nil - expect(created_token.scopes).to eq(['api', 'read_user']) - end + before { get :index } - it "allows creation of a token with no scopes" do - post :create, personal_access_token: { name: FFaker::Product.brand, scopes: [] } + it "retrieves active personal access tokens" do + expect(assigns(:active_personal_access_tokens)).to include(active_personal_access_token) + end + + it "retrieves inactive personal access tokens" do + expect(assigns(:inactive_personal_access_tokens)).to include(inactive_personal_access_token) + end - expect(created_token).not_to be_nil - expect(created_token.scopes).to eq([]) - end + it "does not retrieve impersonation personal access tokens" do + expect(assigns(:active_personal_access_tokens)).not_to include(impersonation_personal_access_token) + expect(assigns(:inactive_personal_access_tokens)).not_to include(impersonation_personal_access_token) end end end diff --git a/spec/controllers/projects/blame_controller_spec.rb b/spec/controllers/projects/blame_controller_spec.rb index addc5e7ec33..c086b386381 100644 --- a/spec/controllers/projects/blame_controller_spec.rb +++ b/spec/controllers/projects/blame_controller_spec.rb @@ -16,8 +16,8 @@ describe Projects::BlameController do before do get(:show, - namespace_id: project.namespace.to_param, - project_id: project.to_param, + namespace_id: project.namespace, + project_id: project, id: id) end diff --git a/spec/controllers/projects/blob_controller_spec.rb b/spec/controllers/projects/blob_controller_spec.rb index 7d4636e98d1..ec36a64b415 100644 --- a/spec/controllers/projects/blob_controller_spec.rb +++ b/spec/controllers/projects/blob_controller_spec.rb @@ -14,8 +14,8 @@ describe Projects::BlobController do render_views def do_get(opts = {}) - params = { namespace_id: project.namespace.to_param, - project_id: project.to_param, + params = { namespace_id: project.namespace, + project_id: project, id: 'master/CHANGELOG' } get :diff, params.merge(opts) end @@ -40,8 +40,8 @@ describe Projects::BlobController do describe 'PUT update' do let(:default_params) do { - namespace_id: project.namespace.to_param, - project_id: project.to_param, + namespace_id: project.namespace, + project_id: project, id: 'master/CHANGELOG', target_branch: 'master', content: 'Added changes', @@ -96,8 +96,8 @@ describe Projects::BlobController do context 'when editing on the fork' do before do - default_params[:namespace_id] = forked_project.namespace.to_param - default_params[:project_id] = forked_project.to_param + default_params[:namespace_id] = forked_project.namespace + default_params[:project_id] = forked_project end it 'redirects to blob' do diff --git a/spec/controllers/projects/boards/issues_controller_spec.rb b/spec/controllers/projects/boards/issues_controller_spec.rb index ad15e3942a5..15667e8d4b1 100644 --- a/spec/controllers/projects/boards/issues_controller_spec.rb +++ b/spec/controllers/projects/boards/issues_controller_spec.rb @@ -43,6 +43,7 @@ describe Projects::Boards::IssuesController do expect(response).to match_response_schema('issues') expect(parsed_response.length).to eq 2 + expect(development.issues.map(&:relative_position)).not_to include(nil) end end @@ -90,7 +91,7 @@ describe Projects::Boards::IssuesController do params = { namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, board_id: board.to_param, list_id: list.try(:to_param) } @@ -146,7 +147,7 @@ describe Projects::Boards::IssuesController do sign_in(user) post :create, namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, board_id: board.to_param, list_id: list.to_param, issue: { title: title }, @@ -209,7 +210,7 @@ describe Projects::Boards::IssuesController do sign_in(user) patch :update, namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, board_id: board.to_param, id: issue.to_param, from_list_id: from_list_id, diff --git a/spec/controllers/projects/boards/lists_controller_spec.rb b/spec/controllers/projects/boards/lists_controller_spec.rb index b3f9f76a50c..432f3c53c90 100644 --- a/spec/controllers/projects/boards/lists_controller_spec.rb +++ b/spec/controllers/projects/boards/lists_controller_spec.rb @@ -47,7 +47,7 @@ describe Projects::Boards::ListsController do sign_in(user) get :index, namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, board_id: board.to_param, format: :json end @@ -104,7 +104,7 @@ describe Projects::Boards::ListsController do sign_in(user) post :create, namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, board_id: board.to_param, list: { label_id: label_id }, format: :json @@ -157,7 +157,7 @@ describe Projects::Boards::ListsController do sign_in(user) patch :update, namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, board_id: board.to_param, id: list.to_param, list: { position: position }, @@ -200,7 +200,7 @@ describe Projects::Boards::ListsController do sign_in(user) delete :destroy, namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, board_id: board.to_param, id: list.to_param, format: :json @@ -244,7 +244,7 @@ describe Projects::Boards::ListsController do sign_in(user) post :generate, namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, board_id: board.to_param, format: :json end diff --git a/spec/controllers/projects/boards_controller_spec.rb b/spec/controllers/projects/boards_controller_spec.rb index cc19035740e..aed3a45c413 100644 --- a/spec/controllers/projects/boards_controller_spec.rb +++ b/spec/controllers/projects/boards_controller_spec.rb @@ -50,8 +50,8 @@ describe Projects::BoardsController do end def list_boards(format: :html) - get :index, namespace_id: project.namespace.to_param, - project_id: project.to_param, + get :index, namespace_id: project.namespace, + project_id: project, format: format end end @@ -100,8 +100,8 @@ describe Projects::BoardsController do end def read_board(board:, format: :html) - get :show, namespace_id: project.namespace.to_param, - project_id: project.to_param, + get :show, namespace_id: project.namespace, + project_id: project, id: board.to_param, format: format end diff --git a/spec/controllers/projects/branches_controller_spec.rb b/spec/controllers/projects/branches_controller_spec.rb index 9de03876755..d20e7368086 100644 --- a/spec/controllers/projects/branches_controller_spec.rb +++ b/spec/controllers/projects/branches_controller_spec.rb @@ -22,8 +22,8 @@ describe Projects::BranchesController do sign_in(user) post :create, - namespace_id: project.namespace.to_param, - project_id: project.to_param, + namespace_id: project.namespace, + project_id: project, branch_name: branch, ref: ref end @@ -68,7 +68,7 @@ describe Projects::BranchesController do describe "created from the new branch button on issues" do let(:branch) { "1-feature-branch" } - let!(:issue) { create(:issue, project: project) } + let(:issue) { create(:issue, project: project) } before do sign_in(user) @@ -76,8 +76,8 @@ describe Projects::BranchesController do it 'redirects' do post :create, - namespace_id: project.namespace.to_param, - project_id: project.to_param, + namespace_id: project.namespace, + project_id: project, branch_name: branch, issue_iid: issue.iid @@ -89,12 +89,49 @@ describe Projects::BranchesController do expect(SystemNoteService).to receive(:new_issue_branch).with(issue, project, user, "1-feature-branch") post :create, - namespace_id: project.namespace.to_param, - project_id: project.to_param, + namespace_id: project.namespace, + project_id: project, branch_name: branch, issue_iid: issue.iid end + context 'repository-less project' do + let(:project) { create :empty_project } + + it 'redirects to newly created branch' do + result = { status: :success, branch: double(name: branch) } + + expect_any_instance_of(CreateBranchService).to receive(:execute).and_return(result) + expect(SystemNoteService).to receive(:new_issue_branch).and_return(true) + + post :create, + namespace_id: project.namespace.to_param, + project_id: project.to_param, + branch_name: branch, + issue_iid: issue.iid + + expect(response).to redirect_to namespace_project_tree_path(project.namespace, project, branch) + end + + it 'redirects to autodeploy setup page' do + result = { status: :success, branch: double(name: branch) } + + project.services << build(:kubernetes_service) + + expect_any_instance_of(CreateBranchService).to receive(:execute).and_return(result) + expect(SystemNoteService).to receive(:new_issue_branch).and_return(true) + + post :create, + namespace_id: project.namespace.to_param, + project_id: project.to_param, + branch_name: branch, + issue_iid: issue.iid + + expect(response.location).to include(namespace_project_new_blob_path(project.namespace, project, branch)) + expect(response).to have_http_status(302) + end + end + context 'without issue feature access' do before do project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC) @@ -106,8 +143,8 @@ describe Projects::BranchesController do expect(SystemNoteService).not_to receive(:new_issue_branch) post :create, - namespace_id: project.namespace.to_param, - project_id: project.to_param, + namespace_id: project.namespace, + project_id: project, branch_name: branch, issue_iid: issue.iid end @@ -126,8 +163,8 @@ describe Projects::BranchesController do post :destroy, format: :html, id: 'foo/bar/baz', - namespace_id: project.namespace.to_param, - project_id: project.to_param + namespace_id: project.namespace, + project_id: project expect(response).to have_http_status(303) end @@ -142,8 +179,8 @@ describe Projects::BranchesController do post :destroy, format: :js, id: branch, - namespace_id: project.namespace.to_param, - project_id: project.to_param + namespace_id: project.namespace, + project_id: project end context "valid branch name, valid source" do @@ -173,8 +210,8 @@ describe Projects::BranchesController do describe "DELETE destroy_all_merged" do def destroy_all_merged delete :destroy_all_merged, - namespace_id: project.namespace.to_param, - project_id: project.to_param + namespace_id: project.namespace, + project_id: project end context 'when user is allowed to push' do @@ -207,4 +244,41 @@ describe Projects::BranchesController do end end end + + describe "GET index" do + render_views + + before do + sign_in(user) + end + + context 'when rendering a JSON format' do + it 'filters branches by name' do + get :index, + namespace_id: project.namespace, + project_id: project, + format: :json, + search: 'master' + + parsed_response = JSON.parse(response.body) + + expect(parsed_response.length).to eq 1 + expect(parsed_response.first).to eq 'master' + end + end + + context 'show_all = true' do + it 'returns all the branches name' do + get :index, + namespace_id: project.namespace, + project_id: project, + format: :json, + show_all: true + + parsed_response = JSON.parse(response.body) + + expect(parsed_response.length).to eq(project.repository.branches.count) + end + end + end end diff --git a/spec/controllers/projects/commit_controller_spec.rb b/spec/controllers/projects/commit_controller_spec.rb index ebd2d0e092b..b223a22ae60 100644 --- a/spec/controllers/projects/commit_controller_spec.rb +++ b/spec/controllers/projects/commit_controller_spec.rb @@ -17,8 +17,8 @@ describe Projects::CommitController do def go(extra_params = {}) params = { - namespace_id: project.namespace.to_param, - project_id: project.to_param + namespace_id: project.namespace, + project_id: project } get :show, params.merge(extra_params) @@ -125,8 +125,8 @@ describe Projects::CommitController do it 'renders it' do get(:show, - namespace_id: fork_project.namespace.to_param, - project_id: fork_project.to_param, + namespace_id: fork_project.namespace, + project_id: fork_project, id: commit.id) expect(response).to be_success @@ -139,8 +139,8 @@ describe Projects::CommitController do commit = project.commit('5937ac0a7beb003549fc5fd26fc247adbce4a52e') get(:branches, - namespace_id: project.namespace.to_param, - project_id: project.to_param, + namespace_id: project.namespace, + project_id: project, id: commit.id) expect(assigns(:branches)).to include("master", "feature_conflict") @@ -152,8 +152,8 @@ describe Projects::CommitController do context 'when target branch is not provided' do it 'renders the 404 page' do post(:revert, - namespace_id: project.namespace.to_param, - project_id: project.to_param, + namespace_id: project.namespace, + project_id: project, id: commit.id) expect(response).not_to be_success @@ -164,9 +164,9 @@ describe Projects::CommitController do context 'when the revert was successful' do it 'redirects to the commits page' do post(:revert, - namespace_id: project.namespace.to_param, - project_id: project.to_param, - target_branch: 'master', + namespace_id: project.namespace, + project_id: project, + start_branch: 'master', id: commit.id) expect(response).to redirect_to namespace_project_commits_path(project.namespace, project, 'master') @@ -177,18 +177,18 @@ describe Projects::CommitController do context 'when the revert failed' do before do post(:revert, - namespace_id: project.namespace.to_param, - project_id: project.to_param, - target_branch: 'master', + namespace_id: project.namespace, + project_id: project, + start_branch: 'master', id: commit.id) end it 'redirects to the commit page' do # Reverting a commit that has been already reverted. post(:revert, - namespace_id: project.namespace.to_param, - project_id: project.to_param, - target_branch: 'master', + namespace_id: project.namespace, + project_id: project, + start_branch: 'master', id: commit.id) expect(response).to redirect_to namespace_project_commit_path(project.namespace, project, commit.id) @@ -201,8 +201,8 @@ describe Projects::CommitController do context 'when target branch is not provided' do it 'renders the 404 page' do post(:cherry_pick, - namespace_id: project.namespace.to_param, - project_id: project.to_param, + namespace_id: project.namespace, + project_id: project, id: master_pickable_commit.id) expect(response).not_to be_success @@ -213,9 +213,9 @@ describe Projects::CommitController do context 'when the cherry-pick was successful' do it 'redirects to the commits page' do post(:cherry_pick, - namespace_id: project.namespace.to_param, - project_id: project.to_param, - target_branch: 'master', + namespace_id: project.namespace, + project_id: project, + start_branch: 'master', id: master_pickable_commit.id) expect(response).to redirect_to namespace_project_commits_path(project.namespace, project, 'master') @@ -226,18 +226,18 @@ describe Projects::CommitController do context 'when the cherry_pick failed' do before do post(:cherry_pick, - namespace_id: project.namespace.to_param, - project_id: project.to_param, - target_branch: 'master', + namespace_id: project.namespace, + project_id: project, + start_branch: 'master', id: master_pickable_commit.id) end it 'redirects to the commit page' do # Cherry-picking a commit that has been already cherry-picked. post(:cherry_pick, - namespace_id: project.namespace.to_param, - project_id: project.to_param, - target_branch: 'master', + namespace_id: project.namespace, + project_id: project, + start_branch: 'master', id: master_pickable_commit.id) expect(response).to redirect_to namespace_project_commit_path(project.namespace, project, master_pickable_commit.id) @@ -249,8 +249,8 @@ describe Projects::CommitController do describe 'GET diff_for_path' do def diff_for_path(extra_params = {}) params = { - namespace_id: project.namespace.to_param, - project_id: project.to_param + namespace_id: project.namespace, + project_id: project } get :diff_for_path, params.merge(extra_params) @@ -313,8 +313,8 @@ describe Projects::CommitController do describe 'GET pipelines' do def get_pipelines(extra_params = {}) params = { - namespace_id: project.namespace.to_param, - project_id: project.to_param + namespace_id: project.namespace, + project_id: project } get :pipelines, params.merge(extra_params) diff --git a/spec/controllers/projects/commits_controller_spec.rb b/spec/controllers/projects/commits_controller_spec.rb index 54b8d1108a5..e26731fb691 100644 --- a/spec/controllers/projects/commits_controller_spec.rb +++ b/spec/controllers/projects/commits_controller_spec.rb @@ -16,8 +16,8 @@ describe Projects::CommitsController do context "when the ref does not exist with the suffix" do it "renders as atom" do get(:show, - namespace_id: project.namespace.to_param, - project_id: project.to_param, + namespace_id: project.namespace, + project_id: project, id: "master.atom") expect(response).to be_success @@ -33,8 +33,8 @@ describe Projects::CommitsController do allow_any_instance_of(Repository).to receive(:commit).with('master.atom').and_return(commit) get(:show, - namespace_id: project.namespace.to_param, - project_id: project.to_param, + namespace_id: project.namespace, + project_id: project, id: "master.atom") end diff --git a/spec/controllers/projects/compare_controller_spec.rb b/spec/controllers/projects/compare_controller_spec.rb index e811c76fb31..15ac4e0925a 100644 --- a/spec/controllers/projects/compare_controller_spec.rb +++ b/spec/controllers/projects/compare_controller_spec.rb @@ -13,8 +13,8 @@ describe Projects::CompareController do it 'compare shows some diffs' do get(:show, - namespace_id: project.namespace.to_param, - project_id: project.to_param, + namespace_id: project.namespace, + project_id: project, from: ref_from, to: ref_to) @@ -25,8 +25,8 @@ describe Projects::CompareController do it 'compare shows some diffs with ignore whitespace change option' do get(:show, - namespace_id: project.namespace.to_param, - project_id: project.to_param, + namespace_id: project.namespace, + project_id: project, from: '08f22f25', to: '66eceea0', w: 1) @@ -43,8 +43,8 @@ describe Projects::CompareController do describe 'non-existent refs' do it 'uses invalid source ref' do get(:show, - namespace_id: project.namespace.to_param, - project_id: project.to_param, + namespace_id: project.namespace, + project_id: project, from: 'non-existent', to: ref_to) @@ -55,8 +55,8 @@ describe Projects::CompareController do it 'uses invalid target ref' do get(:show, - namespace_id: project.namespace.to_param, - project_id: project.to_param, + namespace_id: project.namespace, + project_id: project, from: ref_from, to: 'non-existent') @@ -67,8 +67,8 @@ describe Projects::CompareController do it 'redirects back to index when params[:from] is empty and preserves params[:to]' do post(:create, - namespace_id: project.namespace.to_param, - project_id: project.to_param, + namespace_id: project.namespace, + project_id: project, from: '', to: 'master') @@ -77,8 +77,8 @@ describe Projects::CompareController do it 'redirects back to index when params[:to] is empty and preserves params[:from]' do post(:create, - namespace_id: project.namespace.to_param, - project_id: project.to_param, + namespace_id: project.namespace, + project_id: project, from: 'master', to: '') @@ -87,8 +87,8 @@ describe Projects::CompareController do it 'redirects back to index when params[:from] and params[:to] are empty' do post(:create, - namespace_id: project.namespace.to_param, - project_id: project.to_param, + namespace_id: project.namespace, + project_id: project, from: '', to: '') @@ -99,8 +99,8 @@ describe Projects::CompareController do describe 'GET diff_for_path' do def diff_for_path(extra_params = {}) params = { - namespace_id: project.namespace.to_param, - project_id: project.to_param + namespace_id: project.namespace, + project_id: project } get :diff_for_path, params.merge(extra_params) diff --git a/spec/controllers/projects/cycle_analytics_controller_spec.rb b/spec/controllers/projects/cycle_analytics_controller_spec.rb index 6a6d71a16ee..6fae52edbad 100644 --- a/spec/controllers/projects/cycle_analytics_controller_spec.rb +++ b/spec/controllers/projects/cycle_analytics_controller_spec.rb @@ -13,8 +13,8 @@ describe Projects::CycleAnalyticsController do context 'with no data' do it 'is true' do get(:show, - namespace_id: project.namespace.to_param, - project_id: project.to_param) + namespace_id: project.namespace, + project_id: project) expect(response).to be_success expect(assigns(:cycle_analytics_no_data)).to eq(true) @@ -32,8 +32,8 @@ describe Projects::CycleAnalyticsController do it 'is false' do get(:show, - namespace_id: project.namespace.to_param, - project_id: project.to_param) + namespace_id: project.namespace, + project_id: project) expect(response).to be_success expect(assigns(:cycle_analytics_no_data)).to eq(false) diff --git a/spec/controllers/projects/environments_controller_spec.rb b/spec/controllers/projects/environments_controller_spec.rb index 84d119f1867..83d80b376fb 100644 --- a/spec/controllers/projects/environments_controller_spec.rb +++ b/spec/controllers/projects/environments_controller_spec.rb @@ -187,6 +187,52 @@ describe Projects::EnvironmentsController do end end + describe 'GET #metrics' do + before do + allow(controller).to receive(:environment).and_return(environment) + end + + context 'when environment has no metrics' do + before do + expect(environment).to receive(:metrics).and_return(nil) + end + + it 'returns a metrics page' do + get :metrics, environment_params + + expect(response).to be_ok + end + + context 'when requesting metrics as JSON' do + it 'returns a metrics JSON document' do + get :metrics, environment_params(format: :json) + + expect(response).to have_http_status(204) + expect(json_response).to eq({}) + end + end + end + + context 'when environment has some metrics' do + before do + expect(environment).to receive(:metrics).and_return({ + success: true, + metrics: {}, + last_update: 42 + }) + end + + it 'returns a metrics JSON document' do + get :metrics, environment_params(format: :json) + + expect(response).to be_ok + expect(json_response['success']).to be(true) + expect(json_response['metrics']).to eq({}) + expect(json_response['last_update']).to eq(42) + end + end + end + def environment_params(opts = {}) opts.reverse_merge(namespace_id: project.namespace, project_id: project, diff --git a/spec/controllers/projects/find_file_controller_spec.rb b/spec/controllers/projects/find_file_controller_spec.rb index a4884256c92..6a5433bcc9c 100644 --- a/spec/controllers/projects/find_file_controller_spec.rb +++ b/spec/controllers/projects/find_file_controller_spec.rb @@ -17,8 +17,8 @@ describe Projects::FindFileController do before do get(:show, - namespace_id: project.namespace.to_param, - project_id: project.to_param, + namespace_id: project.namespace, + project_id: project, id: id) end @@ -36,8 +36,8 @@ describe Projects::FindFileController do describe "GET #list" do def go(format: 'json') get :list, - namespace_id: project.namespace.to_param, - project_id: project.to_param, + namespace_id: project.namespace, + project_id: project, id: id, format: format end diff --git a/spec/controllers/projects/forks_controller_spec.rb b/spec/controllers/projects/forks_controller_spec.rb index a867668d97b..8282d79298f 100644 --- a/spec/controllers/projects/forks_controller_spec.rb +++ b/spec/controllers/projects/forks_controller_spec.rb @@ -9,8 +9,8 @@ describe Projects::ForksController do describe 'GET index' do def get_forks get :index, - namespace_id: project.namespace.to_param, - project_id: project.to_param + namespace_id: project.namespace, + project_id: project end context 'when fork is public' do @@ -71,8 +71,8 @@ describe Projects::ForksController do describe 'GET new' do def get_new get :new, - namespace_id: project.namespace.to_param, - project_id: project.to_param + namespace_id: project.namespace, + project_id: project end context 'when user is signed in' do @@ -99,8 +99,8 @@ describe Projects::ForksController do describe 'POST create' do def post_create post :create, - namespace_id: project.namespace.to_param, - project_id: project.to_param, + namespace_id: project.namespace, + project_id: project, namespace_key: user.namespace.id end diff --git a/spec/controllers/projects/graphs_controller_spec.rb b/spec/controllers/projects/graphs_controller_spec.rb index bbe8e4bf6b2..e0de62e4454 100644 --- a/spec/controllers/projects/graphs_controller_spec.rb +++ b/spec/controllers/projects/graphs_controller_spec.rb @@ -9,23 +9,39 @@ describe Projects::GraphsController do project.team << [user, :master] end - describe 'GET #languages' do + describe 'GET languages' do + it "redirects_to action charts" do + get(:commits, namespace_id: project.namespace.path, project_id: project.path, id: 'master') + + expect(response).to redirect_to action: :charts + end + end + + describe 'GET commits' do + it "redirects_to action charts" do + get(:commits, namespace_id: project.namespace.path, project_id: project.path, id: 'master') + + expect(response).to redirect_to action: :charts + end + end + + describe 'GET charts' do let(:linguist_repository) do double(languages: { 'Ruby' => 1000, 'CoffeeScript' => 350, - 'PowerShell' => 15 + 'NSIS' => 15 }) end let(:expected_values) do - ps_color = "##{Digest::SHA256.hexdigest('PowerShell')[0...6]}" + nsis_color = "##{Digest::SHA256.hexdigest('NSIS')[0...6]}" [ # colors from Linguist: - { label: "Ruby", color: "#701516", highlight: "#701516" }, - { label: "CoffeeScript", color: "#244776", highlight: "#244776" }, + { label: "Ruby", color: "#701516", highlight: "#701516" }, + { label: "CoffeeScript", color: "#244776", highlight: "#244776" }, # colors from SHA256 fallback: - { label: "PowerShell", color: ps_color, highlight: ps_color } + { label: "NSIS", color: nsis_color, highlight: nsis_color } ] end @@ -34,7 +50,7 @@ describe Projects::GraphsController do end it 'sets the correct colour according to language' do - get(:languages, namespace_id: project.namespace.path, project_id: project.path, id: 'master') + get(:charts, namespace_id: project.namespace, project_id: project, id: 'master') expected_values.each do |val| expect(assigns(:languages)).to include(a_hash_including(val)) diff --git a/spec/controllers/projects/group_links_controller_spec.rb b/spec/controllers/projects/group_links_controller_spec.rb index a976a9c27ab..ca4a8e871c0 100644 --- a/spec/controllers/projects/group_links_controller_spec.rb +++ b/spec/controllers/projects/group_links_controller_spec.rb @@ -14,8 +14,8 @@ describe Projects::GroupLinksController do describe '#create' do shared_context 'link project to group' do before do - post(:create, namespace_id: project.namespace.to_param, - project_id: project.to_param, + post(:create, namespace_id: project.namespace, + project_id: project, link_group_id: group.id, link_group_access: ProjectGroupLink.default_access) end @@ -50,8 +50,8 @@ describe Projects::GroupLinksController do context 'when project group id equal link group id' do before do - post(:create, namespace_id: project.namespace.to_param, - project_id: project.to_param, + post(:create, namespace_id: project.namespace, + project_id: project, link_group_id: group2.id, link_group_access: ProjectGroupLink.default_access) end @@ -69,8 +69,8 @@ describe Projects::GroupLinksController do context 'when link group id is not present' do before do - post(:create, namespace_id: project.namespace.to_param, - project_id: project.to_param, + post(:create, namespace_id: project.namespace, + project_id: project, link_group_access: ProjectGroupLink.default_access) end diff --git a/spec/controllers/projects/imports_controller_spec.rb b/spec/controllers/projects/imports_controller_spec.rb index 2acbba469e3..7c75815f3c4 100644 --- a/spec/controllers/projects/imports_controller_spec.rb +++ b/spec/controllers/projects/imports_controller_spec.rb @@ -13,13 +13,13 @@ describe Projects::ImportsController do end it 'renders template' do - get :show, namespace_id: project.namespace.to_param, project_id: project.to_param + get :show, namespace_id: project.namespace.to_param, project_id: project expect(response).to render_template :show end it 'sets flash.now if params is present' do - get :show, namespace_id: project.namespace.to_param, project_id: project.to_param, continue: { to: '/', notice_now: 'Started' } + get :show, namespace_id: project.namespace.to_param, project_id: project, continue: { to: '/', notice_now: 'Started' } expect(flash.now[:notice]).to eq 'Started' end @@ -39,13 +39,13 @@ describe Projects::ImportsController do end it 'renders template' do - get :show, namespace_id: project.namespace.to_param, project_id: project.to_param + get :show, namespace_id: project.namespace.to_param, project_id: project expect(response).to render_template :show end it 'sets flash.now if params is present' do - get :show, namespace_id: project.namespace.to_param, project_id: project.to_param, continue: { to: '/', notice_now: 'In progress' } + get :show, namespace_id: project.namespace.to_param, project_id: project, continue: { to: '/', notice_now: 'In progress' } expect(flash.now[:notice]).to eq 'In progress' end @@ -57,7 +57,7 @@ describe Projects::ImportsController do end it 'redirects to new_namespace_project_import_path' do - get :show, namespace_id: project.namespace.to_param, project_id: project.to_param + get :show, namespace_id: project.namespace.to_param, project_id: project expect(response).to redirect_to new_namespace_project_import_path(project.namespace, project) end @@ -72,7 +72,7 @@ describe Projects::ImportsController do it 'redirects to namespace_project_path' do allow_any_instance_of(Project).to receive(:forked?).and_return(true) - get :show, namespace_id: project.namespace.to_param, project_id: project.to_param + get :show, namespace_id: project.namespace.to_param, project_id: project expect(flash[:notice]).to eq 'The project was successfully forked.' expect(response).to redirect_to namespace_project_path(project.namespace, project) @@ -81,7 +81,7 @@ describe Projects::ImportsController do context 'when project is external' do it 'redirects to namespace_project_path' do - get :show, namespace_id: project.namespace.to_param, project_id: project.to_param + get :show, namespace_id: project.namespace.to_param, project_id: project expect(flash[:notice]).to eq 'The project was successfully imported.' expect(response).to redirect_to namespace_project_path(project.namespace, project) @@ -97,7 +97,7 @@ describe Projects::ImportsController do end it 'redirects to params[:to]' do - get :show, namespace_id: project.namespace.to_param, project_id: project.to_param, continue: params + get :show, namespace_id: project.namespace.to_param, project_id: project, continue: params expect(flash[:notice]).to eq params[:notice] expect(response).to redirect_to params[:to] @@ -111,7 +111,7 @@ describe Projects::ImportsController do end it 'redirects to namespace_project_path' do - get :show, namespace_id: project.namespace.to_param, project_id: project.to_param + get :show, namespace_id: project.namespace.to_param, project_id: project expect(response).to redirect_to namespace_project_path(project.namespace, project) end diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 7871b6a9e10..57a921e3676 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -12,7 +12,7 @@ describe Projects::IssuesController do allow(project).to receive(:external_issue_tracker).and_return(external) controller.instance_variable_set(:@project, project) - get :index, namespace_id: project.namespace.path, project_id: project + get :index, namespace_id: project.namespace, project_id: project expect(response).to redirect_to('https://example.com/project') end @@ -27,13 +27,13 @@ describe Projects::IssuesController do it_behaves_like "issuables list meta-data", :issue it "returns index" do - get :index, namespace_id: project.namespace.path, project_id: project.path + get :index, namespace_id: project.namespace, project_id: project expect(response).to have_http_status(200) end it "returns 301 if request path doesn't match project path" do - get :index, namespace_id: project.namespace.path, project_id: project.path.upcase + get :index, namespace_id: project.namespace, project_id: project.path.upcase expect(response).to redirect_to(namespace_project_issues_path(project.namespace, project)) end @@ -42,7 +42,7 @@ describe Projects::IssuesController do project.issues_enabled = false project.save - get :index, namespace_id: project.namespace.path, project_id: project.path + get :index, namespace_id: project.namespace, project_id: project expect(response).to have_http_status(404) end @@ -50,7 +50,7 @@ describe Projects::IssuesController do controller.instance_variable_set(:@project, project) allow(project).to receive(:default_issues_tracker?).and_return(false) - get :index, namespace_id: project.namespace.path, project_id: project.path + get :index, namespace_id: project.namespace, project_id: project expect(response).to have_http_status(404) end end @@ -67,8 +67,8 @@ describe Projects::IssuesController do it 'redirects to last_page if page number is larger than number of pages' do get :index, - namespace_id: project.namespace.path.to_param, - project_id: project.path.to_param, + namespace_id: project.namespace.to_param, + project_id: project, page: (last_page + 1).to_param expect(response).to redirect_to(namespace_project_issues_path(page: last_page, state: controller.params[:state], scope: controller.params[:scope])) @@ -76,8 +76,8 @@ describe Projects::IssuesController do it 'redirects to specified page' do get :index, - namespace_id: project.namespace.path.to_param, - project_id: project.path.to_param, + namespace_id: project.namespace.to_param, + project_id: project, page: last_page.to_param expect(assigns(:issues).current_page).to eq(last_page) @@ -87,6 +87,12 @@ describe Projects::IssuesController do end describe 'GET #new' do + it 'redirects to signin if not logged in' do + get :new, namespace_id: project.namespace, project_id: project + + expect(response).to redirect_to(new_user_session_path) + end + context 'internal issue tracker' do before do sign_in(user) @@ -94,7 +100,7 @@ describe Projects::IssuesController do end it 'builds a new issue' do - get :new, namespace_id: project.namespace.path, project_id: project + get :new, namespace_id: project.namespace, project_id: project expect(assigns(:issue)).to be_a_new(Issue) end @@ -104,7 +110,16 @@ describe Projects::IssuesController do project_with_repository.team << [user, :developer] mr = create(:merge_request_with_diff_notes, source_project: project_with_repository) - get :new, namespace_id: project_with_repository.namespace.path, project_id: project_with_repository, merge_request_for_resolving_discussions: mr.iid + get :new, namespace_id: project_with_repository.namespace, project_id: project_with_repository, merge_request_to_resolve_discussions_of: mr.iid + + expect(assigns(:issue).title).not_to be_empty + expect(assigns(:issue).description).not_to be_empty + end + + it 'fills in an issue for a discussion' do + note = create(:note_on_merge_request, project: project) + + get :new, namespace_id: project.namespace.path, project_id: project, merge_request_to_resolve_discussions_of: note.noteable.iid, discussion_to_resolve: note.discussion_id expect(assigns(:issue).title).not_to be_empty expect(assigns(:issue).description).not_to be_empty @@ -112,12 +127,17 @@ describe Projects::IssuesController do end context 'external issue tracker' do + before do + sign_in(user) + project.team << [user, :developer] + end + it 'redirects to the external issue tracker' do external = double(new_issue_path: 'https://example.com/issues/new') allow(project).to receive(:external_issue_tracker).and_return(external) controller.instance_variable_set(:@project, project) - get :new, namespace_id: project.namespace.path, project_id: project + get :new, namespace_id: project.namespace, project_id: project expect(response).to redirect_to('https://example.com/issues/new') end @@ -125,13 +145,33 @@ describe Projects::IssuesController do end describe 'PUT #update' do - context 'when moving issue to another private project' do - let(:another_project) { create(:empty_project, :private) } + before do + sign_in(user) + project.team << [user, :developer] + end - before do - sign_in(user) - project.team << [user, :developer] + it_behaves_like 'update invalid issuable', Issue + + context 'changing the assignee' do + it 'limits the attributes exposed on the assignee' do + assignee = create(:user) + project.add_developer(assignee) + + put :update, + namespace_id: project.namespace.to_param, + project_id: project, + id: issue.iid, + issue: { assignee_id: assignee.id }, + format: :json + body = JSON.parse(response.body) + + expect(body['assignee'].keys) + .to match_array(%w(name username avatar_url)) end + end + + context 'when moving issue to another private project' do + let(:another_project) { create(:empty_project, :private) } context 'when user has access to move issue' do before { another_project.team << [user, :reporter] } @@ -251,7 +291,7 @@ describe Projects::IssuesController do def update_issue(issue_params = {}, additional_params = {}) params = { namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, id: issue.iid, issue: issue_params }.merge(additional_params) @@ -262,7 +302,7 @@ describe Projects::IssuesController do def move_issue put :update, namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, id: issue.iid, issue: { title: 'New title' }, move_to_project_id: another_project.id @@ -342,7 +382,7 @@ describe Projects::IssuesController do def get_issues get :index, namespace_id: project.namespace.to_param, - project_id: project.to_param + project_id: project end end @@ -405,7 +445,7 @@ describe Projects::IssuesController do def go(id:) get :show, namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, id: id end end @@ -416,7 +456,7 @@ describe Projects::IssuesController do def go(id:) get :edit, namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, id: id end end @@ -427,7 +467,7 @@ describe Projects::IssuesController do def go(id:) put :update, namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, id: id, issue: { title: 'New title' } end @@ -442,7 +482,7 @@ describe Projects::IssuesController do post :create, { namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, issue: { title: 'Title', description: 'Description' }.merge(issue_attrs) }.merge(additional_params) @@ -460,11 +500,11 @@ describe Projects::IssuesController do end let(:merge_request_params) do - { merge_request_for_resolving_discussions: merge_request.iid } + { merge_request_to_resolve_discussions_of: merge_request.iid } end - def post_issue(issue_params) - post :create, namespace_id: project.namespace.to_param, project_id: project.to_param, issue: issue_params, merge_request_for_resolving_discussions: merge_request.iid + def post_issue(issue_params, other_params: {}) + post :create, { namespace_id: project.namespace.to_param, project_id: project, issue: issue_params, merge_request_to_resolve_discussions_of: merge_request.iid }.merge(other_params) end it 'creates an issue for the project' do @@ -483,6 +523,27 @@ describe Projects::IssuesController do expect(discussion.resolved?).to eq(true) end + + it 'sets a flash message' do + post_issue(title: 'Hello') + + expect(flash[:notice]).to eq('Resolved all discussions.') + end + + describe "resolving a single discussion" do + before do + post_issue({ title: 'Hello' }, other_params: { discussion_to_resolve: discussion.id }) + end + it 'resolves a single discussion' do + discussion.first_note.reload + + expect(discussion.resolved?).to eq(true) + end + + it 'sets a flash message that one discussion was resolved' do + expect(flash[:notice]).to eq('Resolved 1 discussion.') + end + end end context 'Akismet is enabled' do @@ -607,8 +668,8 @@ describe Projects::IssuesController do project.team << [admin, :master] sign_in(admin) post :mark_as_spam, { - namespace_id: project.namespace.path, - project_id: project.path, + namespace_id: project.namespace, + project_id: project, id: issue.iid } end @@ -624,7 +685,7 @@ describe Projects::IssuesController do context "when the user is a developer" do before { sign_in(user) } it "rejects a developer to destroy an issue" do - delete :destroy, namespace_id: project.namespace.path, project_id: project.path, id: issue.iid + delete :destroy, namespace_id: project.namespace, project_id: project, id: issue.iid expect(response).to have_http_status(404) end end @@ -637,7 +698,7 @@ describe Projects::IssuesController do before { sign_in(owner) } it "deletes the issue" do - delete :destroy, namespace_id: project.namespace.path, project_id: project.path, id: issue.iid + delete :destroy, namespace_id: project.namespace, project_id: project, id: issue.iid expect(response).to have_http_status(302) expect(controller).to set_flash[:notice].to(/The issue was successfully deleted\./).now @@ -646,7 +707,7 @@ describe Projects::IssuesController do it 'delegates the update of the todos count cache to TodoService' do expect_any_instance_of(TodoService).to receive(:destroy_issue).with(issue, owner).once - delete :destroy, namespace_id: project.namespace.path, project_id: project.path, id: issue.iid + delete :destroy, namespace_id: project.namespace, project_id: project, id: issue.iid end end end @@ -659,8 +720,8 @@ describe Projects::IssuesController do it "toggles the award emoji" do expect do - post(:toggle_award_emoji, namespace_id: project.namespace.path, - project_id: project.path, id: issue.iid, name: "thumbsup") + post(:toggle_award_emoji, namespace_id: project.namespace, + project_id: project, id: issue.iid, name: "thumbsup") end.to change { issue.award_emoji.count }.by(1) expect(response).to have_http_status(200) diff --git a/spec/controllers/projects/labels_controller_spec.rb b/spec/controllers/projects/labels_controller_spec.rb index 3e0326dd47d..6a6e9bf378a 100644 --- a/spec/controllers/projects/labels_controller_spec.rb +++ b/spec/controllers/projects/labels_controller_spec.rb @@ -67,7 +67,7 @@ describe Projects::LabelsController do end def list_labels - get :index, namespace_id: project.namespace.to_param, project_id: project.to_param + get :index, namespace_id: project.namespace.to_param, project_id: project end end @@ -76,7 +76,7 @@ describe Projects::LabelsController do let(:personal_project) { create(:empty_project, namespace: user.namespace) } it 'creates labels' do - post :generate, namespace_id: personal_project.namespace.to_param, project_id: personal_project.to_param + post :generate, namespace_id: personal_project.namespace.to_param, project_id: personal_project expect(response).to have_http_status(302) end @@ -84,7 +84,7 @@ describe Projects::LabelsController do context 'project belonging to a group' do it 'creates labels' do - post :generate, namespace_id: project.namespace.to_param, project_id: project.to_param + post :generate, namespace_id: project.namespace.to_param, project_id: project expect(response).to have_http_status(302) end @@ -109,7 +109,7 @@ describe Projects::LabelsController do end def toggle_subscription(label) - post :toggle_subscription, namespace_id: project.namespace.to_param, project_id: project.to_param, id: label.to_param + post :toggle_subscription, namespace_id: project.namespace.to_param, project_id: project, id: label.to_param end end @@ -119,7 +119,7 @@ describe Projects::LabelsController do context 'not group owner' do it 'denies access' do - post :promote, namespace_id: project.namespace.to_param, project_id: project.to_param, id: label_1.to_param + post :promote, namespace_id: project.namespace.to_param, project_id: project, id: label_1.to_param expect(response).to have_http_status(404) end @@ -131,13 +131,13 @@ describe Projects::LabelsController do end it 'gives access' do - post :promote, namespace_id: project.namespace.to_param, project_id: project.to_param, id: label_1.to_param + post :promote, namespace_id: project.namespace.to_param, project_id: project, id: label_1.to_param expect(response).to redirect_to(namespace_project_labels_path) end it 'promotes the label' do - post :promote, namespace_id: project.namespace.to_param, project_id: project.to_param, id: label_1.to_param + post :promote, namespace_id: project.namespace.to_param, project_id: project, id: label_1.to_param expect(Label.where(id: label_1.id)).to be_empty expect(GroupLabel.find_by(title: promoted_label_name)).not_to be_nil @@ -151,7 +151,7 @@ describe Projects::LabelsController do end it 'returns to label list' do - post :promote, namespace_id: project.namespace.to_param, project_id: project.to_param, id: label_1.to_param + post :promote, namespace_id: project.namespace.to_param, project_id: project, id: label_1.to_param expect(response).to redirect_to(namespace_project_labels_path) end end diff --git a/spec/controllers/projects/mattermosts_controller_spec.rb b/spec/controllers/projects/mattermosts_controller_spec.rb index cae733f0cfb..c5abf11cfa5 100644 --- a/spec/controllers/projects/mattermosts_controller_spec.rb +++ b/spec/controllers/projects/mattermosts_controller_spec.rb @@ -18,7 +18,7 @@ describe Projects::MattermostsController do it 'accepts the request' do get(:new, namespace_id: project.namespace.to_param, - project_id: project.to_param) + project_id: project) expect(response).to have_http_status(200) end @@ -30,7 +30,7 @@ describe Projects::MattermostsController do subject do post(:create, namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, mattermost: mattermost_params) end diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index f84f922ba5e..c310d830e81 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -43,7 +43,8 @@ describe Projects::MergeRequestsController do submit_new_merge_request(format: :json) expect(response).to be_ok - expect(json_response).not_to be_empty + expect(json_response).to have_key 'pipelines' + expect(json_response['pipelines']).not_to be_empty end end end @@ -51,10 +52,11 @@ describe Projects::MergeRequestsController do def submit_new_merge_request(format: :html) get :new, namespace_id: fork_project.namespace.to_param, - project_id: fork_project.to_param, + project_id: fork_project, merge_request: { source_branch: 'remove-submodule', - target_branch: 'master' }, + target_branch: 'master' + }, format: format end end @@ -63,7 +65,7 @@ describe Projects::MergeRequestsController do it "loads labels into the @labels variable" do get action, namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, id: merge_request.iid, format: 'html' expect(assigns(:labels)).not_to be_nil @@ -75,7 +77,7 @@ describe Projects::MergeRequestsController do it "does generally work" do get(:show, namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, id: merge_request.iid, format: format) @@ -89,7 +91,7 @@ describe Projects::MergeRequestsController do get(:show, namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, id: merge_request.iid, format: format) end @@ -97,7 +99,7 @@ describe Projects::MergeRequestsController do it "renders it" do get(:show, namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, id: merge_request.iid, format: format) @@ -110,7 +112,7 @@ describe Projects::MergeRequestsController do get(:show, namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, id: merge_request.iid, format: format) @@ -125,7 +127,7 @@ describe Projects::MergeRequestsController do it "triggers workhorse to serve the request" do get(:show, namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, id: merge_request.iid, format: :diff) @@ -137,7 +139,7 @@ describe Projects::MergeRequestsController do it 'triggers workhorse to serve the request' do get(:show, namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, id: merge_request.iid, format: :patch) @@ -152,7 +154,7 @@ describe Projects::MergeRequestsController do def get_merge_requests(page = nil) get :index, namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, state: 'opened', page: page.to_param end @@ -201,6 +203,24 @@ describe Projects::MergeRequestsController do end describe 'PUT update' do + context 'changing the assignee' do + it 'limits the attributes exposed on the assignee' do + assignee = create(:user) + project.add_developer(assignee) + + put :update, + namespace_id: project.namespace.to_param, + project_id: project, + id: merge_request.iid, + merge_request: { assignee_id: assignee.id }, + format: :json + body = JSON.parse(response.body) + + expect(body['assignee'].keys) + .to match_array(%w(name username avatar_url)) + end + end + context 'there is no source project' do let(:project) { create(:project) } let(:fork_project) { create(:forked_project_with_submodules) } @@ -215,8 +235,8 @@ describe Projects::MergeRequestsController do it 'closes MR without errors' do post :update, - namespace_id: project.namespace.path, - project_id: project.path, + namespace_id: project.namespace, + project_id: project, id: merge_request.iid, merge_request: { state_event: 'close' @@ -230,8 +250,8 @@ describe Projects::MergeRequestsController do merge_request.close! put :update, - namespace_id: project.namespace.path, - project_id: project.path, + namespace_id: project.namespace, + project_id: project, id: merge_request.iid, merge_request: { title: 'New title' @@ -245,8 +265,8 @@ describe Projects::MergeRequestsController do merge_request.close! put :update, - namespace_id: project.namespace.path, - project_id: project.path, + namespace_id: project.namespace, + project_id: project, id: merge_request.iid, merge_request: { target_branch: 'new_branch' @@ -254,14 +274,16 @@ describe Projects::MergeRequestsController do expect { merge_request.reload.target_branch }.not_to change { merge_request.target_branch } end + + it_behaves_like 'update invalid issuable', MergeRequest end end describe 'POST merge' do let(:base_params) do { - namespace_id: project.namespace.path, - project_id: project.path, + namespace_id: project.namespace, + project_id: project, id: merge_request.iid, format: 'raw' } @@ -316,41 +338,41 @@ describe Projects::MergeRequestsController do merge_with_sha end - context 'when merge_when_build_succeeds is passed' do - def merge_when_build_succeeds - post :merge, base_params.merge(sha: merge_request.diff_head_sha, merge_when_build_succeeds: '1') + context 'when the pipeline succeeds is passed' do + def merge_when_pipeline_succeeds + post :merge, base_params.merge(sha: merge_request.diff_head_sha, merge_when_pipeline_succeeds: '1') end before do create(:ci_empty_pipeline, project: project, sha: merge_request.diff_head_sha, ref: merge_request.source_branch) end - it 'returns :merge_when_build_succeeds' do - merge_when_build_succeeds + it 'returns :merge_when_pipeline_succeeds' do + merge_when_pipeline_succeeds - expect(assigns(:status)).to eq(:merge_when_build_succeeds) + expect(assigns(:status)).to eq(:merge_when_pipeline_succeeds) end - it 'sets the MR to merge when the build succeeds' do - service = double(:merge_when_build_succeeds_service) + it 'sets the MR to merge when the pipeline succeeds' do + service = double(:merge_when_pipeline_succeeds_service) expect(MergeRequests::MergeWhenPipelineSucceedsService) .to receive(:new).with(project, anything, anything) .and_return(service) expect(service).to receive(:execute).with(merge_request) - merge_when_build_succeeds + merge_when_pipeline_succeeds end - context 'when project.only_allow_merge_if_build_succeeds? is true' do + context 'when project.only_allow_merge_if_pipeline_succeeds? is true' do before do - project.update_column(:only_allow_merge_if_build_succeeds, true) + project.update_column(:only_allow_merge_if_pipeline_succeeds, true) end - it 'returns :merge_when_build_succeeds' do - merge_when_build_succeeds + it 'returns :merge_when_pipeline_succeeds' do + merge_when_pipeline_succeeds - expect(assigns(:status)).to eq(:merge_when_build_succeeds) + expect(assigns(:status)).to eq(:merge_when_pipeline_succeeds) end end end @@ -425,7 +447,7 @@ describe Projects::MergeRequestsController do describe "DELETE destroy" do it "denies access to users unless they're admin or project owner" do - delete :destroy, namespace_id: project.namespace.path, project_id: project.path, id: merge_request.iid + delete :destroy, namespace_id: project.namespace, project_id: project, id: merge_request.iid expect(response).to have_http_status(404) end @@ -438,7 +460,7 @@ describe Projects::MergeRequestsController do before { sign_in owner } it "deletes the merge request" do - delete :destroy, namespace_id: project.namespace.path, project_id: project.path, id: merge_request.iid + delete :destroy, namespace_id: project.namespace, project_id: project, id: merge_request.iid expect(response).to have_http_status(302) expect(controller).to set_flash[:notice].to(/The merge request was successfully deleted\./).now @@ -447,7 +469,7 @@ describe Projects::MergeRequestsController do it 'delegates the update of the todos count cache to TodoService' do expect_any_instance_of(TodoService).to receive(:destroy_merge_request).with(merge_request, owner).once - delete :destroy, namespace_id: project.namespace.path, project_id: project.path, id: merge_request.iid + delete :destroy, namespace_id: project.namespace, project_id: project, id: merge_request.iid end end end @@ -456,7 +478,7 @@ describe Projects::MergeRequestsController do def go(extra_params = {}) params = { namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, id: merge_request.iid } @@ -536,7 +558,7 @@ describe Projects::MergeRequestsController do def diff_for_path(extra_params = {}) params = { namespace_id: project.namespace.to_param, - project_id: project.to_param + project_id: project } get :diff_for_path, params.merge(extra_params) @@ -600,7 +622,7 @@ describe Projects::MergeRequestsController do before do other_project.team << [user, :master] - diff_for_path(id: merge_request.iid, old_path: existing_path, new_path: existing_path, project_id: other_project.to_param) + diff_for_path(id: merge_request.iid, old_path: existing_path, new_path: existing_path, project_id: other_project) end it 'returns a 404' do @@ -666,7 +688,7 @@ describe Projects::MergeRequestsController do def go(format: 'html') get :commits, namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, id: merge_request.iid, format: format end @@ -706,7 +728,7 @@ describe Projects::MergeRequestsController do before do get :pipelines, namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, id: merge_request.iid, format: :json end @@ -725,7 +747,7 @@ describe Projects::MergeRequestsController do get :conflicts, namespace_id: merge_request_with_conflicts.project.namespace.to_param, - project_id: merge_request_with_conflicts.project.to_param, + project_id: merge_request_with_conflicts.project, id: merge_request_with_conflicts.iid, format: 'json' end @@ -743,7 +765,7 @@ describe Projects::MergeRequestsController do before do get :conflicts, namespace_id: merge_request_with_conflicts.project.namespace.to_param, - project_id: merge_request_with_conflicts.project.to_param, + project_id: merge_request_with_conflicts.project, id: merge_request_with_conflicts.iid, format: 'json' end @@ -772,7 +794,7 @@ describe Projects::MergeRequestsController do section['lines'].each do |line| if section['conflict'] - expect(line['type']).to be_in(['old', 'new']) + expect(line['type']).to be_in(%w(old new)) expect(line.values_at('old_line', 'new_line')).to contain_exactly(nil, a_kind_of(Integer)) else if line['type'].nil? @@ -806,7 +828,7 @@ describe Projects::MergeRequestsController do post :remove_wip, namespace_id: merge_request.project.namespace.to_param, - project_id: merge_request.project.to_param, + project_id: merge_request.project, id: merge_request.iid expect(merge_request.reload.title).to eq(merge_request.wipless_title) @@ -817,7 +839,7 @@ describe Projects::MergeRequestsController do def conflict_for_path(path) get :conflict_for_path, namespace_id: merge_request_with_conflicts.project.namespace.to_param, - project_id: merge_request_with_conflicts.project.to_param, + project_id: merge_request_with_conflicts.project, id: merge_request_with_conflicts.iid, old_path: path, new_path: path, @@ -873,7 +895,7 @@ describe Projects::MergeRequestsController do def resolve_conflicts(files) post :resolve_conflicts, namespace_id: merge_request_with_conflicts.project.namespace.to_param, - project_id: merge_request_with_conflicts.project.to_param, + project_id: merge_request_with_conflicts.project, id: merge_request_with_conflicts.iid, format: 'json', files: files, @@ -1024,7 +1046,7 @@ describe Projects::MergeRequestsController do post :assign_related_issues, namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, id: merge_request.iid end @@ -1079,7 +1101,7 @@ describe Projects::MergeRequestsController do get :ci_environments_status, namespace_id: merge_request.project.namespace.to_param, - project_id: merge_request.project.to_param, + project_id: merge_request.project, id: merge_request.iid, format: 'json' end @@ -1092,8 +1114,8 @@ describe Projects::MergeRequestsController do describe 'GET merge_widget_refresh' do let(:params) do { - namespace_id: project.namespace.path, - project_id: project.path, + namespace_id: project.namespace, + project_id: project, id: merge_request.iid, format: :raw } @@ -1131,14 +1153,14 @@ describe Projects::MergeRequestsController do end context 'when waiting for build' do - let(:merge_request) { create(:merge_request, source_project: project, merge_when_build_succeeds: true, merge_user: user) } + let(:merge_request) { create(:merge_request, source_project: project, merge_when_pipeline_succeeds: true, merge_user: user) } it 'returns an OK response' do expect(response).to have_http_status(:ok) end - it 'sets status to :merge_when_build_succeeds' do - expect(assigns(:status)).to eq(:merge_when_build_succeeds) + it 'sets status to :merge_when_pipeline_succeeds' do + expect(assigns(:status)).to eq(:merge_when_pipeline_succeeds) expect(response).to render_template('merge') end end diff --git a/spec/controllers/projects/notes_controller_spec.rb b/spec/controllers/projects/notes_controller_spec.rb index dc597202050..d80780b1d90 100644 --- a/spec/controllers/projects/notes_controller_spec.rb +++ b/spec/controllers/projects/notes_controller_spec.rb @@ -200,4 +200,31 @@ describe Projects::NotesController do end end end + + describe 'GET index' do + let(:last_fetched_at) { '1487756246' } + let(:request_params) do + { + namespace_id: project.namespace, + project_id: project, + target_type: 'issue', + target_id: issue.id + } + end + + before do + sign_in(user) + project.team << [user, :developer] + end + + it 'passes last_fetched_at from headers to NotesFinder' do + request.headers['X-Last-Fetched-At'] = last_fetched_at + + expect(NotesFinder).to receive(:new) + .with(anything, anything, hash_including(last_fetched_at: last_fetched_at)) + .and_call_original + + get :index, request_params + end + end end diff --git a/spec/controllers/projects/pipelines_controller_spec.rb b/spec/controllers/projects/pipelines_controller_spec.rb index 1ed2ee3ab4a..04bb5cbbd59 100644 --- a/spec/controllers/projects/pipelines_controller_spec.rb +++ b/spec/controllers/projects/pipelines_controller_spec.rb @@ -12,10 +12,13 @@ describe Projects::PipelinesController do describe 'GET index.json' do before do - create_list(:ci_empty_pipeline, 2, project: project) + create(:ci_empty_pipeline, status: 'pending', project: project) + create(:ci_empty_pipeline, status: 'running', project: project) + create(:ci_empty_pipeline, status: 'created', project: project) + create(:ci_empty_pipeline, status: 'success', project: project) - get :index, namespace_id: project.namespace.path, - project_id: project.path, + get :index, namespace_id: project.namespace, + project_id: project, format: :json end @@ -23,9 +26,11 @@ describe Projects::PipelinesController do expect(response).to have_http_status(:ok) expect(json_response).to include('pipelines') - expect(json_response['pipelines'].count).to eq 2 - expect(json_response['count']['all']).to eq 2 - expect(json_response['count']['running_or_pending']).to eq 2 + expect(json_response['pipelines'].count).to eq 4 + expect(json_response['count']['all']).to eq 4 + expect(json_response['count']['running']).to eq 1 + expect(json_response['count']['pending']).to eq 1 + expect(json_response['count']['finished']).to eq 1 end end @@ -57,8 +62,8 @@ describe Projects::PipelinesController do end def get_stage(name) - get :stage, namespace_id: project.namespace.path, - project_id: project.path, + get :stage, namespace_id: project.namespace, + project_id: project, id: pipeline.id, stage: name, format: :json diff --git a/spec/controllers/projects/protected_branches_controller_spec.rb b/spec/controllers/projects/protected_branches_controller_spec.rb index da6112a13f7..e378b5714fe 100644 --- a/spec/controllers/projects/protected_branches_controller_spec.rb +++ b/spec/controllers/projects/protected_branches_controller_spec.rb @@ -4,7 +4,7 @@ describe Projects::ProtectedBranchesController do describe "GET #index" do let(:project) { create(:project_empty_repo, :public) } it "redirects empty repo to projects page" do - get(:index, namespace_id: project.namespace.to_param, project_id: project.to_param) + get(:index, namespace_id: project.namespace.to_param, project_id: project) end end end diff --git a/spec/controllers/projects/raw_controller_spec.rb b/spec/controllers/projects/raw_controller_spec.rb index b23d6e257ba..952071af57f 100644 --- a/spec/controllers/projects/raw_controller_spec.rb +++ b/spec/controllers/projects/raw_controller_spec.rb @@ -3,21 +3,21 @@ require 'spec_helper' describe Projects::RawController do let(:public_project) { create(:project, :public, :repository) } - describe "#show" do + describe '#show' do context 'regular filename' do let(:id) { 'master/README.md' } it 'delivers ASCII file' do get(:show, namespace_id: public_project.namespace.to_param, - project_id: public_project.to_param, + project_id: public_project, id: id) expect(response).to have_http_status(200) expect(response.header['Content-Type']).to eq('text/plain; charset=utf-8') expect(response.header['Content-Disposition']). - to eq("inline") - expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with("git-blob:") + to eq('inline') + expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with('git-blob:') end end @@ -27,12 +27,12 @@ describe Projects::RawController do it 'sets image content type header' do get(:show, namespace_id: public_project.namespace.to_param, - project_id: public_project.to_param, + project_id: public_project, id: id) expect(response).to have_http_status(200) expect(response.header['Content-Type']).to eq('image/jpeg') - expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with("git-blob:") + expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with('git-blob:') end end @@ -40,32 +40,57 @@ describe Projects::RawController do let(:id) { 'be93687/files/lfs/lfs_object.iso' } let!(:lfs_object) { create(:lfs_object, oid: '91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897', size: '1575078') } - context 'when project has access' do + context 'when lfs is enabled' do before do - public_project.lfs_objects << lfs_object - allow_any_instance_of(LfsObjectUploader).to receive(:exists?).and_return(true) - allow(controller).to receive(:send_file) { controller.head :ok } + allow_any_instance_of(Project).to receive(:lfs_enabled?).and_return(true) end - it 'serves the file' do - expect(controller).to receive(:send_file).with("#{Gitlab.config.shared.path}/lfs-objects/91/ef/f75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897", filename: "lfs_object.iso", disposition: 'attachment') - get(:show, - namespace_id: public_project.namespace.to_param, - project_id: public_project.to_param, - id: id) + context 'when project has access' do + before do + public_project.lfs_objects << lfs_object + allow_any_instance_of(LfsObjectUploader).to receive(:exists?).and_return(true) + allow(controller).to receive(:send_file) { controller.head :ok } + end - expect(response).to have_http_status(200) + it 'serves the file' do + expect(controller).to receive(:send_file).with("#{Gitlab.config.shared.path}/lfs-objects/91/ef/f75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897", filename: 'lfs_object.iso', disposition: 'attachment') + get(:show, + namespace_id: public_project.namespace.to_param, + project_id: public_project, + id: id) + + expect(response).to have_http_status(200) + end + end + + context 'when project does not have access' do + it 'does not serve the file' do + get(:show, + namespace_id: public_project.namespace.to_param, + project_id: public_project, + id: id) + + expect(response).to have_http_status(404) + end end end - context 'when project does not have access' do - it 'does not serve the file' do + context 'when lfs is not enabled' do + before do + allow_any_instance_of(Project).to receive(:lfs_enabled?).and_return(false) + end + + it 'delivers ASCII file' do get(:show, namespace_id: public_project.namespace.to_param, - project_id: public_project.to_param, + project_id: public_project, id: id) - expect(response).to have_http_status(404) + expect(response).to have_http_status(200) + expect(response.header['Content-Type']).to eq('text/plain; charset=utf-8') + expect(response.header['Content-Disposition']). + to eq('inline') + expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with('git-blob:') end end end diff --git a/spec/controllers/projects/refs_controller_spec.rb b/spec/controllers/projects/refs_controller_spec.rb index d8fb4667c67..3a3e7467ef2 100644 --- a/spec/controllers/projects/refs_controller_spec.rb +++ b/spec/controllers/projects/refs_controller_spec.rb @@ -13,7 +13,7 @@ describe Projects::RefsController do def default_get(format = :html) get :logs_tree, namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, id: 'master', path: 'foo/bar/baz.html', format: format @@ -23,7 +23,7 @@ describe Projects::RefsController do xhr :get, :logs_tree, namespace_id: project.namespace.to_param, - project_id: project.to_param, id: 'master', + project_id: project, id: 'master', path: 'foo/bar/baz.html', format: format end diff --git a/spec/controllers/projects/releases_controller_spec.rb b/spec/controllers/projects/releases_controller_spec.rb index 69fcc26c77e..358f26dfb02 100644 --- a/spec/controllers/projects/releases_controller_spec.rb +++ b/spec/controllers/projects/releases_controller_spec.rb @@ -16,7 +16,7 @@ describe Projects::ReleasesController do tag_id = release.tag project.releases.destroy_all - get :edit, namespace_id: project.namespace.path, project_id: project.path, tag_id: tag_id + get :edit, namespace_id: project.namespace, project_id: project, tag_id: tag_id release = assigns(:release) expect(release).not_to be_nil @@ -24,7 +24,7 @@ describe Projects::ReleasesController do end it 'retrieves an existing release' do - get :edit, namespace_id: project.namespace.path, project_id: project.path, tag_id: release.tag + get :edit, namespace_id: project.namespace, project_id: project, tag_id: release.tag release = assigns(:release) expect(release).not_to be_nil @@ -48,7 +48,7 @@ describe Projects::ReleasesController do def update_release(description) put :update, namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, tag_id: release.tag, release: { description: description } end diff --git a/spec/controllers/projects/repositories_controller_spec.rb b/spec/controllers/projects/repositories_controller_spec.rb index 04e88879fb8..9c55d159fa0 100644 --- a/spec/controllers/projects/repositories_controller_spec.rb +++ b/spec/controllers/projects/repositories_controller_spec.rb @@ -6,7 +6,7 @@ describe Projects::RepositoriesController do describe "GET archive" do context 'as a guest' do it 'responds with redirect in correct format' do - get :archive, namespace_id: project.namespace.path, project_id: project.path, format: "zip" + get :archive, namespace_id: project.namespace, project_id: project, format: "zip" expect(response.header["Content-Type"]).to start_with('text/html') expect(response).to be_redirect @@ -22,7 +22,7 @@ describe Projects::RepositoriesController do end it "uses Gitlab::Workhorse" do - get :archive, namespace_id: project.namespace.path, project_id: project.path, ref: "master", format: "zip" + get :archive, namespace_id: project.namespace, project_id: project, ref: "master", format: "zip" expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with("git-archive:") end @@ -33,7 +33,7 @@ describe Projects::RepositoriesController do end it "renders Not Found" do - get :archive, namespace_id: project.namespace.path, project_id: project.path, ref: "master", format: "zip" + get :archive, namespace_id: project.namespace, project_id: project, ref: "master", format: "zip" expect(response).to have_http_status(404) end diff --git a/spec/controllers/projects/settings/repository_controller_spec.rb b/spec/controllers/projects/settings/repository_controller_spec.rb new file mode 100644 index 00000000000..f73471f8ca8 --- /dev/null +++ b/spec/controllers/projects/settings/repository_controller_spec.rb @@ -0,0 +1,20 @@ +require 'spec_helper' + +describe Projects::Settings::RepositoryController do + let(:project) { create(:project_empty_repo, :public) } + let(:user) { create(:user) } + + before do + project.add_master(user) + sign_in(user) + end + + describe 'GET show' do + it 'renders show with 200 status code' do + get :show, namespace_id: project.namespace, project_id: project + + expect(response).to have_http_status(200) + expect(response).to render_template(:show) + end + end +end diff --git a/spec/controllers/projects/snippets_controller_spec.rb b/spec/controllers/projects/snippets_controller_spec.rb index 8bab094a79e..24a59caff4e 100644 --- a/spec/controllers/projects/snippets_controller_spec.rb +++ b/spec/controllers/projects/snippets_controller_spec.rb @@ -17,16 +17,16 @@ describe Projects::SnippetsController do it 'redirects to last_page if page number is larger than number of pages' do get :index, - namespace_id: project.namespace.path, - project_id: project.path, page: (last_page + 1).to_param + namespace_id: project.namespace, + project_id: project, page: (last_page + 1).to_param expect(response).to redirect_to(namespace_project_snippets_path(page: last_page)) end it 'redirects to specified page' do get :index, - namespace_id: project.namespace.path, - project_id: project.path, page: last_page.to_param + namespace_id: project.namespace, + project_id: project, page: last_page.to_param expect(assigns(:snippets).current_page).to eq(last_page) expect(response).to have_http_status(200) @@ -38,7 +38,7 @@ describe Projects::SnippetsController do context 'when anonymous' do it 'does not include the private snippet' do - get :index, namespace_id: project.namespace.path, project_id: project.path + get :index, namespace_id: project.namespace, project_id: project expect(assigns(:snippets)).not_to include(project_snippet) expect(response).to have_http_status(200) @@ -49,7 +49,7 @@ describe Projects::SnippetsController do before { sign_in(user) } it 'renders the snippet' do - get :index, namespace_id: project.namespace.path, project_id: project.path + get :index, namespace_id: project.namespace, project_id: project expect(assigns(:snippets)).to include(project_snippet) expect(response).to have_http_status(200) @@ -60,7 +60,7 @@ describe Projects::SnippetsController do before { sign_in(user2) } it 'renders the snippet' do - get :index, namespace_id: project.namespace.path, project_id: project.path + get :index, namespace_id: project.namespace, project_id: project expect(assigns(:snippets)).to include(project_snippet) expect(response).to have_http_status(200) @@ -77,7 +77,7 @@ describe Projects::SnippetsController do post :create, { namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, project_snippet: { title: 'Title', content: 'Content' }.merge(snippet_params) }.merge(additional_params) end @@ -152,7 +152,7 @@ describe Projects::SnippetsController do put :update, { namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, id: snippet.id, project_snippet: { title: 'Title', content: 'Content' }.merge(snippet_params) }.merge(additional_params) @@ -281,8 +281,8 @@ describe Projects::SnippetsController do sign_in(admin) post :mark_as_spam, - namespace_id: project.namespace.path, - project_id: project.path, + namespace_id: project.namespace, + project_id: project, id: snippet.id end @@ -300,7 +300,7 @@ describe Projects::SnippetsController do context 'when anonymous' do it 'responds with status 404' do - get action, namespace_id: project.namespace.path, project_id: project.path, id: project_snippet.to_param + get action, namespace_id: project.namespace, project_id: project, id: project_snippet.to_param expect(response).to have_http_status(404) end @@ -310,7 +310,7 @@ describe Projects::SnippetsController do before { sign_in(user) } it 'renders the snippet' do - get action, namespace_id: project.namespace.path, project_id: project.path, id: project_snippet.to_param + get action, namespace_id: project.namespace, project_id: project, id: project_snippet.to_param expect(assigns(:snippet)).to eq(project_snippet) expect(response).to have_http_status(200) @@ -321,7 +321,7 @@ describe Projects::SnippetsController do before { sign_in(user2) } it 'renders the snippet' do - get action, namespace_id: project.namespace.path, project_id: project.path, id: project_snippet.to_param + get action, namespace_id: project.namespace, project_id: project, id: project_snippet.to_param expect(assigns(:snippet)).to eq(project_snippet) expect(response).to have_http_status(200) @@ -332,7 +332,7 @@ describe Projects::SnippetsController do context 'when the project snippet does not exist' do context 'when anonymous' do it 'responds with status 404' do - get action, namespace_id: project.namespace.path, project_id: project.path, id: 42 + get action, namespace_id: project.namespace, project_id: project, id: 42 expect(response).to have_http_status(404) end @@ -342,7 +342,7 @@ describe Projects::SnippetsController do before { sign_in(user) } it 'responds with status 404' do - get action, namespace_id: project.namespace.path, project_id: project.path, id: 42 + get action, namespace_id: project.namespace, project_id: project, id: 42 expect(response).to have_http_status(404) end @@ -364,8 +364,8 @@ describe Projects::SnippetsController do context 'CRLF line ending' do let(:params) do { - namespace_id: project.namespace.path, - project_id: project.path, + namespace_id: project.namespace, + project_id: project, id: project_snippet.to_param } end diff --git a/spec/controllers/projects/tags_controller_spec.rb b/spec/controllers/projects/tags_controller_spec.rb index c36a5fdd66c..fc97bac64cd 100644 --- a/spec/controllers/projects/tags_controller_spec.rb +++ b/spec/controllers/projects/tags_controller_spec.rb @@ -6,7 +6,7 @@ describe Projects::TagsController do let!(:invalid_release) { create(:release, project: project, tag: 'does-not-exist') } describe 'GET index' do - before { get :index, namespace_id: project.namespace.to_param, project_id: project.to_param } + before { get :index, namespace_id: project.namespace.to_param, project_id: project } it 'returns the tags for the page' do expect(assigns(:tags).map(&:name)).to eq(['v1.1.0', 'v1.0.0']) @@ -19,7 +19,7 @@ describe Projects::TagsController do end describe 'GET show' do - before { get :show, namespace_id: project.namespace.to_param, project_id: project.to_param, id: id } + before { get :show, namespace_id: project.namespace.to_param, project_id: project, id: id } context "valid tag" do let(:id) { 'v1.0.0' } diff --git a/spec/controllers/projects/templates_controller_spec.rb b/spec/controllers/projects/templates_controller_spec.rb index 80f84a388ce..70e7f9ca96e 100644 --- a/spec/controllers/projects/templates_controller_spec.rb +++ b/spec/controllers/projects/templates_controller_spec.rb @@ -14,13 +14,13 @@ describe Projects::TemplatesController do before do project.add_user(user, Gitlab::Access::MASTER) - project.repository.commit_file(user, file_path_1, 'something valid', - message: 'test 3', branch_name: 'master', update: false) + project.repository.create_file(user, file_path_1, 'something valid', + message: 'test 3', branch_name: 'master') end describe '#show' do it 'renders template name and content as json' do - get(:show, namespace_id: project.namespace.to_param, template_type: "issue", key: "bug", project_id: project.path, format: :json) + get(:show, namespace_id: project.namespace.to_param, template_type: "issue", key: "bug", project_id: project, format: :json) expect(response.status).to eq(200) expect(body["name"]).to eq("bug") @@ -29,21 +29,21 @@ describe Projects::TemplatesController do it 'renders 404 when unauthorized' do sign_in(user2) - get(:show, namespace_id: project.namespace.to_param, template_type: "issue", key: "bug", project_id: project.path, format: :json) + get(:show, namespace_id: project.namespace.to_param, template_type: "issue", key: "bug", project_id: project, format: :json) expect(response.status).to eq(404) end it 'renders 404 when template type is not found' do sign_in(user) - get(:show, namespace_id: project.namespace.to_param, template_type: "dont_exist", key: "bug", project_id: project.path, format: :json) + get(:show, namespace_id: project.namespace.to_param, template_type: "dont_exist", key: "bug", project_id: project, format: :json) expect(response.status).to eq(404) end it 'renders 404 without errors' do sign_in(user) - expect { get(:show, namespace_id: project.namespace.to_param, template_type: "dont_exist", key: "bug", project_id: project.path, format: :json) }.not_to raise_error + expect { get(:show, namespace_id: project.namespace.to_param, template_type: "dont_exist", key: "bug", project_id: project, format: :json) }.not_to raise_error end end end diff --git a/spec/controllers/projects/todo_controller_spec.rb b/spec/controllers/projects/todo_controller_spec.rb index 415c264e0dd..9a7beeff6fe 100644 --- a/spec/controllers/projects/todo_controller_spec.rb +++ b/spec/controllers/projects/todo_controller_spec.rb @@ -12,8 +12,8 @@ describe Projects::TodosController do describe 'POST create' do def go post :create, - namespace_id: project.namespace.path, - project_id: project.path, + namespace_id: project.namespace, + project_id: project, issuable_id: issue.id, issuable_type: 'issue', format: 'html' @@ -80,8 +80,8 @@ describe Projects::TodosController do describe 'POST create' do def go post :create, - namespace_id: project.namespace.path, - project_id: project.path, + namespace_id: project.namespace, + project_id: project, issuable_id: merge_request.id, issuable_type: 'merge_request', format: 'html' diff --git a/spec/controllers/projects/tree_controller_spec.rb b/spec/controllers/projects/tree_controller_spec.rb index b81645a3d2d..ab94e292e48 100644 --- a/spec/controllers/projects/tree_controller_spec.rb +++ b/spec/controllers/projects/tree_controller_spec.rb @@ -18,7 +18,7 @@ describe Projects::TreeController do before do get(:show, namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, id: id) end @@ -74,7 +74,7 @@ describe Projects::TreeController do before do get(:show, namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, id: id) end @@ -94,7 +94,7 @@ describe Projects::TreeController do before do post(:create_dir, namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, id: 'master', dir_name: path, target_branch: target_branch, diff --git a/spec/controllers/projects/uploads_controller_spec.rb b/spec/controllers/projects/uploads_controller_spec.rb index 0347e789576..cd6961a7bd5 100644 --- a/spec/controllers/projects/uploads_controller_spec.rb +++ b/spec/controllers/projects/uploads_controller_spec.rb @@ -16,7 +16,7 @@ describe Projects::UploadsController do it "returns an error" do post :create, namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, format: :json expect(response).to have_http_status(422) end @@ -26,7 +26,7 @@ describe Projects::UploadsController do before do post :create, namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, file: jpg, format: :json end @@ -35,13 +35,26 @@ describe Projects::UploadsController do expect(response.body).to match '\"alt\":\"rails_sample\"' expect(response.body).to match "\"url\":\"/uploads" end + + # NOTE: This is as close as we're getting to an Integration test for this + # behavior. We're avoiding a proper Feature test because those should be + # testing things entirely user-facing, which the Upload model is very much + # not. + it 'creates a corresponding Upload record' do + upload = Upload.last + + aggregate_failures do + expect(upload).to exist + expect(upload.model).to eq project + end + end end context 'with valid non-image file' do before do post :create, namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, file: txt, format: :json end @@ -57,7 +70,7 @@ describe Projects::UploadsController do let(:go) do get :show, namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, secret: "123456", filename: "image.jpg" end diff --git a/spec/controllers/projects/variables_controller_spec.rb b/spec/controllers/projects/variables_controller_spec.rb index 9fa358f7d62..1ecfe48475c 100644 --- a/spec/controllers/projects/variables_controller_spec.rb +++ b/spec/controllers/projects/variables_controller_spec.rb @@ -12,7 +12,7 @@ describe Projects::VariablesController do describe 'POST #create' do context 'variable is valid' do it 'shows a success flash message' do - post :create, namespace_id: project.namespace.to_param, project_id: project.to_param, + post :create, namespace_id: project.namespace.to_param, project_id: project, variable: { key: "one", value: "two" } expect(flash[:notice]).to include 'Variables were successfully updated.' @@ -22,7 +22,7 @@ describe Projects::VariablesController do context 'variable is invalid' do it 'shows an alert flash message' do - post :create, namespace_id: project.namespace.to_param, project_id: project.to_param, + post :create, namespace_id: project.namespace.to_param, project_id: project, variable: { key: "..one", value: "two" } expect(response).to render_template("projects/variables/show") @@ -35,12 +35,12 @@ describe Projects::VariablesController do context 'updating a variable with valid characters' do before do - variable.gl_project_id = project.id + variable.project_id = project.id project.variables << variable end it 'shows a success flash message' do - post :update, namespace_id: project.namespace.to_param, project_id: project.to_param, + post :update, namespace_id: project.namespace.to_param, project_id: project, id: variable.id, variable: { key: variable.key, value: 'two' } expect(flash[:notice]).to include 'Variable was successfully updated.' @@ -48,7 +48,7 @@ describe Projects::VariablesController do end it 'renders the action #show if the variable key is invalid' do - post :update, namespace_id: project.namespace.to_param, project_id: project.to_param, + post :update, namespace_id: project.namespace.to_param, project_id: project, id: variable.id, variable: { key: '?', value: variable.value } expect(response).to have_http_status(200) diff --git a/spec/controllers/projects_controller_spec.rb b/spec/controllers/projects_controller_spec.rb index e7aa8745b99..a88ffc1ea6a 100644 --- a/spec/controllers/projects_controller_spec.rb +++ b/spec/controllers/projects_controller_spec.rb @@ -35,7 +35,7 @@ describe ProjectsController do let(:private_project) { create(:empty_project, :private) } it "does not initialize notification setting" do - get :show, namespace_id: private_project.namespace.path, id: private_project.path + get :show, namespace_id: private_project.namespace, id: private_project expect(assigns(:notification_setting)).to be_nil end end @@ -43,7 +43,7 @@ describe ProjectsController do context "user has access to project" do context "and does not have notification setting" do it "initializes notification as disabled" do - get :show, namespace_id: public_project.namespace.path, id: public_project.path + get :show, namespace_id: public_project.namespace, id: public_project expect(assigns(:notification_setting).level).to eq("global") end end @@ -56,7 +56,7 @@ describe ProjectsController do end it "shows current notification setting" do - get :show, namespace_id: public_project.namespace.path, id: public_project.path + get :show, namespace_id: public_project.namespace, id: public_project expect(assigns(:notification_setting).level).to eq("watch") end end @@ -71,24 +71,26 @@ describe ProjectsController do end it 'shows wiki homepage' do - get :show, namespace_id: project.namespace.path, id: project.path + get :show, namespace_id: project.namespace, id: project expect(response).to render_template('projects/_wiki') end it 'shows issues list page if wiki is disabled' do project.project_feature.update_attribute(:wiki_access_level, ProjectFeature::DISABLED) + create(:issue, project: project) - get :show, namespace_id: project.namespace.path, id: project.path + get :show, namespace_id: project.namespace, id: project expect(response).to render_template('projects/issues/_issues') + expect(assigns(:issuable_meta_data)).not_to be_nil end it 'shows customize workflow page if wiki and issues are disabled' do project.project_feature.update_attribute(:wiki_access_level, ProjectFeature::DISABLED) project.project_feature.update_attribute(:issues_access_level, ProjectFeature::DISABLED) - get :show, namespace_id: project.namespace.path, id: project.path + get :show, namespace_id: project.namespace, id: project expect(response).to render_template("projects/_customize_workflow") end @@ -96,7 +98,7 @@ describe ProjectsController do it 'shows activity if enabled by user' do user.update_attribute(:project_view, 'activity') - get :show, namespace_id: project.namespace.path, id: project.path + get :show, namespace_id: project.namespace, id: project expect(response).to render_template("projects/_activity") end @@ -113,7 +115,7 @@ describe ProjectsController do before do user.update_attributes(project_view: project_view) - get :show, namespace_id: empty_project.namespace.path, id: empty_project.path + get :show, namespace_id: empty_project.namespace, id: empty_project end it "renders the empty project view" do @@ -133,7 +135,7 @@ describe ProjectsController do before do user.update_attributes(project_view: project_view) - get :show, namespace_id: empty_project.namespace.path, id: empty_project.path + get :show, namespace_id: empty_project.namespace, id: empty_project end it "renders the empty project view" do @@ -154,23 +156,15 @@ describe ProjectsController do allow(controller).to receive(:current_user).and_return(user) allow(user).to receive(:project_view).and_return('activity') - get :show, namespace_id: public_project.namespace.path, id: public_project.path + get :show, namespace_id: public_project.namespace, id: public_project expect(response).to render_template('_activity') end - it "renders the readme view" do - allow(controller).to receive(:current_user).and_return(user) - allow(user).to receive(:project_view).and_return('readme') - - get :show, namespace_id: public_project.namespace.path, id: public_project.path - expect(response).to render_template('_readme') - end - it "renders the files view" do allow(controller).to receive(:current_user).and_return(user) allow(user).to receive(:project_view).and_return('files') - get :show, namespace_id: public_project.namespace.path, id: public_project.path + get :show, namespace_id: public_project.namespace, id: public_project expect(response).to render_template('_files') end end @@ -178,7 +172,7 @@ describe ProjectsController do context "when requested with case sensitive namespace and project path" do context "when there is a match with the same casing" do it "loads the project" do - get :show, namespace_id: public_project.namespace.path, id: public_project.path + get :show, namespace_id: public_project.namespace, id: public_project expect(assigns(:project)).to eq(public_project) expect(response).to have_http_status(200) @@ -187,10 +181,10 @@ describe ProjectsController do context "when there is a match with different casing" do it "redirects to the normalized path" do - get :show, namespace_id: public_project.namespace.path, id: public_project.path.upcase + get :show, namespace_id: public_project.namespace, id: public_project.path.upcase expect(assigns(:project)).to eq(public_project) - expect(response).to redirect_to("/#{public_project.path_with_namespace}") + expect(response).to redirect_to("/#{public_project.full_path}") end end end @@ -208,7 +202,7 @@ describe ProjectsController do project = create(:empty_project, pending_delete: true) sign_in(user) - get :show, namespace_id: project.namespace.path, id: project.path + get :show, namespace_id: project.namespace, id: project expect(response.status).to eq 404 end @@ -218,7 +212,7 @@ describe ProjectsController do it 'redirects to project page (format.html)' do project = create(:project, :public) - get :show, namespace_id: project.namespace.path, id: project.path, format: :git + get :show, namespace_id: project.namespace, id: project, format: :git expect(response).to have_http_status(302) expect(response).to redirect_to(namespace_project_path) @@ -239,7 +233,7 @@ describe ProjectsController do sign_in(admin) put :update, - namespace_id: project.namespace.to_param, + namespace_id: project.namespace, id: project.id, project: project_params @@ -257,7 +251,7 @@ describe ProjectsController do sign_in(admin) orig_id = project.id - delete :destroy, namespace_id: project.namespace.path, id: project.path + delete :destroy, namespace_id: project.namespace, id: project expect { Project.find(orig_id) }.to raise_error(ActiveRecord::RecordNotFound) expect(response).to have_http_status(302) @@ -277,7 +271,7 @@ describe ProjectsController do project.merge_requests << merge_request sign_in(admin) - delete :destroy, namespace_id: fork_project.namespace.path, id: fork_project.path + delete :destroy, namespace_id: fork_project.namespace, id: fork_project expect(merge_request.reload.state).to eq('closed') end @@ -287,8 +281,8 @@ describe ProjectsController do describe 'PUT #new_issue_address' do subject do put :new_issue_address, - namespace_id: project.namespace.to_param, - id: project.to_param + namespace_id: project.namespace, + id: project user.reload end @@ -316,23 +310,23 @@ describe ProjectsController do sign_in(user) expect(user.starred?(public_project)).to be_falsey post(:toggle_star, - namespace_id: public_project.namespace.to_param, - id: public_project.to_param) + namespace_id: public_project.namespace, + id: public_project) expect(user.starred?(public_project)).to be_truthy post(:toggle_star, - namespace_id: public_project.namespace.to_param, - id: public_project.to_param) + namespace_id: public_project.namespace, + id: public_project) expect(user.starred?(public_project)).to be_falsey end it "does nothing if user is not signed in" do post(:toggle_star, - namespace_id: project.namespace.to_param, - id: public_project.to_param) + namespace_id: project.namespace, + id: public_project) expect(user.starred?(public_project)).to be_falsey post(:toggle_star, - namespace_id: project.namespace.to_param, - id: public_project.to_param) + namespace_id: project.namespace, + id: public_project) expect(user.starred?(public_project)).to be_falsey end end @@ -366,8 +360,8 @@ describe ProjectsController do it 'does nothing if project was not forked' do delete(:remove_fork, - namespace_id: unforked_project.namespace.to_param, - id: unforked_project.to_param, format: :js) + namespace_id: unforked_project.namespace, + id: unforked_project, format: :js) expect(flash[:notice]).to be_nil expect(response).to render_template(:remove_fork) @@ -377,8 +371,8 @@ describe ProjectsController do it "does nothing if user is not signed in" do delete(:remove_fork, - namespace_id: project.namespace.to_param, - id: project.to_param, format: :js) + namespace_id: project.namespace, + id: project, format: :js) expect(response).to have_http_status(401) end end @@ -387,7 +381,7 @@ describe ProjectsController do let(:public_project) { create(:project, :public) } it "gets a list of branches and tags" do - get :refs, namespace_id: public_project.namespace.path, id: public_project.path + get :refs, namespace_id: public_project.namespace, id: public_project parsed_body = JSON.parse(response.body) expect(parsed_body["Branches"]).to include("master") @@ -396,7 +390,7 @@ describe ProjectsController do end it "gets a list of branches, tags and commits" do - get :refs, namespace_id: public_project.namespace.path, id: public_project.path, ref: "123456" + get :refs, namespace_id: public_project.namespace, id: public_project, ref: "123456" parsed_body = JSON.parse(response.body) expect(parsed_body["Branches"]).to include("master") diff --git a/spec/controllers/root_controller_spec.rb b/spec/controllers/root_controller_spec.rb index b14d275f7fa..b32eb39b1fb 100644 --- a/spec/controllers/root_controller_spec.rb +++ b/spec/controllers/root_controller_spec.rb @@ -2,6 +2,26 @@ require 'spec_helper' describe RootController do describe 'GET index' do + context 'when user is not logged in' do + it 'redirects to the sign-in page' do + get :index + + expect(response).to redirect_to(new_user_session_path) + end + + context 'when a custom home page URL is defined' do + before do + stub_application_setting(home_page_url: 'https://gitlab.com') + end + + it 'redirects the user to the custom home page URL' do + get :index + + expect(response).to redirect_to('https://gitlab.com') + end + end + end + context 'with a user' do let(:user) { create(:user) } @@ -12,55 +32,60 @@ describe RootController do context 'who has customized their dashboard setting for starred projects' do before do - user.update_attribute(:dashboard, 'stars') + user.dashboard = 'stars' end it 'redirects to their specified dashboard' do get :index + expect(response).to redirect_to starred_dashboard_projects_path end end context 'who has customized their dashboard setting for project activities' do before do - user.update_attribute(:dashboard, 'project_activity') + user.dashboard = 'project_activity' end it 'redirects to the activity list' do get :index + expect(response).to redirect_to activity_dashboard_path end end context 'who has customized their dashboard setting for starred project activities' do before do - user.update_attribute(:dashboard, 'starred_project_activity') + user.dashboard = 'starred_project_activity' end it 'redirects to the activity list' do get :index + expect(response).to redirect_to activity_dashboard_path(filter: 'starred') end end context 'who has customized their dashboard setting for groups' do before do - user.update_attribute(:dashboard, 'groups') + user.dashboard = 'groups' end it 'redirects to their group list' do get :index + expect(response).to redirect_to dashboard_groups_path end end context 'who has customized their dashboard setting for todos' do before do - user.update_attribute(:dashboard, 'todos') + user.dashboard = 'todos' end it 'redirects to their todo list' do get :index + expect(response).to redirect_to dashboard_todos_path end end @@ -68,6 +93,7 @@ describe RootController do context 'who uses the default dashboard setting' do it 'renders the default dashboard' do get :index + expect(response).to render_template 'dashboard/projects/index' end end diff --git a/spec/controllers/sessions_controller_spec.rb b/spec/controllers/sessions_controller_spec.rb index b56c7880b64..a06c29dd91a 100644 --- a/spec/controllers/sessions_controller_spec.rb +++ b/spec/controllers/sessions_controller_spec.rb @@ -25,9 +25,17 @@ describe SessionsController do expect(subject.current_user). to eq user end - it "creates an audit log record" do + it 'creates an audit log record' do expect { post(:create, user: { login: user.username, password: user.password }) }.to change { SecurityEvent.count }.by(1) - expect(SecurityEvent.last.details[:with]).to eq("standard") + expect(SecurityEvent.last.details[:with]).to eq('standard') + end + + include_examples 'user login request with unique ip limit', 302 do + def request + post(:create, user: { login: user.username, password: user.password }) + expect(subject.current_user).to eq user + subject.sign_out user + end end end end diff --git a/spec/controllers/uploads_controller_spec.rb b/spec/controllers/uploads_controller_spec.rb index c9584ddf18c..f67d26da0ac 100644 --- a/spec/controllers/uploads_controller_spec.rb +++ b/spec/controllers/uploads_controller_spec.rb @@ -1,4 +1,9 @@ require 'spec_helper' +shared_examples 'content not cached without revalidation' do + it 'ensures content will not be cached without revalidation' do + expect(subject['Cache-Control']).to eq('max-age=0, private, must-revalidate') + end +end describe UploadsController do let!(:user) { create(:user, avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "image/png")) } @@ -50,6 +55,13 @@ describe UploadsController do expect(response).to have_http_status(200) end + + it_behaves_like 'content not cached without revalidation' do + subject do + get :show, model: 'user', mounted_as: 'avatar', id: user.id, filename: 'image.png' + response + end + end end end @@ -59,6 +71,13 @@ describe UploadsController do expect(response).to have_http_status(200) end + + it_behaves_like 'content not cached without revalidation' do + subject do + get :show, model: 'user', mounted_as: 'avatar', id: user.id, filename: 'image.png' + response + end + end end end @@ -76,6 +95,13 @@ describe UploadsController do expect(response).to have_http_status(200) end + + it_behaves_like 'content not cached without revalidation' do + subject do + get :show, model: 'project', mounted_as: 'avatar', id: project.id, filename: 'image.png' + response + end + end end context "when signed in" do @@ -88,6 +114,13 @@ describe UploadsController do expect(response).to have_http_status(200) end + + it_behaves_like 'content not cached without revalidation' do + subject do + get :show, model: 'project', mounted_as: 'avatar', id: project.id, filename: 'image.png' + response + end + end end end @@ -133,6 +166,13 @@ describe UploadsController do expect(response).to have_http_status(200) end + + it_behaves_like 'content not cached without revalidation' do + subject do + get :show, model: 'project', mounted_as: 'avatar', id: project.id, filename: 'image.png' + response + end + end end end @@ -157,6 +197,13 @@ describe UploadsController do expect(response).to have_http_status(200) end + + it_behaves_like 'content not cached without revalidation' do + subject do + get :show, model: 'group', mounted_as: 'avatar', id: group.id, filename: 'image.png' + response + end + end end context "when signed in" do @@ -169,6 +216,13 @@ describe UploadsController do expect(response).to have_http_status(200) end + + it_behaves_like 'content not cached without revalidation' do + subject do + get :show, model: 'group', mounted_as: 'avatar', id: group.id, filename: 'image.png' + response + end + end end end @@ -205,6 +259,13 @@ describe UploadsController do expect(response).to have_http_status(200) end + + it_behaves_like 'content not cached without revalidation' do + subject do + get :show, model: 'group', mounted_as: 'avatar', id: group.id, filename: 'image.png' + response + end + end end end @@ -234,6 +295,13 @@ describe UploadsController do expect(response).to have_http_status(200) end + + it_behaves_like 'content not cached without revalidation' do + subject do + get :show, model: 'note', mounted_as: 'attachment', id: note.id, filename: 'image.png' + response + end + end end context "when signed in" do @@ -246,6 +314,13 @@ describe UploadsController do expect(response).to have_http_status(200) end + + it_behaves_like 'content not cached without revalidation' do + subject do + get :show, model: 'note', mounted_as: 'attachment', id: note.id, filename: 'image.png' + response + end + end end end @@ -291,6 +366,13 @@ describe UploadsController do expect(response).to have_http_status(200) end + + it_behaves_like 'content not cached without revalidation' do + subject do + get :show, model: 'note', mounted_as: 'attachment', id: note.id, filename: 'image.png' + response + end + end end end diff --git a/spec/factories/chat_teams.rb b/spec/factories/chat_teams.rb new file mode 100644 index 00000000000..82f44fa3d15 --- /dev/null +++ b/spec/factories/chat_teams.rb @@ -0,0 +1,9 @@ +FactoryGirl.define do + factory :chat_team, class: ChatTeam do + sequence :team_id do |n| + "abcdefghijklm#{n}" + end + + namespace factory: :group + end +end diff --git a/spec/factories/ci/builds.rb b/spec/factories/ci/builds.rb index a90534d10ba..6b0d084614b 100644 --- a/spec/factories/ci/builds.rb +++ b/spec/factories/ci/builds.rb @@ -15,8 +15,8 @@ FactoryGirl.define do options do { - image: "ruby:2.1", - services: ["postgres"] + image: 'ruby:2.1', + services: ['postgres'] } end @@ -57,7 +57,7 @@ FactoryGirl.define do end trait :manual do - status 'skipped' + status 'manual' self.when 'manual' end @@ -71,11 +71,26 @@ FactoryGirl.define do allow_failure true end + trait :ignored do + allowed_to_fail + end + trait :playable do - skipped manual end + trait :tags do + tag_list [:docker, :ruby] + end + + trait :on_tag do + tag true + end + + trait :triggered do + trigger_request factory: :ci_trigger_request_with_variables + end + after(:build) do |build, evaluator| build.project = build.pipeline.project end @@ -151,5 +166,31 @@ FactoryGirl.define do allow(build).to receive(:commit).and_return build(:commit) end end + + trait :extended_options do + options do + { + image: 'ruby:2.1', + services: ['postgres'], + after_script: "ls\ndate", + artifacts: { + name: 'artifacts_file', + untracked: false, + paths: ['out/'], + when: 'always', + expire_in: '7d' + }, + cache: { + key: 'cache_key', + untracked: false, + paths: ['vendor/*'] + } + } + end + end + + trait :no_options do + options { {} } + end end end diff --git a/spec/factories/ci/pipelines.rb b/spec/factories/ci/pipelines.rb index 77404f46c92..b67c96bc00d 100644 --- a/spec/factories/ci/pipelines.rb +++ b/spec/factories/ci/pipelines.rb @@ -40,6 +40,14 @@ FactoryGirl.define do trait :invalid do config(rspec: nil) end + + trait :blocked do + status :manual + end + + trait :success do + status :success + end end end end diff --git a/spec/factories/ci/runner_projects.rb b/spec/factories/ci/runner_projects.rb index 3372e5ab685..6712dd5d82e 100644 --- a/spec/factories/ci/runner_projects.rb +++ b/spec/factories/ci/runner_projects.rb @@ -1,6 +1,6 @@ FactoryGirl.define do factory :ci_runner_project, class: Ci::RunnerProject do runner_id 1 - gl_project_id 1 + project_id 1 end end diff --git a/spec/factories/commit_statuses.rb b/spec/factories/commit_statuses.rb index 756b341ecba..169590deb8e 100644 --- a/spec/factories/commit_statuses.rb +++ b/spec/factories/commit_statuses.rb @@ -35,6 +35,10 @@ FactoryGirl.define do status 'created' end + trait :manual do + status 'manual' + end + after(:build) do |build, evaluator| build.project = build.pipeline.project end diff --git a/spec/factories/merge_requests.rb b/spec/factories/merge_requests.rb index 22f84150bb3..21487541507 100644 --- a/spec/factories/merge_requests.rb +++ b/spec/factories/merge_requests.rb @@ -4,6 +4,7 @@ FactoryGirl.define do author association :source_project, :repository, factory: :project target_project { source_project } + project { target_project } # $ git log --pretty=oneline feature..master # 5937ac0a7beb003549fc5fd26fc247adbce4a52e Add submodule from gitlab.com @@ -59,8 +60,8 @@ FactoryGirl.define do target_branch "master" end - trait :merge_when_build_succeeds do - merge_when_build_succeeds true + trait :merge_when_pipeline_succeeds do + merge_when_pipeline_succeeds true merge_user author end diff --git a/spec/factories/notes.rb b/spec/factories/notes.rb index 5c50cd7f4ad..fe19a404e16 100644 --- a/spec/factories/notes.rb +++ b/spec/factories/notes.rb @@ -26,12 +26,17 @@ FactoryGirl.define do factory :diff_note_on_merge_request, traits: [:on_merge_request], class: DiffNote do association :project, :repository + + transient do + line_number 14 + end + position do Gitlab::Diff::Position.new( old_path: "files/ruby/popen.rb", new_path: "files/ruby/popen.rb", old_line: nil, - new_line: 14, + new_line: line_number, diff_refs: noteable.diff_refs ) end diff --git a/spec/factories/oauth_access_grants.rb b/spec/factories/oauth_access_grants.rb new file mode 100644 index 00000000000..543b3e99274 --- /dev/null +++ b/spec/factories/oauth_access_grants.rb @@ -0,0 +1,11 @@ +FactoryGirl.define do + factory :oauth_access_grant do + resource_owner_id { create(:user).id } + application + token { Doorkeeper::OAuth::Helpers::UniqueToken.generate } + expires_in 2.hours + + redirect_uri { application.redirect_uri } + scopes { application.scopes } + end +end diff --git a/spec/factories/oauth_access_tokens.rb b/spec/factories/oauth_access_tokens.rb index ccf02d0719b..a46bc1d8ce8 100644 --- a/spec/factories/oauth_access_tokens.rb +++ b/spec/factories/oauth_access_tokens.rb @@ -2,6 +2,7 @@ FactoryGirl.define do factory :oauth_access_token do resource_owner application - token '123456' + token { Doorkeeper::OAuth::Helpers::UniqueToken.generate } + scopes { application.scopes } end end diff --git a/spec/factories/oauth_applications.rb b/spec/factories/oauth_applications.rb index d116a573830..86cdc208268 100644 --- a/spec/factories/oauth_applications.rb +++ b/spec/factories/oauth_applications.rb @@ -1,7 +1,7 @@ FactoryGirl.define do factory :oauth_application, class: 'Doorkeeper::Application', aliases: [:application] do name { FFaker::Name.name } - uid { FFaker::Name.name } + uid { Doorkeeper::OAuth::Helpers::UniqueToken.generate } redirect_uri { FFaker::Internet.uri('http') } owner owner_type 'User' diff --git a/spec/factories/personal_access_tokens.rb b/spec/factories/personal_access_tokens.rb index 811eab7e15b..7b15ba47de1 100644 --- a/spec/factories/personal_access_tokens.rb +++ b/spec/factories/personal_access_tokens.rb @@ -6,5 +6,22 @@ FactoryGirl.define do revoked false expires_at { 5.days.from_now } scopes ['api'] + impersonation false + + trait :impersonation do + impersonation true + end + + trait :revoked do + revoked true + end + + trait :expired do + expires_at { 1.day.ago } + end + + trait :invalid do + token nil + end end end diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index c80b09e9b9d..0db2fe04edd 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -38,13 +38,17 @@ FactoryGirl.define do trait :empty_repo do after(:create) do |project| - project.create_repository + raise "Failed to create repository!" unless project.create_repository + + # We delete hooks so that gitlab-shell will not try to authenticate with + # an API that isn't running + FileUtils.rm_r(File.join(project.repository_storage_path, "#{project.path_with_namespace}.git", 'hooks')) end end trait :broken_repo do after(:create) do |project| - project.create_repository + raise "Failed to create repository!" unless project.create_repository FileUtils.rm_r(File.join(project.repository_storage_path, "#{project.path_with_namespace}.git", 'refs')) end @@ -138,27 +142,24 @@ FactoryGirl.define do project.add_user(args[:user], args[:access]) - project.repository.commit_file( + project.repository.create_file( args[:user], ".gitlab/#{args[:path]}/bug.md", 'something valid', message: 'test 3', - branch_name: 'master', - update: false) - project.repository.commit_file( + branch_name: 'master') + project.repository.create_file( args[:user], ".gitlab/#{args[:path]}/template_test.md", 'template_test', message: 'test 1', - branch_name: 'master', - update: false) - project.repository.commit_file( + branch_name: 'master') + project.repository.create_file( args[:user], ".gitlab/#{args[:path]}/feature_proposal.md", 'feature_proposal', message: 'test 2', - branch_name: 'master', - update: false) + branch_name: 'master') end end end @@ -188,27 +189,19 @@ FactoryGirl.define do factory :jira_project, parent: :project do has_external_issue_tracker true - - after :create do |project| - project.create_jira_service( - active: true, - properties: { - title: 'JIRA tracker', - url: 'http://jira.example.net', - project_key: 'JIRA' - } - ) - end + jira_service end factory :kubernetes_project, parent: :empty_project do + kubernetes_service + end + + factory :prometheus_project, parent: :empty_project do after :create do |project| - project.create_kubernetes_service( + project.create_prometheus_service( active: true, properties: { - namespace: project.path, - api_url: 'https://kubernetes.example.com', - token: 'a' * 40, + api_url: 'https://prometheus.example.com' } ) end diff --git a/spec/factories/services.rb b/spec/factories/services.rb index a14a46c803e..88f6c265505 100644 --- a/spec/factories/services.rb +++ b/spec/factories/services.rb @@ -2,4 +2,23 @@ FactoryGirl.define do factory :service do project factory: :empty_project end + + factory :kubernetes_service do + project factory: :empty_project + active true + properties({ + namespace: 'somepath', + api_url: 'https://kubernetes.example.com', + token: 'a' * 40, + }) + end + + factory :jira_service do + project factory: :empty_project + active true + properties( + url: 'https://jira.example.com', + project_key: 'jira-key' + ) + end end diff --git a/spec/factories/todos.rb b/spec/factories/todos.rb index a5265f1b189..c1ac3bb84ad 100644 --- a/spec/factories/todos.rb +++ b/spec/factories/todos.rb @@ -18,11 +18,6 @@ FactoryGirl.define do action { Todo::DIRECTLY_ADDRESSED } end - trait :on_commit do - commit_id RepoHelpers.sample_commit.id - target_type "Commit" - end - trait :build_failed do action { Todo::BUILD_FAILED } target factory: :merge_request @@ -48,4 +43,13 @@ FactoryGirl.define do state :done end end + + factory :on_commit_todo, class: Todo do + project factory: :empty_project + author + user + action { Todo::ASSIGNED } + commit_id RepoHelpers.sample_commit.id + target_type "Commit" + end end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 1732b1a0081..249dabbaae8 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -26,6 +26,11 @@ FactoryGirl.define do two_factor_via_otp end + trait :ghost do + ghost true + after(:build) { |user, _| user.block! } + end + trait :two_factor_via_otp do before(:create) do |user| user.otp_required_for_login = true diff --git a/spec/features/admin/admin_disables_git_access_protocol_spec.rb b/spec/features/admin/admin_disables_git_access_protocol_spec.rb index e8e080ce3e2..273cacd82cd 100644 --- a/spec/features/admin/admin_disables_git_access_protocol_spec.rb +++ b/spec/features/admin/admin_disables_git_access_protocol_spec.rb @@ -32,7 +32,7 @@ feature 'Admin disables Git access protocol', feature: true do scenario 'shows only HTTP url' do visit_project - expect(page).to have_content("git clone #{project.http_url_to_repo}") + expect(page).to have_content("git clone #{project.http_url_to_repo(admin)}") expect(page).not_to have_selector('#clone-dropdown') end end diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb index de42ab81fac..03daab12c8f 100644 --- a/spec/features/admin/admin_settings_spec.rb +++ b/spec/features/admin/admin_settings_spec.rb @@ -9,6 +9,13 @@ feature 'Admin updates settings', feature: true do visit admin_application_settings_path end + scenario 'Change visibility settings' do + choose "application_setting_default_project_visibility_20" + click_button 'Save' + + expect(page).to have_content "Application settings saved successfully" + end + scenario 'Change application settings' do uncheck 'Gravatar enabled' fill_in 'Home page URL', with: 'https://about.gitlab.com/' @@ -26,7 +33,7 @@ feature 'Admin updates settings', feature: true do fill_in 'Webhook', with: 'http://localhost' fill_in 'Username', with: 'test_user' fill_in 'service_push_channel', with: '#test_channel' - page.check('Notify only broken builds') + page.check('Notify only broken pipelines') check_all_events click_on 'Save' @@ -50,7 +57,6 @@ feature 'Admin updates settings', feature: true do page.check('Note') page.check('Issue') page.check('Merge request') - page.check('Build') page.check('Pipeline') end end diff --git a/spec/features/admin/admin_users_impersonation_tokens_spec.rb b/spec/features/admin/admin_users_impersonation_tokens_spec.rb new file mode 100644 index 00000000000..9ff5c2f9d40 --- /dev/null +++ b/spec/features/admin/admin_users_impersonation_tokens_spec.rb @@ -0,0 +1,72 @@ +require 'spec_helper' + +describe 'Admin > Users > Impersonation Tokens', feature: true, js: true do + let(:admin) { create(:admin) } + let!(:user) { create(:user) } + + def active_impersonation_tokens + find(".table.active-tokens") + end + + def inactive_impersonation_tokens + find(".table.inactive-tokens") + end + + before { login_as(admin) } + + describe "token creation" do + it "allows creation of a token" do + name = FFaker::Product.brand + + visit admin_user_impersonation_tokens_path(user_id: user.username) + fill_in "Name", with: name + + # Set date to 1st of next month + find_field("Expires at").trigger('focus') + find(".pika-next").click + click_on "1" + + # Scopes + check "api" + check "read_user" + + expect { click_on "Create Impersonation Token" }.to change { PersonalAccessTokensFinder.new(impersonation: true).execute.count } + expect(active_impersonation_tokens).to have_text(name) + expect(active_impersonation_tokens).to have_text('In') + expect(active_impersonation_tokens).to have_text('api') + expect(active_impersonation_tokens).to have_text('read_user') + end + end + + describe 'active tokens' do + let!(:impersonation_token) { create(:personal_access_token, :impersonation, user: user) } + let!(:personal_access_token) { create(:personal_access_token, user: user) } + + it 'only shows impersonation tokens' do + visit admin_user_impersonation_tokens_path(user_id: user.username) + + expect(active_impersonation_tokens).to have_text(impersonation_token.name) + expect(active_impersonation_tokens).not_to have_text(personal_access_token.name) + end + end + + describe "inactive tokens" do + let!(:impersonation_token) { create(:personal_access_token, :impersonation, user: user) } + + it "allows revocation of an active impersonation token" do + visit admin_user_impersonation_tokens_path(user_id: user.username) + + click_on "Revoke" + + expect(inactive_impersonation_tokens).to have_text(impersonation_token.name) + end + + it "moves expired tokens to the 'inactive' section" do + impersonation_token.update(expires_at: 5.days.ago) + + visit admin_user_impersonation_tokens_path(user_id: user.username) + + expect(inactive_impersonation_tokens).to have_text(impersonation_token.name) + end + end +end diff --git a/spec/features/atom/dashboard_issues_spec.rb b/spec/features/atom/dashboard_issues_spec.rb index 21ee6cedbae..58b14e09740 100644 --- a/spec/features/atom/dashboard_issues_spec.rb +++ b/spec/features/atom/dashboard_issues_spec.rb @@ -2,7 +2,8 @@ require 'spec_helper' describe "Dashboard Issues Feed", feature: true do describe "GET /issues" do - let!(:user) { create(:user) } + let!(:user) { create(:user, email: 'private1@example.com', public_email: 'public1@example.com') } + let!(:assignee) { create(:user, email: 'private2@example.com', public_email: 'public2@example.com') } let!(:project1) { create(:project) } let!(:project2) { create(:project) } @@ -23,7 +24,7 @@ describe "Dashboard Issues Feed", feature: true do visit issues_dashboard_path(:atom, private_token: user.private_token, state: 'opened', assignee_id: user.id) link = find('link[type="application/atom+xml"]') - params = CGI::parse(URI.parse(link[:href]).query) + params = CGI.parse(URI.parse(link[:href]).query) expect(params).to include('private_token' => [user.private_token]) expect(params).to include('state' => ['opened']) @@ -31,7 +32,7 @@ describe "Dashboard Issues Feed", feature: true do end context "issue with basic fields" do - let!(:issue2) { create(:issue, author: user, assignee: user, project: project2, description: 'test desc') } + let!(:issue2) { create(:issue, author: user, assignee: assignee, project: project2, description: 'test desc') } it "renders issue fields" do visit issues_dashboard_path(:atom, private_token: user.private_token) @@ -39,8 +40,8 @@ describe "Dashboard Issues Feed", feature: true do entry = find(:xpath, "//feed/entry[contains(summary/text(),'#{issue2.title}')]") expect(entry).to be_present - expect(entry).to have_selector('author email', text: issue2.author_email) - expect(entry).to have_selector('assignee email', text: issue2.author_email) + expect(entry).to have_selector('author email', text: issue2.author_public_email) + expect(entry).to have_selector('assignee email', text: issue2.assignee_public_email) expect(entry).not_to have_selector('labels') expect(entry).not_to have_selector('milestone') expect(entry).to have_selector('description', text: issue2.description) @@ -50,7 +51,7 @@ describe "Dashboard Issues Feed", feature: true do context "issue with label and milestone" do let!(:milestone1) { create(:milestone, project: project1, title: 'v1') } let!(:label1) { create(:label, project: project1, title: 'label1') } - let!(:issue1) { create(:issue, author: user, assignee: user, project: project1, milestone: milestone1) } + let!(:issue1) { create(:issue, author: user, assignee: assignee, project: project1, milestone: milestone1) } before do issue1.labels << label1 @@ -62,8 +63,8 @@ describe "Dashboard Issues Feed", feature: true do entry = find(:xpath, "//feed/entry[contains(summary/text(),'#{issue1.title}')]") expect(entry).to be_present - expect(entry).to have_selector('author email', text: issue1.author_email) - expect(entry).to have_selector('assignee email', text: issue1.author_email) + expect(entry).to have_selector('author email', text: issue1.author_public_email) + expect(entry).to have_selector('assignee email', text: issue1.assignee_public_email) expect(entry).to have_selector('labels label', text: label1.title) expect(entry).to have_selector('milestone', text: milestone1.title) expect(entry).not_to have_selector('description') diff --git a/spec/features/atom/issues_spec.rb b/spec/features/atom/issues_spec.rb index 863412d18eb..b3903ec2faf 100644 --- a/spec/features/atom/issues_spec.rb +++ b/spec/features/atom/issues_spec.rb @@ -2,10 +2,11 @@ require 'spec_helper' describe 'Issues Feed', feature: true do describe 'GET /issues' do - let!(:user) { create(:user) } + let!(:user) { create(:user, email: 'private1@example.com', public_email: 'public1@example.com') } + let!(:assignee) { create(:user, email: 'private2@example.com', public_email: 'public2@example.com') } let!(:group) { create(:group) } let!(:project) { create(:project) } - let!(:issue) { create(:issue, author: user, project: project) } + let!(:issue) { create(:issue, author: user, assignee: assignee, project: project) } before do project.team << [user, :developer] @@ -20,7 +21,8 @@ describe 'Issues Feed', feature: true do expect(response_headers['Content-Type']). to have_content('application/atom+xml') expect(body).to have_selector('title', text: "#{project.name} issues") - expect(body).to have_selector('author email', text: issue.author_email) + expect(body).to have_selector('author email', text: issue.author_public_email) + expect(body).to have_selector('assignee email', text: issue.author_public_email) expect(body).to have_selector('entry summary', text: issue.title) end end @@ -33,7 +35,8 @@ describe 'Issues Feed', feature: true do expect(response_headers['Content-Type']). to have_content('application/atom+xml') expect(body).to have_selector('title', text: "#{project.name} issues") - expect(body).to have_selector('author email', text: issue.author_email) + expect(body).to have_selector('author email', text: issue.author_public_email) + expect(body).to have_selector('assignee email', text: issue.author_public_email) expect(body).to have_selector('entry summary', text: issue.title) end end @@ -43,7 +46,7 @@ describe 'Issues Feed', feature: true do :atom, private_token: user.private_token, state: 'opened', assignee_id: user.id) link = find('link[type="application/atom+xml"]') - params = CGI::parse(URI.parse(link[:href]).query) + params = CGI.parse(URI.parse(link[:href]).query) expect(params).to include('private_token' => [user.private_token]) expect(params).to include('state' => ['opened']) @@ -54,7 +57,7 @@ describe 'Issues Feed', feature: true do visit issues_group_path(group, :atom, private_token: user.private_token, state: 'opened', assignee_id: user.id) link = find('link[type="application/atom+xml"]') - params = CGI::parse(URI.parse(link[:href]).query) + params = CGI.parse(URI.parse(link[:href]).query) expect(params).to include('private_token' => [user.private_token]) expect(params).to include('state' => ['opened']) diff --git a/spec/features/atom/users_spec.rb b/spec/features/atom/users_spec.rb index f8c3ccb416b..55e10a1a89b 100644 --- a/spec/features/atom/users_spec.rb +++ b/spec/features/atom/users_spec.rb @@ -57,11 +57,11 @@ describe "User Feed", feature: true do end it 'has XHTML summaries in notes' do - expect(body).to match /Bug confirmed <img[^>]*\/>/ + expect(body).to match /Bug confirmed <gl-emoji[^>]*>/ end it 'has XHTML summaries in merge request descriptions' do - expect(body).to match /Here is the fix: <\/p><div[^>]*><a[^>]*><img[^>]*\/><\/a><\/div>/ + expect(body).to match /Here is the fix: <a[^>]*><img[^>]*\/><\/a>/ end end end diff --git a/spec/features/boards/add_issues_modal_spec.rb b/spec/features/boards/add_issues_modal_spec.rb index 2875fc1e533..d17a418b8c3 100644 --- a/spec/features/boards/add_issues_modal_spec.rb +++ b/spec/features/boards/add_issues_modal_spec.rb @@ -49,6 +49,12 @@ describe 'Issue Boards add issue modal', :feature, :js do expect(page).not_to have_selector('.add-issues-modal') end + + it 'does not show tooltip on add issues button' do + button = page.find('.filter-dropdown-container button', text: 'Add issues') + + expect(button[:title]).not_to eq("Please add a list to your board first") + end end context 'issues list' do @@ -101,6 +107,9 @@ describe 'Issue Boards add issue modal', :feature, :js do it 'returns issues' do page.within('.add-issues-modal') do find('.form-control').native.send_keys(issue.title) + find('.form-control').native.send_keys(:enter) + + wait_for_vue_resource expect(page).to have_selector('.card', count: 1) end @@ -109,6 +118,9 @@ describe 'Issue Boards add issue modal', :feature, :js do it 'returns no issues' do page.within('.add-issues-modal') do find('.form-control').native.send_keys('testing search') + find('.form-control').native.send_keys(:enter) + + wait_for_vue_resource expect(page).not_to have_selector('.card') expect(page).not_to have_content("You haven't added any issues to your project yet") diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb index 1b25b51cfb2..f7e8b78b54d 100644 --- a/spec/features/boards/boards_spec.rb +++ b/spec/features/boards/boards_spec.rb @@ -28,10 +28,10 @@ describe 'Issue Boards', feature: true, js: true do expect(page).to have_content('Welcome to your Issue Board!') end - it 'disables add issues button by default' do - button = page.find('.issue-boards-search button', text: 'Add issues') + it 'shows tooltip on add issues button' do + button = page.find('.filter-dropdown-container button', text: 'Add issues') - expect(button[:disabled]).to eq true + expect(button[:"data-original-title"]).to eq("Please add a list to your board first") end it 'hides the blank state when clicking nevermind button' do @@ -71,16 +71,16 @@ describe 'Issue Boards', feature: true, js: true do let!(:list1) { create(:list, board: board, label: planning, position: 0) } let!(:list2) { create(:list, board: board, label: development, position: 1) } - let!(:confidential_issue) { create(:labeled_issue, :confidential, project: project, author: user, labels: [planning]) } - let!(:issue1) { create(:labeled_issue, project: project, assignee: user, labels: [planning]) } - let!(:issue2) { create(:labeled_issue, project: project, author: user2, labels: [planning]) } - let!(:issue3) { create(:labeled_issue, project: project, labels: [planning]) } - let!(:issue4) { create(:labeled_issue, project: project, labels: [planning]) } - let!(:issue5) { create(:labeled_issue, project: project, labels: [planning], milestone: milestone) } - let!(:issue6) { create(:labeled_issue, project: project, labels: [planning, development]) } - let!(:issue7) { create(:labeled_issue, project: project, labels: [development]) } + let!(:confidential_issue) { create(:labeled_issue, :confidential, project: project, author: user, labels: [planning], relative_position: 9) } + let!(:issue1) { create(:labeled_issue, project: project, assignee: user, labels: [planning], relative_position: 8) } + let!(:issue2) { create(:labeled_issue, project: project, author: user2, labels: [planning], relative_position: 7) } + let!(:issue3) { create(:labeled_issue, project: project, labels: [planning], relative_position: 6) } + let!(:issue4) { create(:labeled_issue, project: project, labels: [planning], relative_position: 5) } + let!(:issue5) { create(:labeled_issue, project: project, labels: [planning], milestone: milestone, relative_position: 4) } + let!(:issue6) { create(:labeled_issue, project: project, labels: [planning, development], relative_position: 3) } + let!(:issue7) { create(:labeled_issue, project: project, labels: [development], relative_position: 2) } let!(:issue8) { create(:closed_issue, project: project) } - let!(:issue9) { create(:labeled_issue, project: project, labels: [planning, testing, bug, accepting]) } + let!(:issue9) { create(:labeled_issue, project: project, labels: [planning, testing, bug, accepting], relative_position: 1) } before do visit namespace_project_board_path(project.namespace, project, board) @@ -115,9 +115,8 @@ describe 'Issue Boards', feature: true, js: true do end it 'search done list' do - page.within('#js-boards-search') do - find('.form-control').set(issue8.title) - end + find('.filtered-search').set(issue8.title) + find('.filtered-search').native.send_keys(:enter) wait_for_vue_resource @@ -127,9 +126,8 @@ describe 'Issue Boards', feature: true, js: true do end it 'search list' do - page.within('#js-boards-search') do - find('.form-control').set(issue5.title) - end + find('.filtered-search').set(issue5.title) + find('.filtered-search').native.send_keys(:enter) wait_for_vue_resource @@ -333,7 +331,7 @@ describe 'Issue Boards', feature: true, js: true do wait_for_vue_resource - expect(find('.issue-boards-search')).to have_selector('.open') + expect(page).to have_css('#js-add-list.open') end it 'creates new list from a new label' do @@ -359,17 +357,9 @@ describe 'Issue Boards', feature: true, js: true do context 'filtering' do it 'filters by author' do - page.within '.issues-filters' do - click_button('Author') - wait_for_ajax - - page.within '.dropdown-menu-author' do - click_link(user2.name) - end - wait_for_vue_resource - - expect(find('.js-author-search')).to have_content(user2.name) - end + set_filter("author", user2.username) + click_filter_link(user2.username) + submit_filter wait_for_vue_resource wait_for_board_cards(1, 1) @@ -377,17 +367,9 @@ describe 'Issue Boards', feature: true, js: true do end it 'filters by assignee' do - page.within '.issues-filters' do - click_button('Assignee') - wait_for_ajax - - page.within '.dropdown-menu-assignee' do - click_link(user.name) - end - wait_for_vue_resource - - expect(find('.js-assignee-search')).to have_content(user.name) - end + set_filter("assignee", user.username) + click_filter_link(user.username) + submit_filter wait_for_vue_resource @@ -396,17 +378,9 @@ describe 'Issue Boards', feature: true, js: true do end it 'filters by milestone' do - page.within '.issues-filters' do - click_button('Milestone') - wait_for_ajax - - page.within '.milestone-filter' do - click_link(milestone.title) - end - wait_for_vue_resource - - expect(find('.js-milestone-select')).to have_content(milestone.title) - end + set_filter("milestone", "\"#{milestone.title}\"") + click_filter_link(milestone.title) + submit_filter wait_for_vue_resource wait_for_board_cards(1, 1) @@ -415,16 +389,9 @@ describe 'Issue Boards', feature: true, js: true do end it 'filters by label' do - page.within '.issues-filters' do - click_button('Label') - wait_for_ajax - - page.within '.dropdown-menu-labels' do - click_link(testing.title) - wait_for_vue_resource - find('.dropdown-menu-close').click - end - end + set_filter("label", testing.title) + click_filter_link(testing.title) + submit_filter wait_for_vue_resource wait_for_board_cards(1, 1) @@ -432,19 +399,14 @@ describe 'Issue Boards', feature: true, js: true do end it 'filters by label with space after reload' do - page.within '.issues-filters' do - click_button('Label') - wait_for_ajax - - page.within '.dropdown-menu-labels' do - click_link(accepting.title) - wait_for_vue_resource(spinner: false) - find('.dropdown-menu-close').click - end - end + set_filter("label", "\"#{accepting.title}\"") + click_filter_link(accepting.title) + submit_filter # Test after reload page.evaluate_script 'window.location.reload()' + wait_for_board_cards(1, 1) + wait_for_empty_boards((2..3)) wait_for_vue_resource @@ -460,26 +422,16 @@ describe 'Issue Boards', feature: true, js: true do end it 'removes filtered labels' do - wait_for_vue_resource + set_filter("label", testing.title) + click_filter_link(testing.title) + submit_filter - page.within '.labels-filter' do - click_button('Label') - wait_for_ajax - - page.within '.dropdown-menu-labels' do - click_link(testing.title) - wait_for_vue_resource(spinner: false) - end - - expect(page).to have_css('input[name="label_name[]"]', visible: false) + wait_for_board_cards(1, 1) - page.within '.dropdown-menu-labels' do - click_link(testing.title) - wait_for_vue_resource(spinner: false) - end + find('.clear-search').click + submit_filter - expect(page).not_to have_css('input[name="label_name[]"]', visible: false) - end + wait_for_board_cards(1, 8) end it 'infinite scrolls list with label filter' do @@ -487,16 +439,9 @@ describe 'Issue Boards', feature: true, js: true do create(:labeled_issue, project: project, labels: [planning, testing]) end - page.within '.issues-filters' do - click_button('Label') - wait_for_ajax - - page.within '.dropdown-menu-labels' do - click_link(testing.title) - wait_for_vue_resource - find('.dropdown-menu-close').click - end - end + set_filter("label", testing.title) + click_filter_link(testing.title) + submit_filter wait_for_vue_resource @@ -518,18 +463,13 @@ describe 'Issue Boards', feature: true, js: true do end it 'filters by multiple labels' do - page.within '.issues-filters' do - click_button('Label') - wait_for_ajax + set_filter("label", testing.title) + click_filter_link(testing.title) - page.within(find('.dropdown-menu-labels')) do - click_link(testing.title) - wait_for_vue_resource - click_link(bug.title) - wait_for_vue_resource - find('.dropdown-menu-close').click - end - end + set_filter("label", bug.title) + click_filter_link(bug.title) + + submit_filter wait_for_vue_resource @@ -545,14 +485,14 @@ describe 'Issue Boards', feature: true, js: true do wait_for_vue_resource end + page.within('.tokens-container') do + expect(page).to have_content(bug.title) + end + wait_for_vue_resource wait_for_board_cards(1, 1) wait_for_empty_boards((2..3)) - - page.within('.labels-filter') do - expect(find('.dropdown-toggle-text')).to have_content(bug.title) - end end it 'removes label filter by clicking label button on issue' do @@ -560,16 +500,13 @@ describe 'Issue Boards', feature: true, js: true do page.within(find('.card', match: :first)) do click_button(bug.title) end + wait_for_vue_resource expect(page).to have_selector('.card', count: 1) end wait_for_vue_resource - - page.within('.labels-filter') do - expect(find('.dropdown-toggle-text')).to have_content(bug.title) - end end end end @@ -643,4 +580,20 @@ describe 'Issue Boards', feature: true, js: true do wait_for_board_cards(board, 0) end end + + def set_filter(type, text) + find('.filtered-search').native.send_keys("#{type}:#{text}") + end + + def submit_filter + find('.filtered-search').native.send_keys(:enter) + end + + def click_filter_link(link_text) + page.within('.filtered-search-input-container') do + expect(page).to have_button(link_text) + + click_button(link_text) + end + end end diff --git a/spec/features/boards/issue_ordering_spec.rb b/spec/features/boards/issue_ordering_spec.rb new file mode 100644 index 00000000000..c50155a6d14 --- /dev/null +++ b/spec/features/boards/issue_ordering_spec.rb @@ -0,0 +1,166 @@ +require 'rails_helper' + +describe 'Issue Boards', :feature, :js do + include WaitForVueResource + include DragTo + + let(:project) { create(:empty_project, :public) } + let(:board) { create(:board, project: project) } + let(:user) { create(:user) } + let(:label) { create(:label, project: project) } + let!(:list1) { create(:list, board: board, label: label, position: 0) } + let!(:issue1) { create(:labeled_issue, project: project, title: 'testing 1', labels: [label], relative_position: 3) } + let!(:issue2) { create(:labeled_issue, project: project, title: 'testing 2', labels: [label], relative_position: 2) } + let!(:issue3) { create(:labeled_issue, project: project, title: 'testing 3', labels: [label], relative_position: 1) } + + before do + project.team << [user, :master] + + login_as(user) + end + + context 'un-ordered issues' do + let!(:issue4) { create(:labeled_issue, project: project, labels: [label]) } + + before do + visit namespace_project_board_path(project.namespace, project, board) + wait_for_vue_resource + + expect(page).to have_selector('.board', count: 2) + end + + it 'has un-ordered issue as last issue' do + page.within(first('.board')) do + expect(all('.card').last).to have_content(issue4.title) + end + end + + it 'moves un-ordered issue to top of list' do + drag(from_index: 3, to_index: 0) + + page.within(first('.board')) do + expect(first('.card')).to have_content(issue4.title) + end + end + end + + context 'ordering in list' do + before do + visit namespace_project_board_path(project.namespace, project, board) + wait_for_vue_resource + + expect(page).to have_selector('.board', count: 2) + end + + it 'moves from middle to top' do + drag(from_index: 1, to_index: 0) + + wait_for_vue_resource + + expect(first('.card')).to have_content(issue2.title) + end + + it 'moves from middle to bottom' do + drag(from_index: 1, to_index: 2) + + wait_for_vue_resource + + expect(all('.card').last).to have_content(issue2.title) + end + + it 'moves from top to bottom' do + drag(from_index: 0, to_index: 2) + + wait_for_vue_resource + + expect(all('.card').last).to have_content(issue3.title) + end + + it 'moves from bottom to top' do + drag(from_index: 2, to_index: 0) + + wait_for_vue_resource + + expect(first('.card')).to have_content(issue1.title) + end + + it 'moves from top to middle' do + drag(from_index: 0, to_index: 1) + + wait_for_vue_resource + + expect(first('.card')).to have_content(issue2.title) + end + + it 'moves from bottom to middle' do + drag(from_index: 2, to_index: 1) + + wait_for_vue_resource + + expect(all('.card').last).to have_content(issue2.title) + end + end + + context 'ordering when changing list' do + let(:label2) { create(:label, project: project) } + let!(:list2) { create(:list, board: board, label: label2, position: 1) } + let!(:issue4) { create(:labeled_issue, project: project, title: 'testing 1', labels: [label2], relative_position: 3.0) } + let!(:issue5) { create(:labeled_issue, project: project, title: 'testing 2', labels: [label2], relative_position: 2.0) } + let!(:issue6) { create(:labeled_issue, project: project, title: 'testing 3', labels: [label2], relative_position: 1.0) } + + before do + visit namespace_project_board_path(project.namespace, project, board) + wait_for_vue_resource + + expect(page).to have_selector('.board', count: 3) + end + + it 'moves to top of another list' do + drag(list_from_index: 0, list_to_index: 1) + + wait_for_vue_resource + + expect(first('.board')).to have_selector('.card', count: 2) + expect(all('.board')[1]).to have_selector('.card', count: 4) + + page.within(all('.board')[1]) do + expect(first('.card')).to have_content(issue3.title) + end + end + + it 'moves to bottom of another list' do + drag(list_from_index: 0, list_to_index: 1, to_index: 2) + + wait_for_vue_resource + + expect(first('.board')).to have_selector('.card', count: 2) + expect(all('.board')[1]).to have_selector('.card', count: 4) + + page.within(all('.board')[1]) do + expect(all('.card').last).to have_content(issue3.title) + end + end + + it 'moves to index of another list' do + drag(list_from_index: 0, list_to_index: 1, to_index: 1) + + wait_for_vue_resource + + expect(first('.board')).to have_selector('.card', count: 2) + expect(all('.board')[1]).to have_selector('.card', count: 4) + + page.within(all('.board')[1]) do + expect(all('.card')[1]).to have_content(issue3.title) + end + end + end + + def drag(selector: '.board-list', list_from_index: 0, from_index: 0, to_index: 0, list_to_index: 0) + drag_to(selector: selector, + scrollable: '#board-app', + list_from_index: list_from_index, + from_index: from_index, + to_index: to_index, + list_to_index: list_to_index) + end +end diff --git a/spec/features/boards/modal_filter_spec.rb b/spec/features/boards/modal_filter_spec.rb index 1cf0d11d448..e2281a7da55 100644 --- a/spec/features/boards/modal_filter_spec.rb +++ b/spec/features/boards/modal_filter_spec.rb @@ -1,7 +1,6 @@ require 'rails_helper' describe 'Issue Boards add issue modal filtering', :feature, :js do - include WaitForAjax include WaitForVueResource let(:project) { create(:empty_project, :public) } @@ -23,6 +22,7 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do page.within('.add-issues-modal') do find('.form-control').native.send_keys('testing empty state') + find('.form-control').native.send_keys(:enter) wait_for_vue_resource @@ -33,13 +33,11 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do it 'restores filters when closing' do visit_board - page.within('.add-issues-modal') do - click_button 'Milestone' - - wait_for_ajax - - click_link 'Upcoming' + set_filter('milestone') + click_filter_link('Upcoming') + submit_filter + page.within('.add-issues-modal') do wait_for_vue_resource expect(page).to have_selector('.card', count: 0) @@ -56,39 +54,44 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do end end - context 'author' do - let!(:issue) { create(:issue, project: project, author: user2) } - - before do - project.team << [user2, :developer] + it 'resotres filters after clicking clear button' do + visit_board - visit_board - end + set_filter('milestone') + click_filter_link('Upcoming') + submit_filter - it 'filters by any author' do - page.within('.add-issues-modal') do - click_button 'Author' + page.within('.add-issues-modal') do + wait_for_vue_resource - wait_for_ajax + expect(page).to have_selector('.card', count: 0) - click_link 'Any Author' + find('.clear-search').click - wait_for_vue_resource + wait_for_vue_resource - expect(page).to have_selector('.card', count: 2) - end + expect(page).to have_selector('.card', count: 1) end + end - it 'filters by selected user' do - page.within('.add-issues-modal') do - click_button 'Author' + context 'author' do + let!(:issue) { create(:issue, project: project, author: user2) } + + before do + project.team << [user2, :developer] - wait_for_ajax + visit_board + end - click_link user2.name + it 'filters by selected user' do + set_filter('author') + click_filter_link(user2.name) + submit_filter + page.within('.add-issues-modal') do wait_for_vue_resource + expect(page).to have_selector('.js-visual-token', text: user2.username) expect(page).to have_selector('.card', count: 1) end end @@ -103,46 +106,28 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do visit_board end - it 'filters by any assignee' do - page.within('.add-issues-modal') do - click_button 'Assignee' - - wait_for_ajax - - click_link 'Any Assignee' - - wait_for_vue_resource - - expect(page).to have_selector('.card', count: 2) - end - end - it 'filters by unassigned' do - page.within('.add-issues-modal') do - click_button 'Assignee' - - wait_for_ajax - - click_link 'Unassigned' + set_filter('assignee') + click_filter_link('No Assignee') + submit_filter + page.within('.add-issues-modal') do wait_for_vue_resource + expect(page).to have_selector('.js-visual-token', text: 'none') expect(page).to have_selector('.card', count: 1) end end it 'filters by selected user' do - page.within('.add-issues-modal') do - click_button 'Assignee' - - wait_for_ajax - - page.within '.dropdown-menu-user' do - click_link user2.name - end + set_filter('assignee') + click_filter_link(user2.name) + submit_filter + page.within('.add-issues-modal') do wait_for_vue_resource + expect(page).to have_selector('.js-visual-token', text: user2.username) expect(page).to have_selector('.card', count: 1) end end @@ -156,44 +141,28 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do visit_board end - it 'filters by any milestone' do - page.within('.add-issues-modal') do - click_button 'Milestone' - - wait_for_ajax - - click_link 'Any Milestone' - - wait_for_vue_resource - - expect(page).to have_selector('.card', count: 2) - end - end - it 'filters by upcoming milestone' do - page.within('.add-issues-modal') do - click_button 'Milestone' - - wait_for_ajax - - click_link 'Upcoming' + set_filter('milestone') + click_filter_link('Upcoming') + submit_filter + page.within('.add-issues-modal') do wait_for_vue_resource + expect(page).to have_selector('.js-visual-token', text: 'upcoming') expect(page).to have_selector('.card', count: 0) end end it 'filters by selected milestone' do - page.within('.add-issues-modal') do - click_button 'Milestone' - - wait_for_ajax - - click_link milestone.name + set_filter('milestone') + click_filter_link(milestone.name) + submit_filter + page.within('.add-issues-modal') do wait_for_vue_resource + expect(page).to have_selector('.js-visual-token', text: milestone.name) expect(page).to have_selector('.card', count: 1) end end @@ -207,44 +176,28 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do visit_board end - it 'filters by any label' do - page.within('.add-issues-modal') do - click_button 'Label' - - wait_for_ajax - - click_link 'Any Label' - - wait_for_vue_resource - - expect(page).to have_selector('.card', count: 2) - end - end - it 'filters by no label' do - page.within('.add-issues-modal') do - click_button 'Label' - - wait_for_ajax - - click_link 'No Label' + set_filter('label') + click_filter_link('No Label') + submit_filter + page.within('.add-issues-modal') do wait_for_vue_resource + expect(page).to have_selector('.js-visual-token', text: 'none') expect(page).to have_selector('.card', count: 1) end end it 'filters by label' do - page.within('.add-issues-modal') do - click_button 'Label' - - wait_for_ajax - - click_link label.title + set_filter('label') + click_filter_link(label.title) + submit_filter + page.within('.add-issues-modal') do wait_for_vue_resource + expect(page).to have_selector('.js-visual-token', text: label.title) expect(page).to have_selector('.card', count: 1) end end @@ -256,4 +209,20 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do click_button('Add issues') end + + def set_filter(type, text = '') + find('.add-issues-modal .filtered-search').native.send_keys("#{type}:#{text}") + end + + def submit_filter + find('.add-issues-modal .filtered-search').native.send_keys(:enter) + end + + def click_filter_link(link_text) + page.within('.add-issues-modal .filtered-search-input-container') do + expect(page).to have_button(link_text) + + click_button(link_text) + end + end end diff --git a/spec/features/boards/sidebar_spec.rb b/spec/features/boards/sidebar_spec.rb index 59e87b3f69c..3332e07ec31 100644 --- a/spec/features/boards/sidebar_spec.rb +++ b/spec/features/boards/sidebar_spec.rb @@ -11,8 +11,8 @@ describe 'Issue Boards', feature: true, js: true do let!(:bug) { create(:label, project: project, name: 'Bug') } let!(:regression) { create(:label, project: project, name: 'Regression') } let!(:stretch) { create(:label, project: project, name: 'Stretch') } - let!(:issue1) { create(:labeled_issue, project: project, assignee: user, milestone: milestone, labels: [development]) } - let!(:issue2) { create(:labeled_issue, project: project, labels: [development, stretch]) } + let!(:issue1) { create(:labeled_issue, project: project, assignee: user, milestone: milestone, labels: [development], relative_position: 2) } + let!(:issue2) { create(:labeled_issue, project: project, labels: [development, stretch], relative_position: 1) } let(:board) { create(:board, project: project) } let!(:list) { create(:list, board: board, label: development, position: 0) } let(:card) { first('.board').first('.card') } diff --git a/spec/features/commits_spec.rb b/spec/features/commits_spec.rb index 324ede798fe..0e305c52358 100644 --- a/spec/features/commits_spec.rb +++ b/spec/features/commits_spec.rb @@ -192,7 +192,7 @@ describe 'Commits' do commits = project.repository.commits(branch_name) commits.each do |commit| - expect(page).to have_content("committed #{commit.committed_date}") + expect(page).to have_content("committed #{commit.committed_date.strftime("%b %d, %Y")}") end end end diff --git a/spec/features/copy_as_gfm_spec.rb b/spec/features/copy_as_gfm_spec.rb index fec86128d03..55df7e45f79 100644 --- a/spec/features/copy_as_gfm_spec.rb +++ b/spec/features/copy_as_gfm_spec.rb @@ -2,433 +2,594 @@ require 'spec_helper' describe 'Copy as GFM', feature: true, js: true do include GitlabMarkdownHelper + include RepoHelpers include ActionView::Helpers::JavaScriptHelper before do - @feat = MarkdownFeature.new + login_as :admin + end - # `markdown` helper expects a `@project` variable - @project = @feat.project + describe 'Copying rendered GFM' do + before do + @feat = MarkdownFeature.new - visit namespace_project_issue_path(@project.namespace, @project, @feat.issue) - end + # `markdown` helper expects a `@project` variable + @project = @feat.project - # The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert GitLab Flavored Markdown (GFM) to HTML. - # The handlers defined in app/assets/javascripts/copy_as_gfm.js.es6 consequently convert that same HTML to GFM. - # To make sure these filters and handlers are properly aligned, this spec tests the GFM-to-HTML-to-GFM cycle - # by verifying (`html_to_gfm(gfm_to_html(gfm)) == gfm`) for a number of examples of GFM for every filter, using the `verify` helper. + visit namespace_project_issue_path(@project.namespace, @project, @feat.issue) + end - # These are all in a single `it` for performance reasons. - it 'works', :aggregate_failures do - verify( - 'nesting', + # The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert GitLab Flavored Markdown (GFM) to HTML. + # The handlers defined in app/assets/javascripts/copy_as_gfm.js consequently convert that same HTML to GFM. + # To make sure these filters and handlers are properly aligned, this spec tests the GFM-to-HTML-to-GFM cycle + # by verifying (`html_to_gfm(gfm_to_html(gfm)) == gfm`) for a number of examples of GFM for every filter, using the `verify` helper. - '> 1. [x] **[$`2 + 2`$ {-=-}{+=+} 2^2 ~~:thumbsup:~~](http://google.com)**' - ) + # These are all in a single `it` for performance reasons. + it 'works', :aggregate_failures do + verify( + 'nesting', - verify( - 'a real world example from the gitlab-ce README', + '> 1. [x] **[$`2 + 2`$ {-=-}{+=+} 2^2 ~~:thumbsup:~~](http://google.com)**' + ) - <<-GFM.strip_heredoc - # GitLab + verify( + 'a real world example from the gitlab-ce README', - [![Build status](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/build.svg)](https://gitlab.com/gitlab-org/gitlab-ce/commits/master) - [![CE coverage report](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/coverage.svg?job=coverage)](https://gitlab-org.gitlab.io/gitlab-ce/coverage-ruby) - [![Code Climate](https://codeclimate.com/github/gitlabhq/gitlabhq.svg)](https://codeclimate.com/github/gitlabhq/gitlabhq) - [![Core Infrastructure Initiative Best Practices](https://bestpractices.coreinfrastructure.org/projects/42/badge)](https://bestpractices.coreinfrastructure.org/projects/42) + <<-GFM.strip_heredoc + # GitLab - ## Canonical source + [![Build status](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/build.svg)](https://gitlab.com/gitlab-org/gitlab-ce/commits/master) + [![CE coverage report](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/coverage.svg?job=coverage)](https://gitlab-org.gitlab.io/gitlab-ce/coverage-ruby) + [![Code Climate](https://codeclimate.com/github/gitlabhq/gitlabhq.svg)](https://codeclimate.com/github/gitlabhq/gitlabhq) + [![Core Infrastructure Initiative Best Practices](https://bestpractices.coreinfrastructure.org/projects/42/badge)](https://bestpractices.coreinfrastructure.org/projects/42) - The canonical source of GitLab Community Edition is [hosted on GitLab.com](https://gitlab.com/gitlab-org/gitlab-ce/). + ## Canonical source - ## Open source software to collaborate on code + The canonical source of GitLab Community Edition is [hosted on GitLab.com](https://gitlab.com/gitlab-org/gitlab-ce/). - To see how GitLab looks please see the [features page on our website](https://about.gitlab.com/features/). + ## Open source software to collaborate on code + To see how GitLab looks please see the [features page on our website](https://about.gitlab.com/features/). - - Manage Git repositories with fine grained access controls that keep your code secure - - Perform code reviews and enhance collaboration with merge requests + - Manage Git repositories with fine grained access controls that keep your code secure - - Complete continuous integration (CI) and CD pipelines to builds, test, and deploy your applications + - Perform code reviews and enhance collaboration with merge requests - - Each project can also have an issue tracker, issue board, and a wiki + - Complete continuous integration (CI) and CD pipelines to builds, test, and deploy your applications - - Used by more than 100,000 organizations, GitLab is the most popular solution to manage Git repositories on-premises + - Each project can also have an issue tracker, issue board, and a wiki - - Completely free and open source (MIT Expat license) - GFM - ) + - Used by more than 100,000 organizations, GitLab is the most popular solution to manage Git repositories on-premises - verify( - 'InlineDiffFilter', + - Completely free and open source (MIT Expat license) + GFM + ) - '{-Deleted text-}', - '{+Added text+}' - ) + verify( + 'InlineDiffFilter', - verify( - 'TaskListFilter', + '{-Deleted text-}', + '{+Added text+}' + ) - '- [ ] Unchecked task', - '- [x] Checked task', - '1. [ ] Unchecked numbered task', - '1. [x] Checked numbered task' - ) + verify( + 'TaskListFilter', - verify( - 'ReferenceFilter', + '- [ ] Unchecked task', + '- [x] Checked task', + '1. [ ] Unchecked numbered task', + '1. [x] Checked numbered task' + ) - # issue reference - @feat.issue.to_reference, - # full issue reference - @feat.issue.to_reference(full: true), - # issue URL - namespace_project_issue_url(@project.namespace, @project, @feat.issue), - # issue URL with note anchor - namespace_project_issue_url(@project.namespace, @project, @feat.issue, anchor: 'note_123'), - # issue link - "[Issue](#{namespace_project_issue_url(@project.namespace, @project, @feat.issue)})", - # issue link with note anchor - "[Issue](#{namespace_project_issue_url(@project.namespace, @project, @feat.issue, anchor: 'note_123')})", - ) + verify( + 'ReferenceFilter', - verify( - 'AutolinkFilter', + # issue reference + @feat.issue.to_reference, + # full issue reference + @feat.issue.to_reference(full: true), + # issue URL + namespace_project_issue_url(@project.namespace, @project, @feat.issue), + # issue URL with note anchor + namespace_project_issue_url(@project.namespace, @project, @feat.issue, anchor: 'note_123'), + # issue link + "[Issue](#{namespace_project_issue_url(@project.namespace, @project, @feat.issue)})", + # issue link with note anchor + "[Issue](#{namespace_project_issue_url(@project.namespace, @project, @feat.issue, anchor: 'note_123')})", + ) - 'https://example.com' - ) + verify( + 'AutolinkFilter', - verify( - 'TableOfContentsFilter', + 'https://example.com' + ) - '[[_TOC_]]' - ) + verify( + 'TableOfContentsFilter', - verify( - 'EmojiFilter', + '[[_TOC_]]' + ) - ':thumbsup:' - ) + verify( + 'EmojiFilter', - verify( - 'ImageLinkFilter', - - '![Image](https://example.com/image.png)' - ) + ':thumbsup:' + ) - verify( - 'VideoLinkFilter', + verify( + 'ImageLinkFilter', + + '![Image](https://example.com/image.png)' + ) - '![Video](https://example.com/video.mp4)' - ) + verify( + 'VideoLinkFilter', - verify( - 'MathFilter: math as converted from GFM to HTML', + '![Video](https://example.com/video.mp4)' + ) - '$`c = \pm\sqrt{a^2 + b^2}`$', + verify( + 'MathFilter: math as converted from GFM to HTML', - # math block - <<-GFM.strip_heredoc - ```math - c = \pm\sqrt{a^2 + b^2} - ``` - GFM - ) + '$`c = \pm\sqrt{a^2 + b^2}`$', - aggregate_failures('MathFilter: math as transformed from HTML to KaTeX') do - gfm = '$`c = \pm\sqrt{a^2 + b^2}`$' + # math block + <<-GFM.strip_heredoc + ```math + c = \pm\sqrt{a^2 + b^2} + ``` + GFM + ) - html = <<-HTML.strip_heredoc - <span class="katex"> - <span class="katex-mathml"> - <math> - <semantics> - <mrow> - <mi>c</mi> - <mo>=</mo> - <mo>±</mo> - <msqrt> - <mrow> - <msup> - <mi>a</mi> - <mn>2</mn> - </msup> - <mo>+</mo> - <msup> - <mi>b</mi> - <mn>2</mn> - </msup> - </mrow> - </msqrt> - </mrow> - <annotation encoding="application/x-tex">c = \\pm\\sqrt{a^2 + b^2}</annotation> - </semantics> - </math> - </span> - <span class="katex-html" aria-hidden="true"> - <span class="strut" style="height: 0.913389em;"></span> - <span class="strut bottom" style="height: 1.04em; vertical-align: -0.126611em;"></span> - <span class="base textstyle uncramped"> - <span class="mord mathit">c</span> - <span class="mrel">=</span> - <span class="mord">±</span> - <span class="sqrt mord"><span class="sqrt-sign" style="top: -0.073389em;"> - <span class="style-wrap reset-textstyle textstyle uncramped">√</span> - </span> - <span class="vlist"> - <span class="" style="top: 0em;"> - <span class="fontsize-ensurer reset-size5 size5"> - <span class="" style="font-size: 1em;"></span> - </span> - <span class="mord textstyle cramped"> - <span class="mord"> - <span class="mord mathit">a</span> - <span class="msupsub"> - <span class="vlist"> - <span class="" style="top: -0.289em; margin-right: 0.05em;"> - <span class="fontsize-ensurer reset-size5 size5"> - <span class="" style="font-size: 0em;"></span> - </span> - <span class="reset-textstyle scriptstyle cramped"> - <span class="mord mathrm">2</span> + aggregate_failures('MathFilter: math as transformed from HTML to KaTeX') do + gfm = '$`c = \pm\sqrt{a^2 + b^2}`$' + + html = <<-HTML.strip_heredoc + <span class="katex"> + <span class="katex-mathml"> + <math> + <semantics> + <mrow> + <mi>c</mi> + <mo>=</mo> + <mo>±</mo> + <msqrt> + <mrow> + <msup> + <mi>a</mi> + <mn>2</mn> + </msup> + <mo>+</mo> + <msup> + <mi>b</mi> + <mn>2</mn> + </msup> + </mrow> + </msqrt> + </mrow> + <annotation encoding="application/x-tex">c = \\pm\\sqrt{a^2 + b^2}</annotation> + </semantics> + </math> + </span> + <span class="katex-html" aria-hidden="true"> + <span class="strut" style="height: 0.913389em;"></span> + <span class="strut bottom" style="height: 1.04em; vertical-align: -0.126611em;"></span> + <span class="base textstyle uncramped"> + <span class="mord mathit">c</span> + <span class="mrel">=</span> + <span class="mord">±</span> + <span class="sqrt mord"><span class="sqrt-sign" style="top: -0.073389em;"> + <span class="style-wrap reset-textstyle textstyle uncramped">√</span> + </span> + <span class="vlist"> + <span class="" style="top: 0em;"> + <span class="fontsize-ensurer reset-size5 size5"> + <span class="" style="font-size: 1em;"></span> + </span> + <span class="mord textstyle cramped"> + <span class="mord"> + <span class="mord mathit">a</span> + <span class="msupsub"> + <span class="vlist"> + <span class="" style="top: -0.289em; margin-right: 0.05em;"> + <span class="fontsize-ensurer reset-size5 size5"> + <span class="" style="font-size: 0em;"></span> + </span> + <span class="reset-textstyle scriptstyle cramped"> + <span class="mord mathrm">2</span> + </span> </span> + <span class="baseline-fix"> + <span class="fontsize-ensurer reset-size5 size5"> + <span class="" style="font-size: 0em;"></span> + </span> + </span> </span> - <span class="baseline-fix"> - <span class="fontsize-ensurer reset-size5 size5"> - <span class="" style="font-size: 0em;"></span> - </span> - </span> </span> </span> - </span> - <span class="mbin">+</span> - <span class="mord"> - <span class="mord mathit">b</span> - <span class="msupsub"> - <span class="vlist"> - <span class="" style="top: -0.289em; margin-right: 0.05em;"> - <span class="fontsize-ensurer reset-size5 size5"> - <span class="" style="font-size: 0em;"></span> - </span> - <span class="reset-textstyle scriptstyle cramped"> - <span class="mord mathrm">2</span> + <span class="mbin">+</span> + <span class="mord"> + <span class="mord mathit">b</span> + <span class="msupsub"> + <span class="vlist"> + <span class="" style="top: -0.289em; margin-right: 0.05em;"> + <span class="fontsize-ensurer reset-size5 size5"> + <span class="" style="font-size: 0em;"></span> + </span> + <span class="reset-textstyle scriptstyle cramped"> + <span class="mord mathrm">2</span> + </span> </span> + <span class="baseline-fix"> + <span class="fontsize-ensurer reset-size5 size5"> + <span class="" style="font-size: 0em;"></span> + </span> + </span> </span> - <span class="baseline-fix"> - <span class="fontsize-ensurer reset-size5 size5"> - <span class="" style="font-size: 0em;"></span> - </span> - </span> </span> </span> </span> </span> - </span> - <span class="" style="top: -0.833389em;"> - <span class="fontsize-ensurer reset-size5 size5"> - <span class="" style="font-size: 1em;"></span> + <span class="" style="top: -0.833389em;"> + <span class="fontsize-ensurer reset-size5 size5"> + <span class="" style="font-size: 1em;"></span> + </span> + <span class="reset-textstyle textstyle uncramped sqrt-line"></span> </span> - <span class="reset-textstyle textstyle uncramped sqrt-line"></span> + <span class="baseline-fix"> + <span class="fontsize-ensurer reset-size5 size5"> + <span class="" style="font-size: 1em;"></span> + </span> + </span> </span> - <span class="baseline-fix"> - <span class="fontsize-ensurer reset-size5 size5"> - <span class="" style="font-size: 1em;"></span> - </span> - </span> </span> </span> </span> </span> - </span> - HTML + HTML - output_gfm = html_to_gfm(html) - expect(output_gfm.strip).to eq(gfm.strip) - end + output_gfm = html_to_gfm(html) + expect(output_gfm.strip).to eq(gfm.strip) + end - verify( - 'SanitizationFilter', + verify( + 'SanitizationFilter', - <<-GFM.strip_heredoc - <a name="named-anchor"></a> - - <sub>sub</sub> + <<-GFM.strip_heredoc + <a name="named-anchor"></a> - <dl> - <dt>dt</dt> - <dd>dd</dd> - </dl> + <sub>sub</sub> - <kbd>kbd</kbd> + <dl> + <dt>dt</dt> + <dd>dd</dd> + </dl> - <q>q</q> + <kbd>kbd</kbd> - <samp>samp</samp> + <q>q</q> - <var>var</var> + <samp>samp</samp> - <ruby>ruby</ruby> + <var>var</var> - <rt>rt</rt> + <ruby>ruby</ruby> - <rp>rp</rp> + <rt>rt</rt> - <abbr>abbr</abbr> - GFM - ) + <rp>rp</rp> - verify( - 'SanitizationFilter', + <abbr>abbr</abbr> - <<-GFM.strip_heredoc, - ``` - Plain text - ``` - GFM + <summary>summary</summary> - <<-GFM.strip_heredoc, - ```ruby - def foo - bar - end - ``` - GFM + <details>details</details> + GFM + ) + + verify( + 'SanitizationFilter', + + <<-GFM.strip_heredoc, + ``` + Plain text + ``` + GFM + + <<-GFM.strip_heredoc, + ```ruby + def foo + bar + end + ``` + GFM + + <<-GFM.strip_heredoc + Foo + + This is an example of GFM + + ```js + Code goes here + ``` + GFM + ) + + verify( + 'MarkdownFilter', + + "Line with two spaces at the end \nto insert a linebreak", + + '`code`', + '`` code with ` ticks ``', + + '> Quote', + + # multiline quote + <<-GFM.strip_heredoc, + > Multiline + > Quote + > + > With multiple paragraphs + GFM + + '![Image](https://example.com/image.png)', + + '# Heading with no anchor link', + + '[Link](https://example.com)', + + '- List item', + + # multiline list item + <<-GFM.strip_heredoc, + - Multiline + List item + GFM + + # nested lists + <<-GFM.strip_heredoc, + - Nested + + + - Lists + GFM + + # list with blockquote + <<-GFM.strip_heredoc, + - List + + > Blockquote + GFM + + '1. Numbered list item', + + # multiline numbered list item + <<-GFM.strip_heredoc, + 1. Multiline + Numbered list item + GFM + + # nested numbered list + <<-GFM.strip_heredoc, + 1. Nested - <<-GFM.strip_heredoc - Foo - This is an example of GFM + 1. Numbered lists + GFM - ```js - Code goes here - ``` - GFM - ) + '# Heading', + '## Heading', + '### Heading', + '#### Heading', + '##### Heading', + '###### Heading', - verify( - 'MarkdownFilter', + '**Bold**', - "Line with two spaces at the end \nto insert a linebreak", + '_Italics_', - '`code`', - '`` code with ` ticks ``', + '~~Strikethrough~~', - '> Quote', + '2^2', - # multiline quote - <<-GFM.strip_heredoc, - > Multiline - > Quote - > - > With multiple paragraphs - GFM + '-----', - '![Image](https://example.com/image.png)', + # table + <<-GFM.strip_heredoc, + | Centered | Right | Left | + |:--------:|------:|------| + | Foo | Bar | **Baz** | + | Foo | Bar | **Baz** | + GFM - '# Heading with no anchor link', + # table with empty heading + <<-GFM.strip_heredoc, + | | x | y | + |---|---|---| + | a | 1 | 0 | + | b | 0 | 1 | + GFM + ) + end - '[Link](https://example.com)', + alias_method :gfm_to_html, :markdown - '- List item', + def verify(label, *gfms) + aggregate_failures(label) do + gfms.each do |gfm| + html = gfm_to_html(gfm) + output_gfm = html_to_gfm(html) + expect(output_gfm.strip).to eq(gfm.strip) + end + end + end - # multiline list item - <<-GFM.strip_heredoc, - - Multiline - List item - GFM + # Fake a `current_user` helper + def current_user + @feat.user + end + end - # nested lists - <<-GFM.strip_heredoc, - - Nested + describe 'Copying code' do + let(:project) { create(:project) } + context 'from a diff' do + before do + visit namespace_project_commit_path(project.namespace, project, sample_commit.id) + end - - Lists - GFM + context 'selecting one word of text' do + it 'copies as inline code' do + verify( + '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"] .line .no', - # list with blockquote - <<-GFM.strip_heredoc, - - List + '`RuntimeError`' + ) + end + end - > Blockquote - GFM + context 'selecting one line of text' do + it 'copies as inline code' do + verify( + '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"] .line', - '1. Numbered list item', + '`raise RuntimeError, "System commands must be given as an array of strings"`' + ) + end + end - # multiline numbered list item - <<-GFM.strip_heredoc, - 1. Multiline - Numbered list item - GFM + context 'selecting multiple lines of text' do + it 'copies as a code block' do + verify( + '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_10"]', + + <<-GFM.strip_heredoc, + ```ruby + raise RuntimeError, "System commands must be given as an array of strings" + end + ``` + GFM + ) + end + end + end - # nested numbered list - <<-GFM.strip_heredoc, - 1. Nested + context 'from a blob' do + before do + visit namespace_project_blob_path(project.namespace, project, File.join('master', 'files/ruby/popen.rb')) + end + context 'selecting one word of text' do + it 'copies as inline code' do + verify( + '.line[id="LC9"] .no', - 1. Numbered lists - GFM + '`RuntimeError`' + ) + end + end - '# Heading', - '## Heading', - '### Heading', - '#### Heading', - '##### Heading', - '###### Heading', + context 'selecting one line of text' do + it 'copies as inline code' do + verify( + '.line[id="LC9"]', - '**Bold**', + '`raise RuntimeError, "System commands must be given as an array of strings"`' + ) + end + end - '_Italics_', + context 'selecting multiple lines of text' do + it 'copies as a code block' do + verify( + '.line[id="LC9"], .line[id="LC10"]', + + <<-GFM.strip_heredoc, + ```ruby + raise RuntimeError, "System commands must be given as an array of strings" + end + ``` + GFM + ) + end + end + end - '~~Strikethrough~~', + context 'from a GFM code block' do + before do + visit namespace_project_blob_path(project.namespace, project, File.join('markdown', 'doc/api/users.md')) + end - '2^2', + context 'selecting one word of text' do + it 'copies as inline code' do + verify( + '.line[id="LC27"] .s2', - '-----', + '`"bio"`' + ) + end + end - # table - <<-GFM.strip_heredoc, - | Centered | Right | Left | - |:--------:|------:|------| - | Foo | Bar | **Baz** | - | Foo | Bar | **Baz** | - GFM + context 'selecting one line of text' do + it 'copies as inline code' do + verify( + '.line[id="LC27"]', - # table with empty heading - <<-GFM.strip_heredoc, - | | x | y | - |---|---|---| - | a | 1 | 0 | - | b | 0 | 1 | - GFM - ) + '`"bio": null,`' + ) + end + end + + context 'selecting multiple lines of text' do + it 'copies as a code block with the correct language' do + verify( + '.line[id="LC27"], .line[id="LC28"]', + + <<-GFM.strip_heredoc, + ```json + "bio": null, + "skype": "", + ``` + GFM + ) + end + end + end + + def verify(selector, gfm) + html = html_for_selector(selector) + output_gfm = html_to_gfm(html, 'transformCodeSelection') + expect(output_gfm.strip).to eq(gfm.strip) + end end - alias_method :gfm_to_html, :markdown + def html_for_selector(selector) + js = <<-JS.strip_heredoc + (function(selector) { + var els = document.querySelectorAll(selector); + var htmls = _.map(els, function(el) { return el.outerHTML; }); + return htmls.join("\\n"); + })("#{escape_javascript(selector)}") + JS + page.evaluate_script(js) + end - def html_to_gfm(html) + def html_to_gfm(html, transformer = 'transformGFMSelection') js = <<-JS.strip_heredoc (function(html) { + var transformer = window.gl.CopyAsGFM[#{transformer.inspect}]; + var node = document.createElement('div'); node.innerHTML = html; + + node = transformer(node); + if (!node) return null; + return window.gl.CopyAsGFM.nodeToGFM(node); })("#{escape_javascript(html)}") JS page.evaluate_script(js) end - - def verify(label, *gfms) - aggregate_failures(label) do - gfms.each do |gfm| - html = gfm_to_html(gfm) - output_gfm = html_to_gfm(html) - expect(output_gfm.strip).to eq(gfm.strip) - end - end - end - - # Fake a `current_user` helper - def current_user - @feat.user - end end diff --git a/spec/features/dashboard/activity_spec.rb b/spec/features/dashboard/activity_spec.rb new file mode 100644 index 00000000000..c977f266296 --- /dev/null +++ b/spec/features/dashboard/activity_spec.rb @@ -0,0 +1,11 @@ +require 'spec_helper' + +RSpec.describe 'Dashboard Activity', feature: true do + before do + login_as(create :user) + visit activity_dashboard_path + end + + it_behaves_like "it has an RSS button with current_user's private token" + it_behaves_like "an autodiscoverable RSS feed with current_user's private token" +end diff --git a/spec/features/dashboard/archived_projects_spec.rb b/spec/features/dashboard/archived_projects_spec.rb index 038c1641be9..f33bcbb5318 100644 --- a/spec/features/dashboard/archived_projects_spec.rb +++ b/spec/features/dashboard/archived_projects_spec.rb @@ -25,4 +25,19 @@ RSpec.describe 'Dashboard Archived Project', feature: true do expect(page).to have_link(project.name) expect(page).to have_link(archived_project.name) end + + it 'searchs archived projects', :js do + click_button 'Last updated' + click_link 'Show archived projects' + + expect(page).to have_link(project.name) + expect(page).to have_link(archived_project.name) + + fill_in 'project-filter-form-field', with: archived_project.name + + find('#project-filter-form-field').native.send_keys :return + + expect(page).not_to have_link(project.name) + expect(page).to have_link(archived_project.name) + end end diff --git a/spec/features/dashboard/groups_list_spec.rb b/spec/features/dashboard/groups_list_spec.rb new file mode 100644 index 00000000000..ca04107d33a --- /dev/null +++ b/spec/features/dashboard/groups_list_spec.rb @@ -0,0 +1,47 @@ +require 'spec_helper' + +describe 'Dashboard Groups page', js: true, feature: true do + include WaitForAjax + + let!(:user) { create :user } + let!(:group) { create(:group) } + let!(:nested_group) { create(:group, :nested) } + let!(:another_group) { create(:group) } + + before do + group.add_owner(user) + nested_group.add_owner(user) + + login_as(user) + + visit dashboard_groups_path + end + + it 'shows groups user is member of' do + expect(page).to have_content(group.full_name) + expect(page).to have_content(nested_group.full_name) + expect(page).not_to have_content(another_group.full_name) + end + + it 'filters groups' do + fill_in 'filter_groups', with: group.name + wait_for_ajax + + expect(page).to have_content(group.full_name) + expect(page).not_to have_content(nested_group.full_name) + expect(page).not_to have_content(another_group.full_name) + end + + it 'resets search when user cleans the input' do + fill_in 'filter_groups', with: group.name + wait_for_ajax + + fill_in 'filter_groups', with: "" + wait_for_ajax + + expect(page).to have_content(group.full_name) + expect(page).to have_content(nested_group.full_name) + expect(page).not_to have_content(another_group.full_name) + expect(page.all('.js-groups-list-holder .content-list li').length).to eq 2 + end +end diff --git a/spec/features/dashboard/issues_spec.rb b/spec/features/dashboard/issues_spec.rb index 2db1cf71209..f4420814c3a 100644 --- a/spec/features/dashboard/issues_spec.rb +++ b/spec/features/dashboard/issues_spec.rb @@ -45,4 +45,7 @@ RSpec.describe 'Dashboard Issues', feature: true do expect(page).to have_content(assigned_issue.title) expect(page).to have_content(other_issue.title) end + + it_behaves_like "it has an RSS button with current_user's private token" + it_behaves_like "an autodiscoverable RSS feed with current_user's private token" end diff --git a/spec/features/dashboard/project_member_activity_index_spec.rb b/spec/features/dashboard/project_member_activity_index_spec.rb index ba77093a6d4..49d93db58a9 100644 --- a/spec/features/dashboard/project_member_activity_index_spec.rb +++ b/spec/features/dashboard/project_member_activity_index_spec.rb @@ -12,7 +12,7 @@ feature 'Project member activity', feature: true, js: true do def visit_activities_and_wait_with_event(event_type) Event.create(project: project, author_id: user.id, action: event_type) - visit activity_namespace_project_path(project.namespace.path, project.path) + visit activity_namespace_project_path(project.namespace, project) wait_for_ajax end diff --git a/spec/features/dashboard/projects_spec.rb b/spec/features/dashboard/projects_spec.rb new file mode 100644 index 00000000000..c4e58d14f75 --- /dev/null +++ b/spec/features/dashboard/projects_spec.rb @@ -0,0 +1,32 @@ +require 'spec_helper' + +RSpec.describe 'Dashboard Projects', feature: true do + let(:user) { create(:user) } + let(:project) { create(:project, name: "awesome stuff") } + + before do + project.team << [user, :developer] + login_as user + visit dashboard_projects_path + end + + it 'shows the project the user in a member of in the list' do + visit dashboard_projects_path + expect(page).to have_content('awesome stuff') + end + + describe "with a pipeline" do + let(:pipeline) { create(:ci_pipeline, :success, project: project, sha: project.commit.sha) } + + before do + pipeline + end + + it 'shows that the last pipeline passed' do + visit dashboard_projects_path + expect(page).to have_xpath("//a[@href='#{pipelines_namespace_project_commit_path(project.namespace, project, project.commit)}']") + end + end + + it_behaves_like "an autodiscoverable RSS feed with current_user's private token" +end diff --git a/spec/features/dashboard/user_filters_projects_spec.rb b/spec/features/dashboard/user_filters_projects_spec.rb index c2e0612aef8..34d6257f5fd 100644 --- a/spec/features/dashboard/user_filters_projects_spec.rb +++ b/spec/features/dashboard/user_filters_projects_spec.rb @@ -1,26 +1,45 @@ require 'spec_helper' -describe "Dashboard > User filters projects", feature: true do +describe 'Dashboard > User filters projects', :feature do + let(:user) { create(:user) } + let(:project) { create(:project, name: 'Victorialand', namespace: user.namespace) } + let(:user2) { create(:user) } + let(:project2) { create(:project, name: 'Treasure', namespace: user2.namespace) } + + before do + project.team << [user, :master] + + login_as(user) + end + describe 'filtering personal projects' do before do - user = create(:user) - project = create(:project, name: "Victorialand", namespace: user.namespace) - project.team << [user, :master] - - user2 = create(:user) - project2 = create(:project, name: "Treasure", namespace: user2.namespace) project2.team << [user, :developer] - login_as(user) visit dashboard_projects_path end it 'filters by projects "Owned by me"' do - click_link "Owned by me" + click_link 'Owned by me' expect(page).to have_css('.is-active', text: 'Owned by me') expect(page).to have_content('Victorialand') expect(page).not_to have_content('Treasure') end end + + describe 'filtering starred projects', :js do + before do + user.toggle_star(project) + + visit dashboard_projects_path + end + + it 'returns message when starred projects fitler returns no results' do + fill_in 'project-filter-form-field', with: 'Beta\n' + + expect(page).to have_content('No projects found') + expect(page).not_to have_content('You don\'t have starred projects yet') + end + end end diff --git a/spec/features/dashboard_issues_spec.rb b/spec/features/dashboard_issues_spec.rb index b898f9bc64f..8c61cdebc4b 100644 --- a/spec/features/dashboard_issues_spec.rb +++ b/spec/features/dashboard_issues_spec.rb @@ -48,10 +48,10 @@ describe "Dashboard Issues filtering", feature: true, js: true do it 'updates atom feed link' do visit_issues(milestone_title: '', assignee_id: user.id) - link = find('.nav-controls a', text: 'Subscribe') - params = CGI::parse(URI.parse(link[:href]).query) + link = find('.nav-controls a[title="Subscribe"]') + params = CGI.parse(URI.parse(link[:href]).query) auto_discovery_link = find('link[type="application/atom+xml"]', visible: false) - auto_discovery_params = CGI::parse(URI.parse(auto_discovery_link[:href]).query) + auto_discovery_params = CGI.parse(URI.parse(auto_discovery_link[:href]).query) expect(params).to include('private_token' => [user.private_token]) expect(params).to include('milestone_title' => ['']) diff --git a/spec/features/explore/groups_list_spec.rb b/spec/features/explore/groups_list_spec.rb new file mode 100644 index 00000000000..773ae4b38bc --- /dev/null +++ b/spec/features/explore/groups_list_spec.rb @@ -0,0 +1,46 @@ +require 'spec_helper' + +describe 'Explore Groups page', js: true, feature: true do + include WaitForAjax + + let!(:user) { create :user } + let!(:group) { create(:group) } + let!(:public_group) { create(:group, :public) } + let!(:private_group) { create(:group, :private) } + + before do + group.add_owner(user) + + login_as(user) + + visit explore_groups_path + end + + it 'shows groups user is member of' do + expect(page).to have_content(group.full_name) + expect(page).to have_content(public_group.full_name) + expect(page).not_to have_content(private_group.full_name) + end + + it 'filters groups' do + fill_in 'filter_groups', with: group.name + wait_for_ajax + + expect(page).to have_content(group.full_name) + expect(page).not_to have_content(public_group.full_name) + expect(page).not_to have_content(private_group.full_name) + end + + it 'resets search when user cleans the input' do + fill_in 'filter_groups', with: group.name + wait_for_ajax + + fill_in 'filter_groups', with: "" + wait_for_ajax + + expect(page).to have_content(group.full_name) + expect(page).to have_content(public_group.full_name) + expect(page).not_to have_content(private_group.full_name) + expect(page.all('.js-groups-list-holder .content-list li').length).to eq 2 + end +end diff --git a/spec/features/groups/activity_spec.rb b/spec/features/groups/activity_spec.rb new file mode 100644 index 00000000000..3b481cba424 --- /dev/null +++ b/spec/features/groups/activity_spec.rb @@ -0,0 +1,26 @@ +require 'spec_helper' + +feature 'Group activity page', feature: true do + let(:group) { create(:group) } + let(:path) { activity_group_path(group) } + + context 'when signed in' do + before do + user = create(:group_member, :developer, user: create(:user), group: group ).user + login_as(user) + visit path + end + + it_behaves_like "it has an RSS button with current_user's private token" + it_behaves_like "an autodiscoverable RSS feed with current_user's private token" + end + + context 'when signed out' do + before do + visit path + end + + it_behaves_like "it has an RSS button without a private token" + it_behaves_like "an autodiscoverable RSS feed without a private token" + end +end diff --git a/spec/features/groups/group_name_toggle_spec.rb b/spec/features/groups/group_name_toggle_spec.rb new file mode 100644 index 00000000000..8528718a2f7 --- /dev/null +++ b/spec/features/groups/group_name_toggle_spec.rb @@ -0,0 +1,44 @@ +require 'spec_helper' + +feature 'Group name toggle', feature: true, js: true do + let(:group) { create(:group) } + let(:nested_group_1) { create(:group, parent: group) } + let(:nested_group_2) { create(:group, parent: nested_group_1) } + let(:nested_group_3) { create(:group, parent: nested_group_2) } + + before do + login_as :user + end + + it 'is not present for less than 3 groups' do + visit group_path(group) + expect(page).not_to have_css('.group-name-toggle') + + visit group_path(nested_group_1) + expect(page).not_to have_css('.group-name-toggle') + end + + it 'is present for nested group of 3 or more in the namespace' do + visit group_path(nested_group_2) + expect(page).to have_css('.group-name-toggle') + + visit group_path(nested_group_3) + expect(page).to have_css('.group-name-toggle') + end + + context 'for group with at least 3 groups' do + before do + visit group_path(nested_group_2) + end + + it 'should show the full group namespace when toggled' do + expect(page).not_to have_content(group.name) + expect(page).to have_css('.group-path.hidable', visible: false) + + click_button '...' + + expect(page).to have_content(group.name) + expect(page).to have_css('.group-path.hidable', visible: true) + end + end +end diff --git a/spec/features/groups/issues_spec.rb b/spec/features/groups/issues_spec.rb index 476eca17a9d..1b3747c390b 100644 --- a/spec/features/groups/issues_spec.rb +++ b/spec/features/groups/issues_spec.rb @@ -5,4 +5,22 @@ feature 'Group issues page', feature: true do let(:issuable) { create(:issue, project: project, title: "this is my created issuable")} include_examples 'project features apply to issuables', Issue + + context 'rss feed' do + let(:access_level) { ProjectFeature::ENABLED } + + context 'when signed in' do + let(:user) { user_in_group } + + it_behaves_like "it has an RSS button with current_user's private token" + it_behaves_like "an autodiscoverable RSS feed with current_user's private token" + end + + context 'when signed out' do + let(:user) { nil } + + it_behaves_like "it has an RSS button without a private token" + it_behaves_like "an autodiscoverable RSS feed without a private token" + end + end end diff --git a/spec/features/groups/show_spec.rb b/spec/features/groups/show_spec.rb new file mode 100644 index 00000000000..fb39693e8ca --- /dev/null +++ b/spec/features/groups/show_spec.rb @@ -0,0 +1,24 @@ +require 'spec_helper' + +feature 'Group show page', feature: true do + let(:group) { create(:group) } + let(:path) { group_path(group) } + + context 'when signed in' do + before do + user = create(:group_member, :developer, user: create(:user), group: group ).user + login_as(user) + visit path + end + + it_behaves_like "an autodiscoverable RSS feed with current_user's private token" + end + + context 'when signed out' do + before do + visit path + end + + it_behaves_like "an autodiscoverable RSS feed without a private token" + end +end diff --git a/spec/features/groups_spec.rb b/spec/features/groups_spec.rb index 37b7c20239f..d243f9478bb 100644 --- a/spec/features/groups_spec.rb +++ b/spec/features/groups_spec.rb @@ -43,6 +43,44 @@ feature 'Group', feature: true do expect(page).to have_namespace_error_message end end + + describe 'Mattermost team creation' do + before do + allow(Settings.mattermost).to receive_messages(enabled: mattermost_enabled) + + visit new_group_path + end + + context 'Mattermost enabled' do + let(:mattermost_enabled) { true } + + it 'displays a team creation checkbox' do + expect(page).to have_selector('#group_create_chat_team') + end + + it 'checks the checkbox by default' do + expect(find('#group_create_chat_team')['checked']).to eq(true) + end + + it 'updates the team URL on graph path update', :js do + out_span = find('span[data-bind-out="create_chat_team"]') + + expect(out_span.text).to be_empty + + fill_in('group_path', with: 'test-group') + + expect(out_span.text).to eq('test-group') + end + end + + context 'Mattermost disabled' do + let(:mattermost_enabled) { false } + + it 'doesnt show a team creation checkbox if Mattermost not enabled' do + expect(page).not_to have_selector('#group_create_chat_team') + end + end + end end describe 'create a nested group' do @@ -105,7 +143,7 @@ feature 'Group', feature: true do visit path - expect(page).to have_css('.group-home-desc > p > img') + expect(page).to have_css('.group-home-desc > p > gl-emoji') end it 'sanitizes unwanted tags' do diff --git a/spec/features/help_pages_spec.rb b/spec/features/help_pages_spec.rb index 40a1fced8d8..e0b2404e60a 100644 --- a/spec/features/help_pages_spec.rb +++ b/spec/features/help_pages_spec.rb @@ -33,4 +33,30 @@ describe 'Help Pages', feature: true do it_behaves_like 'help page', prefix: '/gitlab' end end + + context 'in a production environment with version check enabled', js: true do + before do + allow(Rails.env).to receive(:production?) { true } + allow(current_application_settings).to receive(:version_check_enabled) { true } + allow_any_instance_of(VersionCheck).to receive(:url) { '/version-check-url' } + + login_as :user + visit help_path + end + + it 'should display a version check image' do + expect(find('.js-version-status-badge')).to be_visible + end + + it 'should have a src url' do + expect(find('.js-version-status-badge')['src']).to match(/\/version-check-url/) + end + + it 'should hide the version check image if the image request fails' do + # We use '--load-images=no' with poltergeist so we must trigger manually + execute_script("$('.js-version-status-badge').trigger('error');") + + expect(find('.js-version-status-badge', visible: false)).not_to be_visible + end + end end diff --git a/spec/features/issuables/default_sort_order_spec.rb b/spec/features/issuables/default_sort_order_spec.rb index 73553f97d6f..bfe43bff10f 100644 --- a/spec/features/issuables/default_sort_order_spec.rb +++ b/spec/features/issuables/default_sort_order_spec.rb @@ -176,7 +176,7 @@ describe 'Projects > Issuables > Default sort order', feature: true do end def selected_sort_order - find('.pull-right .dropdown button').text.downcase + find('.filter-dropdown-container .dropdown button').text.downcase end def visit_merge_requests_with_state(project, state) diff --git a/spec/features/issuables/issuable_list_spec.rb b/spec/features/issuables/issuable_list_spec.rb index e31bc40adc3..b90bf6268fd 100644 --- a/spec/features/issuables/issuable_list_spec.rb +++ b/spec/features/issuables/issuable_list_spec.rb @@ -30,6 +30,13 @@ describe 'issuable list', feature: true do end end + it "counts merge requests closing issues icons for each issue" do + visit_issuable_list(:issue) + + expect(page).to have_selector('.icon-merge-request-unmerged', count: 1) + expect(first('.icon-merge-request-unmerged').find(:xpath, '..')).to have_content(1) + end + def visit_issuable_list(issuable_type) if issuable_type == :issue visit namespace_project_issues_path(project.namespace, project) @@ -40,11 +47,12 @@ describe 'issuable list', feature: true do def create_issuables(issuable_type) 3.times do - if issuable_type == :issue - issuable = create(:issue, project: project, author: user) - else - issuable = create(:merge_request, title: FFaker::Lorem.sentence, source_project: project, source_branch: FFaker::Name.name) - end + issuable = + if issuable_type == :issue + create(:issue, project: project, author: user) + else + create(:merge_request, title: FFaker::Lorem.sentence, source_project: project, source_branch: FFaker::Name.name) + end 2.times do create(:note_on_issue, noteable: issuable, project: project, note: 'Test note') @@ -53,5 +61,15 @@ describe 'issuable list', feature: true do create(:award_emoji, :downvote, awardable: issuable) create(:award_emoji, :upvote, awardable: issuable) end + + if issuable_type == :issue + issue = Issue.reorder(:iid).first + merge_request = create(:merge_request, + title: FFaker::Lorem.sentence, + source_project: project, + source_branch: FFaker::Name.name) + + MergeRequestsClosingIssues.create!(issue: issue, merge_request: merge_request) + end end end diff --git a/spec/features/issues/award_emoji_spec.rb b/spec/features/issues/award_emoji_spec.rb index 73e43316dc7..16e453bc328 100644 --- a/spec/features/issues/award_emoji_spec.rb +++ b/spec/features/issues/award_emoji_spec.rb @@ -17,22 +17,35 @@ describe 'Awards Emoji', feature: true do login_as(user) end + describe 'visiting an issue with a legacy award emoji that is not valid anymore' do + before do + # The `heart_tip` emoji is not valid anymore so we need to skip validation + issue.award_emoji.build(user: user, name: 'heart_tip').save!(validate: false) + visit namespace_project_issue_path(project.namespace, project, issue) + end + + # Regression test: https://gitlab.com/gitlab-org/gitlab-ce/issues/29529 + it 'does not shows a 500 page' do + expect(page).to have_text(issue.title) + end + end + describe 'Click award emoji from issue#show' do - let!(:note) { create(:note_on_issue, noteable: issue, project: issue.project, note: "Hello world") } + let!(:note) { create(:note_on_issue, noteable: issue, project: issue.project, note: "Hello world") } before do visit namespace_project_issue_path(project.namespace, project, issue) end it 'increments the thumbsdown emoji', js: true do - find('[data-emoji="thumbsdown"]').click + find('[data-name="thumbsdown"]').click wait_for_ajax expect(thumbsdown_emoji).to have_text("1") end context 'click the thumbsup emoji' do it 'increments the thumbsup emoji', js: true do - find('[data-emoji="thumbsup"]').click + find('[data-name="thumbsup"]').click wait_for_ajax expect(thumbsup_emoji).to have_text("1") end @@ -44,7 +57,7 @@ describe 'Awards Emoji', feature: true do context 'click the thumbsdown emoji' do it 'increments the thumbsdown emoji', js: true do - find('[data-emoji="thumbsdown"]').click + find('[data-name="thumbsdown"]').click wait_for_ajax expect(thumbsdown_emoji).to have_text("1") end @@ -67,6 +80,18 @@ describe 'Awards Emoji', feature: true do expect(page).not_to have_selector(emoji_counter) end end + + context 'execute /award slash command' do + it 'toggles the emoji award on noteable', js: true do + execute_slash_command('/award :100:') + + expect(find(noteable_award_counter)).to have_text("1") + + execute_slash_command('/award :100:') + + expect(page).not_to have_selector(noteable_award_counter) + end + end end end @@ -80,6 +105,15 @@ describe 'Awards Emoji', feature: true do end end + def execute_slash_command(cmd) + within('.js-main-target-form') do + fill_in 'note[note]', with: cmd + click_button 'Comment' + end + + wait_for_ajax + end + def thumbsup_emoji page.all(emoji_counter).first end @@ -92,15 +126,19 @@ describe 'Awards Emoji', feature: true do 'span.js-counter' end + def noteable_award_counter + ".awards .active" + end + def toggle_smiley_emoji(status) within('.note') do find('.note-emoji-button').click end unless status - first('[data-emoji="smiley"]').click + first('[data-name="smiley"]').click else - find('[data-emoji="smiley"]').click + find('[data-name="smiley"]').click end wait_for_ajax diff --git a/spec/features/issues/bulk_assignment_labels_spec.rb b/spec/features/issues/bulk_assignment_labels_spec.rb index 832757b24d4..2f59630b4fb 100644 --- a/spec/features/issues/bulk_assignment_labels_spec.rb +++ b/spec/features/issues/bulk_assignment_labels_spec.rb @@ -55,7 +55,7 @@ feature 'Issues > Labels bulk assignment', feature: true do context 'to all issues' do before do check 'check_all_issues' - open_labels_dropdown ['bug', 'feature'] + open_labels_dropdown %w(bug feature) update_issues end @@ -70,7 +70,7 @@ feature 'Issues > Labels bulk assignment', feature: true do context 'to a issue' do before do check "selected_issue_#{issue1.id}" - open_labels_dropdown ['bug', 'feature'] + open_labels_dropdown %w(bug feature) update_issues end @@ -112,7 +112,7 @@ feature 'Issues > Labels bulk assignment', feature: true do visit namespace_project_issues_path(project.namespace, project) check 'check_all_issues' - unmark_labels_in_dropdown ['bug', 'feature'] + unmark_labels_in_dropdown %w(bug feature) update_issues end diff --git a/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb b/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb index 762cab0c0e1..572bca3de21 100644 --- a/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb +++ b/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb @@ -1,76 +1,93 @@ require 'rails_helper' -feature 'Resolving all open discussions in a merge request from an issue', feature: true do +feature 'Resolving all open discussions in a merge request from an issue', feature: true, js: true do let(:user) { create(:user) } - let(:project) { create(:project, only_allow_merge_if_all_discussions_are_resolved: true) } + let(:project) { create(:project) } let(:merge_request) { create(:merge_request, source_project: project) } let!(:discussion) { Discussion.for_diff_notes([create(:diff_note_on_merge_request, noteable: merge_request, project: project)]).first } - before do - project.team << [user, :master] - login_as user - end - - context 'with the internal tracker disabled' do + describe 'as a user with access to the project' do before do - project.project_feature.update_attribute(:issues_access_level, ProjectFeature::DISABLED) + project.team << [user, :master] + login_as user visit namespace_project_merge_request_path(project.namespace, project, merge_request) end - it 'does not show a link to create a new issue' do - expect(page).not_to have_link 'open an issue to resolve them later' - end - end - - context 'merge request has discussions that need to be resolved' do - before do - visit namespace_project_merge_request_path(project.namespace, project, merge_request) + it 'shows a button to resolve all discussions by creating a new issue' do + within('li#resolve-count-app') do + expect(page).to have_link "Resolve all discussions in new issue", href: new_namespace_project_issue_path(project.namespace, project, merge_request_to_resolve_discussions_of: merge_request.iid) + end end - it 'shows a warning that the merge request contains unresolved discussions' do - expect(page).to have_content 'This merge request has unresolved discussions' - end + context 'resolving the discussion' do + before do + click_button 'Resolve discussion' + end - it 'has a link to resolve all discussions by creating an issue' do - page.within '.mr-widget-body' do - expect(page).to have_link 'open an issue to resolve them later', href: new_namespace_project_issue_path(project.namespace, project, merge_request_for_resolving_discussions: merge_request.iid) + it 'hides the link for creating a new issue' do + expect(page).not_to have_link "Resolve all discussions in new issue", href: new_namespace_project_issue_path(project.namespace, project, merge_request_to_resolve_discussions_of: merge_request.iid) end end context 'creating an issue for discussions' do before do - page.click_link 'open an issue to resolve them later', href: new_namespace_project_issue_path(project.namespace, project, merge_request_for_resolving_discussions: merge_request.iid) + click_link "Resolve all discussions in new issue", href: new_namespace_project_issue_path(project.namespace, project, merge_request_to_resolve_discussions_of: merge_request.iid) end - it 'shows an issue with the title filled in' do - title_field = page.find_field('issue[title]') + it_behaves_like 'creating an issue for a discussion' + end - expect(title_field.value).to include(merge_request.title) + context 'for a project where all discussions need to be resolved before merging' do + before do + project.update_attribute(:only_allow_merge_if_all_discussions_are_resolved, true) end - it 'has a mention of the discussion in the description' do - description_field = page.find_field('issue[description]') + context 'with the internal tracker disabled' do + before do + project.project_feature.update_attribute(:issues_access_level, ProjectFeature::DISABLED) + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end - expect(description_field.value).to include(discussion.first_note.note) + it 'does not show a link to create a new issue' do + expect(page).not_to have_link 'open an issue to resolve them later' + end end - it 'has a hidden field for the merge request' do - merge_request_field = find('#merge_request_for_resolving_discussions', visible: false) - - expect(merge_request_field.value).to eq(merge_request.iid.to_s) - end + context 'merge request has discussions that need to be resolved' do + before do + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end - it 'can create a new issue for the project' do - expect { click_button 'Submit issue' }.to change { project.issues.reload.size }.by(1) - end + it 'shows a warning that the merge request contains unresolved discussions' do + expect(page).to have_content 'This merge request has unresolved discussions' + end - it 'resolves the discussion in the merge request' do - click_button 'Submit issue' + it 'has a link to resolve all discussions by creating an issue' do + page.within '.mr-widget-body' do + expect(page).to have_link 'open an issue to resolve them later', href: new_namespace_project_issue_path(project.namespace, project, merge_request_to_resolve_discussions_of: merge_request.iid) + end + end - discussion.first_note.reload + context 'creating an issue for discussions' do + before do + page.click_link 'open an issue to resolve them later', href: new_namespace_project_issue_path(project.namespace, project, merge_request_to_resolve_discussions_of: merge_request.iid) + end - expect(discussion.resolved?).to eq(true) + it_behaves_like 'creating an issue for a discussion' + end end end end + + describe 'as a reporter' do + before do + project.team << [user, :reporter] + login_as user + visit new_namespace_project_issue_path(project.namespace, project, merge_request_to_resolve_discussions_of: merge_request.iid) + end + + it 'Shows a notice to ask someone else to resolve the discussions' do + expect(page).to have_content("The discussions at #{merge_request.to_reference} will stay unresolved. Ask someone with permission to resolve them.") + end + end end diff --git a/spec/features/issues/create_issue_for_single_discussion_in_merge_request.rb b/spec/features/issues/create_issue_for_single_discussion_in_merge_request.rb new file mode 100644 index 00000000000..88e2cc60d79 --- /dev/null +++ b/spec/features/issues/create_issue_for_single_discussion_in_merge_request.rb @@ -0,0 +1,81 @@ +require 'rails_helper' + +feature 'Resolve an open discussion in a merge request by creating an issue', feature: true do + let(:user) { create(:user) } + let(:project) { create(:project, only_allow_merge_if_all_discussions_are_resolved: true) } + let(:merge_request) { create(:merge_request, source_project: project) } + let!(:discussion) { Discussion.for_diff_notes([create(:diff_note_on_merge_request, noteable: merge_request, project: project)]).first } + + describe 'As a user with access to the project' do + before do + project.team << [user, :master] + login_as user + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + context 'with the internal tracker disabled' do + before do + project.project_feature.update_attribute(:issues_access_level, ProjectFeature::DISABLED) + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + it 'does not show a link to create a new issue' do + expect(page).not_to have_link 'Resolve this discussion in a new issue' + end + end + + context 'resolving the discussion', js: true do + before do + click_button 'Resolve discussion' + end + + it 'hides the link for creating a new issue' do + expect(page).not_to have_link 'Resolve this discussion in a new issue' + end + + it 'shows the link for creating a new issue when unresolving a discussion' do + page.within '.diff-content' do + click_button 'Unresolve discussion' + end + + expect(page).to have_link 'Resolve this discussion in a new issue' + end + end + + it 'has a link to create a new issue for a discussion' do + new_issue_link = new_namespace_project_issue_path(project.namespace, project, discussion_to_resolve: discussion.id, merge_request_to_resolve_discussions_of: merge_request.iid) + + expect(page).to have_link 'Resolve this discussion in a new issue', href: new_issue_link + end + + context 'creating the issue' do + before do + click_link 'Resolve this discussion in a new issue', href: new_namespace_project_issue_path(project.namespace, project, discussion_to_resolve: discussion.id, merge_request_to_resolve_discussions_of: merge_request.iid) + end + + it 'has a hidden field for the discussion' do + discussion_field = find('#discussion_to_resolve', visible: false) + + expect(discussion_field.value).to eq(discussion.id.to_s) + end + + it_behaves_like 'creating an issue for a discussion' + end + end + + describe 'as a reporter' do + before do + project.team << [user, :reporter] + login_as user + visit new_namespace_project_issue_path(project.namespace, project, + merge_request_to_resolve_discussions_of: merge_request.iid, + discussion_to_resolve: discussion.id) + end + + it 'Shows a notice to ask someone else to resolve the discussions' do + expect(page).to have_content("The discussion at #{merge_request.to_reference}"\ + "(discussion #{discussion.first_note.id}) will stay unresolved."\ + "Ask someone with permission to resolve it.") + end + end +end diff --git a/spec/features/issues/filtered_search/dropdown_assignee_spec.rb b/spec/features/issues/filtered_search/dropdown_assignee_spec.rb index 93763f092fb..4dcc56a97d1 100644 --- a/spec/features/issues/filtered_search/dropdown_assignee_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_assignee_spec.rb @@ -1,6 +1,7 @@ require 'rails_helper' -describe 'Dropdown assignee', js: true, feature: true do +describe 'Dropdown assignee', :feature, :js do + include FilteredSearchHelpers include WaitForAjax let!(:project) { create(:empty_project) } @@ -9,17 +10,10 @@ describe 'Dropdown assignee', js: true, feature: true do let!(:user_jacob) { create(:user, name: 'Jacob', username: 'otter32') } let(:filtered_search) { find('.filtered-search') } let(:js_dropdown_assignee) { '#js-dropdown-assignee' } - - def send_keys_to_filtered_search(input) - input.split("").each do |i| - filtered_search.send_keys(i) - sleep 5 - wait_for_ajax - end - end + let(:filter_dropdown) { find("#{js_dropdown_assignee} .filter-dropdown") } def dropdown_assignee_size - page.all('#js-dropdown-assignee .filter-dropdown .filter-dropdown-item').size + filter_dropdown.all('.filter-dropdown-item').size end def click_assignee(text) @@ -56,63 +50,80 @@ describe 'Dropdown assignee', js: true, feature: true do end it 'should hide loading indicator when loaded' do - send_keys_to_filtered_search('assignee:') + filtered_search.set('assignee:') - expect(page).not_to have_css('#js-dropdown-assignee .filter-dropdown-loading') + expect(find(js_dropdown_assignee)).to have_css('.filter-dropdown-loading') + expect(find(js_dropdown_assignee)).not_to have_css('.filter-dropdown-loading') end it 'should load all the assignees when opened' do - send_keys_to_filtered_search('assignee:') + filtered_search.set('assignee:') expect(dropdown_assignee_size).to eq(3) end it 'shows current user at top of dropdown' do - send_keys_to_filtered_search('assignee:') + filtered_search.set('assignee:') - expect(first('#js-dropdown-assignee .filter-dropdown li')).to have_content(user.name) + expect(filter_dropdown.first('.filter-dropdown-item')).to have_content(user.name) end end describe 'filtering' do before do - send_keys_to_filtered_search('assignee:') + filtered_search.set('assignee:') + + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user_john.name) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user_jacob.name) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user.name) end it 'filters by name' do - send_keys_to_filtered_search('j') + filtered_search.send_keys('j') - expect(dropdown_assignee_size).to eq(2) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user_john.name) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user_jacob.name) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_no_content(user.name) end it 'filters by case insensitive name' do - send_keys_to_filtered_search('J') + filtered_search.send_keys('J') - expect(dropdown_assignee_size).to eq(2) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user_john.name) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user_jacob.name) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_no_content(user.name) end it 'filters by username with symbol' do - send_keys_to_filtered_search('@ot') + filtered_search.send_keys('@ot') - expect(dropdown_assignee_size).to eq(2) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user_jacob.name) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user.name) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_no_content(user_john.name) end it 'filters by case insensitive username with symbol' do - send_keys_to_filtered_search('@OT') + filtered_search.send_keys('@OT') - expect(dropdown_assignee_size).to eq(2) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user_jacob.name) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user.name) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_no_content(user_john.name) end it 'filters by username without symbol' do - send_keys_to_filtered_search('ot') + filtered_search.send_keys('ot') - expect(dropdown_assignee_size).to eq(2) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user_jacob.name) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user.name) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_no_content(user_john.name) end it 'filters by case insensitive username without symbol' do - send_keys_to_filtered_search('OT') + filtered_search.send_keys('OT') - expect(dropdown_assignee_size).to eq(2) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user_jacob.name) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_content(user.name) + expect(find("#{js_dropdown_assignee} .filter-dropdown")).to have_no_content(user_john.name) end end @@ -125,22 +136,25 @@ describe 'Dropdown assignee', js: true, feature: true do click_assignee(user_jacob.name) expect(page).to have_css(js_dropdown_assignee, visible: false) - expect(filtered_search.value).to eq("assignee:@#{user_jacob.username} ") + expect_tokens([{ name: 'assignee', value: "@#{user_jacob.username}" }]) + expect_filtered_search_input_empty end it 'fills in the assignee username when the assignee has been filtered' do - send_keys_to_filtered_search('roo') + filtered_search.send_keys('roo') click_assignee(user.name) expect(page).to have_css(js_dropdown_assignee, visible: false) - expect(filtered_search.value).to eq("assignee:@#{user.username} ") + expect_tokens([{ name: 'assignee', value: "@#{user.username}" }]) + expect_filtered_search_input_empty end it 'selects `no assignee`' do find('#js-dropdown-assignee .filter-dropdown-item', text: 'No Assignee').click expect(page).to have_css(js_dropdown_assignee, visible: false) - expect(filtered_search.value).to eq("assignee:none ") + expect_tokens([{ name: 'assignee', value: 'none' }]) + expect_filtered_search_input_empty end end @@ -173,7 +187,7 @@ describe 'Dropdown assignee', js: true, feature: true do describe 'caching requests' do it 'caches requests after the first load' do filtered_search.set('assignee') - send_keys_to_filtered_search(':') + filtered_search.send_keys(':') initial_size = dropdown_assignee_size expect(initial_size).to be > 0 @@ -182,7 +196,7 @@ describe 'Dropdown assignee', js: true, feature: true do project.team << [new_user, :master] find('.filtered-search-input-container .clear-search').click filtered_search.set('assignee') - send_keys_to_filtered_search(':') + filtered_search.send_keys(':') expect(dropdown_assignee_size).to eq(initial_size) end diff --git a/spec/features/issues/filtered_search/dropdown_author_spec.rb b/spec/features/issues/filtered_search/dropdown_author_spec.rb index 59e302f0e2d..1772a120045 100644 --- a/spec/features/issues/filtered_search/dropdown_author_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_author_spec.rb @@ -1,6 +1,7 @@ require 'rails_helper' describe 'Dropdown author', js: true, feature: true do + include FilteredSearchHelpers include WaitForAjax let!(:project) { create(:empty_project) } @@ -13,9 +14,10 @@ describe 'Dropdown author', js: true, feature: true do def send_keys_to_filtered_search(input) input.split("").each do |i| filtered_search.send_keys(i) - sleep 5 - wait_for_ajax end + + sleep 0.5 + wait_for_ajax end def dropdown_author_size @@ -121,14 +123,16 @@ describe 'Dropdown author', js: true, feature: true do click_author(user_jacob.name) expect(page).to have_css(js_dropdown_author, visible: false) - expect(filtered_search.value).to eq("author:@#{user_jacob.username} ") + expect_tokens([{ name: 'author', value: "@#{user_jacob.username}" }]) + expect_filtered_search_input_empty end it 'fills in the author username when the author has been filtered' do click_author(user.name) expect(page).to have_css(js_dropdown_author, visible: false) - expect(filtered_search.value).to eq("author:@#{user.username} ") + expect_tokens([{ name: 'author', value: "@#{user.username}" }]) + expect_filtered_search_input_empty end end diff --git a/spec/features/issues/filtered_search/dropdown_hint_spec.rb b/spec/features/issues/filtered_search/dropdown_hint_spec.rb index 04dd54ab459..01b657bcada 100644 --- a/spec/features/issues/filtered_search/dropdown_hint_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_hint_spec.rb @@ -1,6 +1,7 @@ require 'rails_helper' describe 'Dropdown hint', js: true, feature: true do + include FilteredSearchHelpers include WaitForAjax let!(:project) { create(:empty_project) } @@ -66,7 +67,8 @@ describe 'Dropdown hint', js: true, feature: true do expect(page).to have_css(js_dropdown_hint, visible: false) expect(page).to have_css('#js-dropdown-author', visible: true) - expect(filtered_search.value).to eq('author:') + expect_tokens([{ name: 'author' }]) + expect_filtered_search_input_empty end it 'opens the assignee dropdown when you click on assignee' do @@ -74,7 +76,8 @@ describe 'Dropdown hint', js: true, feature: true do expect(page).to have_css(js_dropdown_hint, visible: false) expect(page).to have_css('#js-dropdown-assignee', visible: true) - expect(filtered_search.value).to eq('assignee:') + expect_tokens([{ name: 'assignee' }]) + expect_filtered_search_input_empty end it 'opens the milestone dropdown when you click on milestone' do @@ -82,7 +85,8 @@ describe 'Dropdown hint', js: true, feature: true do expect(page).to have_css(js_dropdown_hint, visible: false) expect(page).to have_css('#js-dropdown-milestone', visible: true) - expect(filtered_search.value).to eq('milestone:') + expect_tokens([{ name: 'milestone' }]) + expect_filtered_search_input_empty end it 'opens the label dropdown when you click on label' do @@ -90,7 +94,8 @@ describe 'Dropdown hint', js: true, feature: true do expect(page).to have_css(js_dropdown_hint, visible: false) expect(page).to have_css('#js-dropdown-label', visible: true) - expect(filtered_search.value).to eq('label:') + expect_tokens([{ name: 'label' }]) + expect_filtered_search_input_empty end end @@ -101,7 +106,8 @@ describe 'Dropdown hint', js: true, feature: true do expect(page).to have_css(js_dropdown_hint, visible: false) expect(page).to have_css('#js-dropdown-author', visible: true) - expect(filtered_search.value).to eq('author:') + expect_tokens([{ name: 'author' }]) + expect_filtered_search_input_empty end it 'opens the assignee dropdown when you click on assignee' do @@ -110,7 +116,8 @@ describe 'Dropdown hint', js: true, feature: true do expect(page).to have_css(js_dropdown_hint, visible: false) expect(page).to have_css('#js-dropdown-assignee', visible: true) - expect(filtered_search.value).to eq('assignee:') + expect_tokens([{ name: 'assignee' }]) + expect_filtered_search_input_empty end it 'opens the milestone dropdown when you click on milestone' do @@ -119,7 +126,8 @@ describe 'Dropdown hint', js: true, feature: true do expect(page).to have_css(js_dropdown_hint, visible: false) expect(page).to have_css('#js-dropdown-milestone', visible: true) - expect(filtered_search.value).to eq('milestone:') + expect_tokens([{ name: 'milestone' }]) + expect_filtered_search_input_empty end it 'opens the label dropdown when you click on label' do @@ -128,7 +136,46 @@ describe 'Dropdown hint', js: true, feature: true do expect(page).to have_css(js_dropdown_hint, visible: false) expect(page).to have_css('#js-dropdown-label', visible: true) - expect(filtered_search.value).to eq('label:') + expect_tokens([{ name: 'label' }]) + expect_filtered_search_input_empty + end + end + + describe 'reselecting from dropdown' do + it 'reuses existing author text' do + filtered_search.send_keys('author:') + filtered_search.send_keys(:backspace) + click_hint('author') + + expect_tokens([{ name: 'author' }]) + expect_filtered_search_input_empty + end + + it 'reuses existing assignee text' do + filtered_search.send_keys('assignee:') + filtered_search.send_keys(:backspace) + click_hint('assignee') + + expect_tokens([{ name: 'assignee' }]) + expect_filtered_search_input_empty + end + + it 'reuses existing milestone text' do + filtered_search.send_keys('milestone:') + filtered_search.send_keys(:backspace) + click_hint('milestone') + + expect_tokens([{ name: 'milestone' }]) + expect_filtered_search_input_empty + end + + it 'reuses existing label text' do + filtered_search.send_keys('label:') + filtered_search.send_keys(:backspace) + click_hint('label') + + expect_tokens([{ name: 'label' }]) + expect_filtered_search_input_empty end end end diff --git a/spec/features/issues/filtered_search/dropdown_label_spec.rb b/spec/features/issues/filtered_search/dropdown_label_spec.rb index ab3b868fd3a..b192064b693 100644 --- a/spec/features/issues/filtered_search/dropdown_label_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_label_spec.rb @@ -51,7 +51,8 @@ describe 'Dropdown label', js: true, feature: true do filtered_search.native.send_keys(:down, :down, :enter) - expect(filtered_search.value).to eq("label:~#{bug_label.title} ") + expect_tokens([{ name: 'label', value: "~#{bug_label.title}" }]) + expect_filtered_search_input_empty end end @@ -92,7 +93,7 @@ describe 'Dropdown label', js: true, feature: true do end it 'filters by case-insensitive name with or without symbol' do - search_for_label('b') + filtered_search.send_keys('b') expect(filter_dropdown.find('.filter-dropdown-item', text: bug_label.title)).to be_visible expect(filter_dropdown.find('.filter-dropdown-item', text: uppercase_label.title)).to be_visible @@ -101,7 +102,7 @@ describe 'Dropdown label', js: true, feature: true do clear_search_field init_label_search - search_for_label('~bu') + filtered_search.send_keys('~bu') expect(filter_dropdown.find('.filter-dropdown-item', text: bug_label.title)).to be_visible expect(filter_dropdown.find('.filter-dropdown-item', text: uppercase_label.title)).to be_visible @@ -180,7 +181,8 @@ describe 'Dropdown label', js: true, feature: true do click_label(bug_label.title) expect(page).not_to have_css(js_dropdown_label) - expect(filtered_search.value).to eq("label:~#{bug_label.title} ") + expect_tokens([{ name: 'label', value: "~#{bug_label.title}" }]) + expect_filtered_search_input_empty end it 'fills in the label name when the label is partially filled' do @@ -188,49 +190,56 @@ describe 'Dropdown label', js: true, feature: true do click_label(bug_label.title) expect(page).not_to have_css(js_dropdown_label) - expect(filtered_search.value).to eq("label:~#{bug_label.title} ") + expect_tokens([{ name: 'label', value: "~#{bug_label.title}" }]) + expect_filtered_search_input_empty end it 'fills in the label name that contains multiple words' do click_label(two_words_label.title) expect(page).not_to have_css(js_dropdown_label) - expect(filtered_search.value).to eq("label:~\"#{two_words_label.title}\" ") + expect_tokens([{ name: 'label', value: "\"#{two_words_label.title}\"" }]) + expect_filtered_search_input_empty end it 'fills in the label name that contains multiple words and is very long' do click_label(long_label.title) expect(page).not_to have_css(js_dropdown_label) - expect(filtered_search.value).to eq("label:~\"#{long_label.title}\" ") + expect_tokens([{ name: 'label', value: "\"#{long_label.title}\"" }]) + expect_filtered_search_input_empty end it 'fills in the label name that contains double quotes' do click_label(wont_fix_label.title) expect(page).not_to have_css(js_dropdown_label) - expect(filtered_search.value).to eq("label:~'#{wont_fix_label.title}' ") + expect_tokens([{ name: 'label', value: "~'#{wont_fix_label.title}'" }]) + expect_filtered_search_input_empty end it 'fills in the label name with the correct capitalization' do click_label(uppercase_label.title) expect(page).not_to have_css(js_dropdown_label) - expect(filtered_search.value).to eq("label:~#{uppercase_label.title} ") + expect_tokens([{ name: 'label', value: "~#{uppercase_label.title}" }]) + expect_filtered_search_input_empty end it 'fills in the label name with special characters' do click_label(special_label.title) expect(page).not_to have_css(js_dropdown_label) - expect(filtered_search.value).to eq("label:~#{special_label.title} ") + expect_tokens([{ name: 'label', value: "~#{special_label.title}" }]) + expect_filtered_search_input_empty end it 'selects `no label`' do find("#{js_dropdown_label} .filter-dropdown-item", text: 'No Label').click expect(page).not_to have_css(js_dropdown_label) - expect(filtered_search.value).to eq("label:none ") + expect_tokens([{ name: 'label', value: 'none' }]) + expect_filtered_search_input_empty end end diff --git a/spec/features/issues/filtered_search/dropdown_milestone_spec.rb b/spec/features/issues/filtered_search/dropdown_milestone_spec.rb index 0ce16715b86..ce96a420699 100644 --- a/spec/features/issues/filtered_search/dropdown_milestone_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_milestone_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' -describe 'Dropdown milestone', js: true, feature: true do - include WaitForAjax +describe 'Dropdown milestone', :feature, :js do + include FilteredSearchHelpers let!(:project) { create(:empty_project) } let!(:user) { create(:user) } @@ -14,18 +14,10 @@ describe 'Dropdown milestone', js: true, feature: true do let(:filtered_search) { find('.filtered-search') } let(:js_dropdown_milestone) { '#js-dropdown-milestone' } - - def send_keys_to_filtered_search(input) - input.split("").each do |i| - filtered_search.send_keys(i) - sleep 3 - wait_for_ajax - sleep 3 - end - end + let(:filter_dropdown) { find("#{js_dropdown_milestone} .filter-dropdown") } def dropdown_milestone_size - page.all('#js-dropdown-milestone .filter-dropdown .filter-dropdown-item').size + filter_dropdown.all('.filter-dropdown-item').size end def click_milestone(text) @@ -64,13 +56,14 @@ describe 'Dropdown milestone', js: true, feature: true do end it 'should hide loading indicator when loaded' do - send_keys_to_filtered_search('milestone:') + filtered_search.set('milestone:') - expect(page).not_to have_css('#js-dropdown-milestone .filter-dropdown-loading') + expect(find(js_dropdown_milestone)).to have_css('.filter-dropdown-loading') + expect(find(js_dropdown_milestone)).not_to have_css('.filter-dropdown-loading') end it 'should load all the milestones when opened' do - send_keys_to_filtered_search('milestone:') + filtered_search.set('milestone:') expect(dropdown_milestone_size).to be > 0 end @@ -78,41 +71,48 @@ describe 'Dropdown milestone', js: true, feature: true do describe 'filtering' do before do - filtered_search.set('milestone') + filtered_search.set('milestone:') + + expect(find("#{js_dropdown_milestone} .filter-dropdown")).to have_content(milestone.title) + expect(find("#{js_dropdown_milestone} .filter-dropdown")).to have_content(uppercase_milestone.title) + expect(find("#{js_dropdown_milestone} .filter-dropdown")).to have_content(two_words_milestone.title) + expect(find("#{js_dropdown_milestone} .filter-dropdown")).to have_content(wont_fix_milestone.title) + expect(find("#{js_dropdown_milestone} .filter-dropdown")).to have_content(special_milestone.title) + expect(find("#{js_dropdown_milestone} .filter-dropdown")).to have_content(long_milestone.title) end it 'filters by name' do - send_keys_to_filtered_search(':v1') + filtered_search.send_keys('v1') expect(dropdown_milestone_size).to eq(1) end it 'filters by case insensitive name' do - send_keys_to_filtered_search(':V1') + filtered_search.send_keys('V1') expect(dropdown_milestone_size).to eq(1) end it 'filters by name with symbol' do - send_keys_to_filtered_search(':%v1') + filtered_search.send_keys('%v1') expect(dropdown_milestone_size).to eq(1) end it 'filters by case insensitive name with symbol' do - send_keys_to_filtered_search(':%V1') + filtered_search.send_keys('%V1') expect(dropdown_milestone_size).to eq(1) end it 'filters by special characters' do - send_keys_to_filtered_search(':(+') + filtered_search.send_keys('(+') expect(dropdown_milestone_size).to eq(1) end it 'filters by special characters with symbol' do - send_keys_to_filtered_search(':%(+') + filtered_search.send_keys('%(+') expect(dropdown_milestone_size).to eq(1) end @@ -121,70 +121,94 @@ describe 'Dropdown milestone', js: true, feature: true do describe 'selecting from dropdown' do before do filtered_search.set('milestone:') + + expect(find("#{js_dropdown_milestone} .filter-dropdown")).to have_content(milestone.title) + expect(find("#{js_dropdown_milestone} .filter-dropdown")).to have_content(uppercase_milestone.title) + expect(find("#{js_dropdown_milestone} .filter-dropdown")).to have_content(two_words_milestone.title) + expect(find("#{js_dropdown_milestone} .filter-dropdown")).to have_content(wont_fix_milestone.title) + expect(find("#{js_dropdown_milestone} .filter-dropdown")).to have_content(special_milestone.title) + expect(find("#{js_dropdown_milestone} .filter-dropdown")).to have_content(long_milestone.title) end it 'fills in the milestone name when the milestone has not been filled' do click_milestone(milestone.title) expect(page).to have_css(js_dropdown_milestone, visible: false) - expect(filtered_search.value).to eq("milestone:%#{milestone.title} ") + expect_tokens([{ name: 'milestone', value: "%#{milestone.title}" }]) + expect_filtered_search_input_empty end it 'fills in the milestone name when the milestone is partially filled' do - send_keys_to_filtered_search('v') + filtered_search.send_keys('v') click_milestone(milestone.title) expect(page).to have_css(js_dropdown_milestone, visible: false) - expect(filtered_search.value).to eq("milestone:%#{milestone.title} ") + expect_tokens([{ name: 'milestone', value: "%#{milestone.title}" }]) + expect_filtered_search_input_empty end it 'fills in the milestone name that contains multiple words' do click_milestone(two_words_milestone.title) expect(page).to have_css(js_dropdown_milestone, visible: false) - expect(filtered_search.value).to eq("milestone:%\"#{two_words_milestone.title}\" ") + expect_tokens([{ name: 'milestone', value: "%\"#{two_words_milestone.title}\"" }]) + expect_filtered_search_input_empty end it 'fills in the milestone name that contains multiple words and is very long' do click_milestone(long_milestone.title) expect(page).to have_css(js_dropdown_milestone, visible: false) - expect(filtered_search.value).to eq("milestone:%\"#{long_milestone.title}\" ") + expect_tokens([{ name: 'milestone', value: "%\"#{long_milestone.title}\"" }]) + expect_filtered_search_input_empty end it 'fills in the milestone name that contains double quotes' do click_milestone(wont_fix_milestone.title) expect(page).to have_css(js_dropdown_milestone, visible: false) - expect(filtered_search.value).to eq("milestone:%'#{wont_fix_milestone.title}' ") + expect_tokens([{ name: 'milestone', value: "%'#{wont_fix_milestone.title}'" }]) + expect_filtered_search_input_empty end it 'fills in the milestone name with the correct capitalization' do click_milestone(uppercase_milestone.title) expect(page).to have_css(js_dropdown_milestone, visible: false) - expect(filtered_search.value).to eq("milestone:%#{uppercase_milestone.title} ") + expect_tokens([{ name: 'milestone', value: "%#{uppercase_milestone.title}" }]) + expect_filtered_search_input_empty end it 'fills in the milestone name with special characters' do click_milestone(special_milestone.title) expect(page).to have_css(js_dropdown_milestone, visible: false) - expect(filtered_search.value).to eq("milestone:%#{special_milestone.title} ") + expect_tokens([{ name: 'milestone', value: "%#{special_milestone.title}" }]) + expect_filtered_search_input_empty end it 'selects `no milestone`' do click_static_milestone('No Milestone') expect(page).to have_css(js_dropdown_milestone, visible: false) - expect(filtered_search.value).to eq("milestone:none ") + expect_tokens([{ name: 'milestone', value: 'none' }]) + expect_filtered_search_input_empty end it 'selects `upcoming milestone`' do click_static_milestone('Upcoming') expect(page).to have_css(js_dropdown_milestone, visible: false) - expect(filtered_search.value).to eq("milestone:upcoming ") + expect_tokens([{ name: 'milestone', value: 'upcoming' }]) + expect_filtered_search_input_empty + end + + it 'selects `started milestones`' do + click_static_milestone('Started') + + expect(page).to have_css(js_dropdown_milestone, visible: false) + expect_tokens([{ name: 'milestone', value: 'started' }]) + expect_filtered_search_input_empty end end @@ -222,16 +246,14 @@ describe 'Dropdown milestone', js: true, feature: true do describe 'caching requests' do it 'caches requests after the first load' do - filtered_search.set('milestone') - send_keys_to_filtered_search(':') + filtered_search.set('milestone:') initial_size = dropdown_milestone_size expect(initial_size).to be > 0 create(:milestone, project: project) find('.filtered-search-input-container .clear-search').click - filtered_search.set('milestone') - send_keys_to_filtered_search(':') + filtered_search.set('milestone:') expect(dropdown_milestone_size).to eq(initial_size) end diff --git a/spec/features/issues/filtered_search/filter_issues_spec.rb b/spec/features/issues/filtered_search/filter_issues_spec.rb index 0420e64d42c..f463312bf57 100644 --- a/spec/features/issues/filtered_search/filter_issues_spec.rb +++ b/spec/features/issues/filtered_search/filter_issues_spec.rb @@ -1,4 +1,4 @@ -require 'rails_helper' +require 'spec_helper' describe 'Filter issues', js: true, feature: true do include FilteredSearchHelpers @@ -8,13 +8,12 @@ describe 'Filter issues', js: true, feature: true do let!(:project) { create(:project, group: group) } let!(:user) { create(:user) } let!(:user2) { create(:user) } - let!(:milestone) { create(:milestone, project: project) } let!(:label) { create(:label, project: project) } let!(:wontfix) { create(:label, project: project, title: "Won't fix") } let!(:bug_label) { create(:label, project: project, title: 'bug') } let!(:caps_sensitive_label) { create(:label, project: project, title: 'CAPS_sensitive') } - let!(:milestone) { create(:milestone, title: "8", project: project) } + let!(:milestone) { create(:milestone, title: "8", project: project, start_date: 2.days.ago) } let!(:multiple_words_label) { create(:label, project: project, title: "Two words") } let!(:closed_issue) { create(:issue, title: 'bug that is closed', project: project, state: :closed) } @@ -97,7 +96,9 @@ describe 'Filter issues', js: true, feature: true do it 'filters issues by searched author' do input_filtered_search("author:@#{user.username}") + expect_tokens([{ name: 'author', value: user.username }]) expect_issues_list_count(5) + expect_filtered_search_input_empty end it 'filters issues by invalid author' do @@ -110,36 +111,50 @@ describe 'Filter issues', js: true, feature: true do end context 'author with other filters' do + let(:search_term) { 'issue' } + it 'filters issues by searched author and text' do - search = "author:@#{user.username} issue" - input_filtered_search(search) + input_filtered_search("author:@#{user.username} #{search_term}") + expect_tokens([{ name: 'author', value: user.username }]) expect_issues_list_count(3) - expect_filtered_search_input(search) + expect_filtered_search_input(search_term) end it 'filters issues by searched author, assignee and text' do - search = "author:@#{user.username} assignee:@#{user.username} issue" - input_filtered_search(search) + input_filtered_search("author:@#{user.username} assignee:@#{user.username} #{search_term}") + expect_tokens([ + { name: 'author', value: user.username }, + { name: 'assignee', value: user.username } + ]) expect_issues_list_count(3) - expect_filtered_search_input(search) + expect_filtered_search_input(search_term) end it 'filters issues by searched author, assignee, label, and text' do - search = "author:@#{user.username} assignee:@#{user.username} label:~#{caps_sensitive_label.title} issue" - input_filtered_search(search) + input_filtered_search("author:@#{user.username} assignee:@#{user.username} label:~#{caps_sensitive_label.title} #{search_term}") + expect_tokens([ + { name: 'author', value: user.username }, + { name: 'assignee', value: user.username }, + { name: 'label', value: caps_sensitive_label.title } + ]) expect_issues_list_count(1) - expect_filtered_search_input(search) + expect_filtered_search_input(search_term) end it 'filters issues by searched author, assignee, label, milestone and text' do - search = "author:@#{user.username} assignee:@#{user.username} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} issue" - input_filtered_search(search) + input_filtered_search("author:@#{user.username} assignee:@#{user.username} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} #{search_term}") + expect_tokens([ + { name: 'author', value: user.username }, + { name: 'assignee', value: user.username }, + { name: 'label', value: caps_sensitive_label.title }, + { name: 'milestone', value: milestone.title } + ]) expect_issues_list_count(1) - expect_filtered_search_input(search) + expect_filtered_search_input(search_term) end end @@ -151,19 +166,19 @@ describe 'Filter issues', js: true, feature: true do describe 'filter issues by assignee' do context 'only assignee' do it 'filters issues by searched assignee' do - search = "assignee:@#{user.username}" - input_filtered_search(search) + input_filtered_search("assignee:@#{user.username}") + expect_tokens([{ name: 'assignee', value: user.username }]) expect_issues_list_count(5) - expect_filtered_search_input(search) + expect_filtered_search_input_empty end it 'filters issues by no assignee' do - search = "assignee:none" - input_filtered_search(search) + input_filtered_search('assignee:none') + expect_tokens([{ name: 'assignee', value: 'none' }]) expect_issues_list_count(8, 1) - expect_filtered_search_input(search) + expect_filtered_search_input_empty end it 'filters issues by invalid assignee' do @@ -176,36 +191,50 @@ describe 'Filter issues', js: true, feature: true do end context 'assignee with other filters' do + let(:search_term) { 'searchTerm' } + it 'filters issues by searched assignee and text' do - search = "assignee:@#{user.username} searchTerm" - input_filtered_search(search) + input_filtered_search("assignee:@#{user.username} #{search_term}") + expect_tokens([{ name: 'assignee', value: user.username }]) expect_issues_list_count(2) - expect_filtered_search_input(search) + expect_filtered_search_input(search_term) end it 'filters issues by searched assignee, author and text' do - search = "assignee:@#{user.username} author:@#{user.username} searchTerm" - input_filtered_search(search) + input_filtered_search("assignee:@#{user.username} author:@#{user.username} #{search_term}") + expect_tokens([ + { name: 'assignee', value: user.username }, + { name: 'author', value: user.username } + ]) expect_issues_list_count(2) - expect_filtered_search_input(search) + expect_filtered_search_input(search_term) end it 'filters issues by searched assignee, author, label, text' do - search = "assignee:@#{user.username} author:@#{user.username} label:~#{caps_sensitive_label.title} searchTerm" - input_filtered_search(search) + input_filtered_search("assignee:@#{user.username} author:@#{user.username} label:~#{caps_sensitive_label.title} #{search_term}") + expect_tokens([ + { name: 'assignee', value: user.username }, + { name: 'author', value: user.username }, + { name: 'label', value: caps_sensitive_label.title } + ]) expect_issues_list_count(1) - expect_filtered_search_input(search) + expect_filtered_search_input(search_term) end it 'filters issues by searched assignee, author, label, milestone and text' do - search = "assignee:@#{user.username} author:@#{user.username} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} searchTerm" - input_filtered_search(search) + input_filtered_search("assignee:@#{user.username} author:@#{user.username} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} #{search_term}") + expect_tokens([ + { name: 'assignee', value: user.username }, + { name: 'author', value: user.username }, + { name: 'label', value: caps_sensitive_label.title }, + { name: 'milestone', value: milestone.title } + ]) expect_issues_list_count(1) - expect_filtered_search_input(search) + expect_filtered_search_input(search_term) end end @@ -217,21 +246,23 @@ describe 'Filter issues', js: true, feature: true do end describe 'filter issues by label' do + let(:search_term) { 'bug' } + context 'only label' do it 'filters issues by searched label' do - search = "label:~#{bug_label.title}" - input_filtered_search(search) + input_filtered_search("label:~#{bug_label.title}") + expect_tokens([{ name: 'label', value: bug_label.title }]) expect_issues_list_count(2) - expect_filtered_search_input(search) + expect_filtered_search_input_empty end it 'filters issues by no label' do - search = "label:none" - input_filtered_search(search) + input_filtered_search('label:none') + expect_tokens([{ name: 'label', value: 'none' }]) expect_issues_list_count(9, 1) - expect_filtered_search_input(search) + expect_filtered_search_input_empty end it 'filters issues by invalid label' do @@ -239,11 +270,14 @@ describe 'Filter issues', js: true, feature: true do end it 'filters issues by multiple labels' do - search = "label:~#{bug_label.title} label:~#{caps_sensitive_label.title}" - input_filtered_search(search) + input_filtered_search("label:~#{bug_label.title} label:~#{caps_sensitive_label.title}") + expect_tokens([ + { name: 'label', value: bug_label.title }, + { name: 'label', value: caps_sensitive_label.title } + ]) expect_issues_list_count(1) - expect_filtered_search_input(search) + expect_filtered_search_input_empty end it 'filters issues by label containing special characters' do @@ -251,21 +285,20 @@ describe 'Filter issues', js: true, feature: true do special_issue = create(:issue, title: "Issue with special character label", project: project) special_issue.labels << special_label - search = "label:~#{special_label.title}" - input_filtered_search(search) - + input_filtered_search("label:~#{special_label.title}") + expect_tokens([{ name: 'label', value: special_label.title }]) expect_issues_list_count(1) - expect_filtered_search_input(search) + expect_filtered_search_input_empty end it 'does not show issues' do - new_label = create(:label, project: project, title: "new_label") + new_label = create(:label, project: project, title: 'new_label') - search = "label:~#{new_label.title}" - input_filtered_search(search) + input_filtered_search("label:~#{new_label.title}") + expect_tokens([{ name: 'label', value: new_label.title }]) expect_no_issues_list() - expect_filtered_search_input(search) + expect_filtered_search_input_empty end end @@ -275,29 +308,29 @@ describe 'Filter issues', js: true, feature: true do special_multiple_issue = create(:issue, title: "Issue with special character multiple words label", project: project) special_multiple_issue.labels << special_multiple_label - search = "label:~'#{special_multiple_label.title}'" - input_filtered_search(search) + input_filtered_search("label:~'#{special_multiple_label.title}'") + # filtered search defaults quotations to double quotes + expect_tokens([{ name: 'label', value: "\"#{special_multiple_label.title}\"" }]) expect_issues_list_count(1) - # filtered search defaults quotations to double quotes - expect_filtered_search_input("label:~\"#{special_multiple_label.title}\"") + expect_filtered_search_input_empty end it 'single quotes' do - search = "label:~'#{multiple_words_label.title}'" - input_filtered_search(search) + input_filtered_search("label:~'#{multiple_words_label.title}'") + expect_tokens([{ name: 'label', value: "\"#{multiple_words_label.title}\"" }]) expect_issues_list_count(1) - expect_filtered_search_input("label:~\"#{multiple_words_label.title}\"") + expect_filtered_search_input_empty end it 'double quotes' do - search = "label:~\"#{multiple_words_label.title}\"" - input_filtered_search(search) + input_filtered_search("label:~\"#{multiple_words_label.title}\"") + expect_tokens([{ name: 'label', value: "\"#{multiple_words_label.title}\"" }]) expect_issues_list_count(1) - expect_filtered_search_input(search) + expect_filtered_search_input_empty end it 'single quotes containing double quotes' do @@ -305,11 +338,11 @@ describe 'Filter issues', js: true, feature: true do double_quotes_label_issue = create(:issue, title: "Issue with double quotes label", project: project) double_quotes_label_issue.labels << double_quotes_label - search = "label:~'#{double_quotes_label.title}'" - input_filtered_search(search) + input_filtered_search("label:~'#{double_quotes_label.title}'") + expect_tokens([{ name: 'label', value: "'#{double_quotes_label.title}'" }]) expect_issues_list_count(1) - expect_filtered_search_input(search) + expect_filtered_search_input_empty end it 'double quotes containing single quotes' do @@ -317,86 +350,115 @@ describe 'Filter issues', js: true, feature: true do single_quotes_label_issue = create(:issue, title: "Issue with single quotes label", project: project) single_quotes_label_issue.labels << single_quotes_label - search = "label:~\"#{single_quotes_label.title}\"" - input_filtered_search(search) + input_filtered_search("label:~\"#{single_quotes_label.title}\"") + expect_tokens([{ name: 'label', value: "\"#{single_quotes_label.title}\"" }]) expect_issues_list_count(1) - expect_filtered_search_input(search) + expect_filtered_search_input_empty end end context 'label with other filters' do it 'filters issues by searched label and text' do - search = "label:~#{caps_sensitive_label.title} bug" - input_filtered_search(search) + input_filtered_search("label:~#{caps_sensitive_label.title} #{search_term}") + expect_tokens([{ name: 'label', value: caps_sensitive_label.title }]) expect_issues_list_count(1) - expect_filtered_search_input(search) + expect_filtered_search_input(search_term) end it 'filters issues by searched label, author and text' do - search = "label:~#{caps_sensitive_label.title} author:@#{user.username} bug" - input_filtered_search(search) + input_filtered_search("label:~#{caps_sensitive_label.title} author:@#{user.username} #{search_term}") + expect_tokens([ + { name: 'label', value: caps_sensitive_label.title }, + { name: 'author', value: user.username } + ]) expect_issues_list_count(1) - expect_filtered_search_input(search) + expect_filtered_search_input(search_term) end it 'filters issues by searched label, author, assignee and text' do - search = "label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} bug" - input_filtered_search(search) + input_filtered_search("label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} #{search_term}") + expect_tokens([ + { name: 'label', value: caps_sensitive_label.title }, + { name: 'author', value: user.username }, + { name: 'assignee', value: user.username } + ]) expect_issues_list_count(1) - expect_filtered_search_input(search) + expect_filtered_search_input(search_term) end it 'filters issues by searched label, author, assignee, milestone and text' do - search = "label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} milestone:%#{milestone.title} bug" - input_filtered_search(search) + input_filtered_search("label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} milestone:%#{milestone.title} #{search_term}") + expect_tokens([ + { name: 'label', value: caps_sensitive_label.title }, + { name: 'author', value: user.username }, + { name: 'assignee', value: user.username }, + { name: 'milestone', value: milestone.title } + ]) expect_issues_list_count(1) - expect_filtered_search_input(search) + expect_filtered_search_input(search_term) end end context 'multiple labels with other filters' do it 'filters issues by searched label, label2, and text' do - search = "label:~#{bug_label.title} label:~#{caps_sensitive_label.title} bug" - input_filtered_search(search) + input_filtered_search("label:~#{bug_label.title} label:~#{caps_sensitive_label.title} #{search_term}") + expect_tokens([ + { name: 'label', value: bug_label.title }, + { name: 'label', value: caps_sensitive_label.title } + ]) expect_issues_list_count(1) - expect_filtered_search_input(search) + expect_filtered_search_input(search_term) end it 'filters issues by searched label, label2, author and text' do - search = "label:~#{bug_label.title} label:~#{caps_sensitive_label.title} author:@#{user.username} bug" - input_filtered_search(search) + input_filtered_search("label:~#{bug_label.title} label:~#{caps_sensitive_label.title} author:@#{user.username} #{search_term}") + expect_tokens([ + { name: 'label', value: bug_label.title }, + { name: 'label', value: caps_sensitive_label.title }, + { name: 'author', value: user.username } + ]) expect_issues_list_count(1) - expect_filtered_search_input(search) + expect_filtered_search_input(search_term) end it 'filters issues by searched label, label2, author, assignee and text' do - search = "label:~#{bug_label.title} label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} bug" - input_filtered_search(search) + input_filtered_search("label:~#{bug_label.title} label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} #{search_term}") + expect_tokens([ + { name: 'label', value: bug_label.title }, + { name: 'label', value: caps_sensitive_label.title }, + { name: 'author', value: user.username }, + { name: 'assignee', value: user.username } + ]) expect_issues_list_count(1) - expect_filtered_search_input(search) + expect_filtered_search_input(search_term) end it 'filters issues by searched label, label2, author, assignee, milestone and text' do - search = "label:~#{bug_label.title} label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} milestone:%#{milestone.title} bug" - input_filtered_search(search) + input_filtered_search("label:~#{bug_label.title} label:~#{caps_sensitive_label.title} author:@#{user.username} assignee:@#{user.username} milestone:%#{milestone.title} #{search_term}") + expect_tokens([ + { name: 'label', value: bug_label.title }, + { name: 'label', value: caps_sensitive_label.title }, + { name: 'author', value: user.username }, + { name: 'assignee', value: user.username }, + { name: 'milestone', value: milestone.title } + ]) expect_issues_list_count(1) - expect_filtered_search_input(search) + expect_filtered_search_input(search_term) end end context 'issue label clicked' do before do find('.issues-list .issue .issue-info a .label', text: multiple_words_label.title).click - sleep 1 end it 'filters' do @@ -404,7 +466,8 @@ describe 'Filter issues', js: true, feature: true do end it 'displays in search bar' do - expect(find('.filtered-search').value).to eq("label:~\"#{multiple_words_label.title}\"") + expect_tokens([{ name: 'label', value: "\"#{multiple_words_label.title}\"" }]) + expect_filtered_search_input_empty end end @@ -420,19 +483,33 @@ describe 'Filter issues', js: true, feature: true do it 'filters issues by searched milestone' do input_filtered_search("milestone:%#{milestone.title}") + expect_tokens([{ name: 'milestone', value: milestone.title }]) expect_issues_list_count(5) + expect_filtered_search_input_empty end it 'filters issues by no milestone' do input_filtered_search("milestone:none") + expect_tokens([{ name: 'milestone', value: 'none' }]) expect_issues_list_count(7, 1) + expect_filtered_search_input_empty end it 'filters issues by upcoming milestones' do input_filtered_search("milestone:upcoming") + expect_tokens([{ name: 'milestone', value: 'upcoming' }]) expect_issues_list_count(1) + expect_filtered_search_input_empty + end + + it 'filters issues by started milestones' do + input_filtered_search("milestone:started") + + expect_tokens([{ name: 'milestone', value: 'started' }]) + expect_issues_list_count(5) + expect_filtered_search_input_empty end it 'filters issues by invalid milestones' do @@ -447,55 +524,69 @@ describe 'Filter issues', js: true, feature: true do special_milestone = create(:milestone, title: '!@\#{$%^&*()}', project: project) create(:issue, title: "Issue with special character milestone", project: project, milestone: special_milestone) - search = "milestone:%#{special_milestone.title}" - input_filtered_search(search) + input_filtered_search("milestone:%#{special_milestone.title}") + expect_tokens([{ name: 'milestone', value: special_milestone.title }]) expect_issues_list_count(1) - expect_filtered_search_input(search) + expect_filtered_search_input_empty end it 'does not show issues' do new_milestone = create(:milestone, title: "new", project: project) - search = "milestone:%#{new_milestone.title}" - input_filtered_search(search) + input_filtered_search("milestone:%#{new_milestone.title}") + expect_tokens([{ name: 'milestone', value: new_milestone.title }]) expect_no_issues_list() - expect_filtered_search_input(search) + expect_filtered_search_input_empty end end context 'milestone with other filters' do + let(:search_term) { 'bug' } + it 'filters issues by searched milestone and text' do - search = "milestone:%#{milestone.title} bug" - input_filtered_search(search) + input_filtered_search("milestone:%#{milestone.title} #{search_term}") + expect_tokens([{ name: 'milestone', value: milestone.title }]) expect_issues_list_count(2) - expect_filtered_search_input(search) + expect_filtered_search_input(search_term) end it 'filters issues by searched milestone, author and text' do - search = "milestone:%#{milestone.title} author:@#{user.username} bug" - input_filtered_search(search) + input_filtered_search("milestone:%#{milestone.title} author:@#{user.username} #{search_term}") + expect_tokens([ + { name: 'milestone', value: milestone.title }, + { name: 'author', value: user.username } + ]) expect_issues_list_count(2) - expect_filtered_search_input(search) + expect_filtered_search_input(search_term) end it 'filters issues by searched milestone, author, assignee and text' do - search = "milestone:%#{milestone.title} author:@#{user.username} assignee:@#{user.username} bug" - input_filtered_search(search) + input_filtered_search("milestone:%#{milestone.title} author:@#{user.username} assignee:@#{user.username} #{search_term}") + expect_tokens([ + { name: 'milestone', value: milestone.title }, + { name: 'author', value: user.username }, + { name: 'assignee', value: user.username } + ]) expect_issues_list_count(2) - expect_filtered_search_input(search) + expect_filtered_search_input(search_term) end it 'filters issues by searched milestone, author, assignee, label and text' do - search = "milestone:%#{milestone.title} author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} bug" - input_filtered_search(search) - + input_filtered_search("milestone:%#{milestone.title} author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} #{search_term}") + + expect_tokens([ + { name: 'milestone', value: milestone.title }, + { name: 'author', value: user.username }, + { name: 'assignee', value: user.username }, + { name: 'label', value: bug_label.title } + ]) expect_issues_list_count(2) - expect_filtered_search_input(search) + expect_filtered_search_input(search_term) end end @@ -506,44 +597,6 @@ describe 'Filter issues', js: true, feature: true do end end - describe 'overwrites selected filter' do - it 'changes author' do - input_filtered_search("author:@#{user.username}", submit: false) - - select_search_at_index(3) - - page.within '#js-dropdown-author' do - click_button user2.username - end - - expect(filtered_search.value).to eq("author:@#{user2.username} ") - end - - it 'changes label' do - input_filtered_search("author:@#{user.username} label:~#{bug_label.title}", submit: false) - - select_search_at_index(27) - - page.within '#js-dropdown-label' do - click_button label.name - end - - expect(filtered_search.value).to eq("author:@#{user.username} label:~#{label.name} ") - end - - it 'changes label correctly space is in previous label' do - input_filtered_search("label:~\"#{multiple_words_label.title}\"", submit: false) - - select_search_at_index(0) - - page.within '#js-dropdown-label' do - click_button label.name - end - - expect(filtered_search.value).to eq("label:~#{label.name} ") - end - end - describe 'filter issues by text' do context 'only text' do it 'filters issues by searched text' do @@ -605,80 +658,81 @@ describe 'Filter issues', js: true, feature: true do context 'searched text with other filters' do it 'filters issues by searched text and author' do + # After searching, all search terms are placed at the end input_filtered_search("bug author:@#{user.username}") expect_issues_list_count(2) - expect_filtered_search_input("author:@#{user.username} bug") + expect_filtered_search_input('bug') end it 'filters issues by searched text, author and more text' do input_filtered_search("bug author:@#{user.username} report") expect_issues_list_count(1) - expect_filtered_search_input("author:@#{user.username} bug report") + expect_filtered_search_input('bug report') end it 'filters issues by searched text, author and assignee' do input_filtered_search("bug author:@#{user.username} assignee:@#{user.username}") expect_issues_list_count(2) - expect_filtered_search_input("author:@#{user.username} assignee:@#{user.username} bug") + expect_filtered_search_input('bug') end it 'filters issues by searched text, author, more text and assignee' do input_filtered_search("bug author:@#{user.username} report assignee:@#{user.username}") expect_issues_list_count(1) - expect_filtered_search_input("author:@#{user.username} assignee:@#{user.username} bug report") + expect_filtered_search_input('bug report') end it 'filters issues by searched text, author, more text, assignee and even more text' do input_filtered_search("bug author:@#{user.username} report assignee:@#{user.username} with") expect_issues_list_count(1) - expect_filtered_search_input("author:@#{user.username} assignee:@#{user.username} bug report with") + expect_filtered_search_input('bug report with') end it 'filters issues by searched text, author, assignee and label' do input_filtered_search("bug author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title}") expect_issues_list_count(2) - expect_filtered_search_input("author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} bug") + expect_filtered_search_input('bug') end it 'filters issues by searched text, author, text, assignee, text, label and text' do input_filtered_search("bug author:@#{user.username} report assignee:@#{user.username} with label:~#{bug_label.title} everything") expect_issues_list_count(1) - expect_filtered_search_input("author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} bug report with everything") + expect_filtered_search_input('bug report with everything') end it 'filters issues by searched text, author, assignee, label and milestone' do input_filtered_search("bug author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} milestone:%#{milestone.title}") expect_issues_list_count(2) - expect_filtered_search_input("author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} milestone:%#{milestone.title} bug") + expect_filtered_search_input('bug') end it 'filters issues by searched text, author, text, assignee, text, label, text, milestone and text' do input_filtered_search("bug author:@#{user.username} report assignee:@#{user.username} with label:~#{bug_label.title} everything milestone:%#{milestone.title} you") expect_issues_list_count(1) - expect_filtered_search_input("author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} milestone:%#{milestone.title} bug report with everything you") + expect_filtered_search_input('bug report with everything you') end it 'filters issues by searched text, author, assignee, multiple labels and milestone' do input_filtered_search("bug author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title}") expect_issues_list_count(1) - expect_filtered_search_input("author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} bug") + expect_filtered_search_input('bug') end it 'filters issues by searched text, author, text, assignee, text, label1, text, label2, text, milestone and text' do input_filtered_search("bug author:@#{user.username} report assignee:@#{user.username} with label:~#{bug_label.title} everything label:~#{caps_sensitive_label.title} you milestone:%#{milestone.title} thought") expect_issues_list_count(1) - expect_filtered_search_input("author:@#{user.username} assignee:@#{user.username} label:~#{bug_label.title} label:~#{caps_sensitive_label.title} milestone:%#{milestone.title} bug report with everything you thought") + expect_filtered_search_input('bug report with everything you thought') end end @@ -717,8 +771,8 @@ describe 'Filter issues', js: true, feature: true do before do input_filtered_search('bug') - # Wait for search results to load - sleep 2 + # This ensures that the search is performed + expect_issues_list_count(4, 1) end it 'open state' do diff --git a/spec/features/issues/filtered_search/search_bar_spec.rb b/spec/features/issues/filtered_search/search_bar_spec.rb index 90eb60eb337..59244d65eec 100644 --- a/spec/features/issues/filtered_search/search_bar_spec.rb +++ b/spec/features/issues/filtered_search/search_bar_spec.rb @@ -1,6 +1,7 @@ require 'rails_helper' describe 'Search bar', js: true, feature: true do + include FilteredSearchHelpers include WaitForAjax let!(:project) { create(:empty_project) } @@ -32,7 +33,8 @@ describe 'Search bar', js: true, feature: true do it 'selects item' do filtered_search.native.send_keys(:down, :down, :enter) - expect(filtered_search.value).to eq('author:') + expect_tokens([{ name: 'author' }]) + expect_filtered_search_input_empty end end diff --git a/spec/features/issues/filtered_search/visual_tokens_spec.rb b/spec/features/issues/filtered_search/visual_tokens_spec.rb new file mode 100644 index 00000000000..96e87c82d2c --- /dev/null +++ b/spec/features/issues/filtered_search/visual_tokens_spec.rb @@ -0,0 +1,352 @@ +require 'rails_helper' + +describe 'Visual tokens', js: true, feature: true do + include FilteredSearchHelpers + + let!(:project) { create(:empty_project) } + let!(:user) { create(:user, name: 'administrator', username: 'root') } + let!(:user_rock) { create(:user, name: 'The Rock', username: 'rock') } + let!(:milestone_nine) { create(:milestone, title: '9.0', project: project) } + let!(:milestone_ten) { create(:milestone, title: '10.0', project: project) } + let!(:label) { create(:label, project: project, title: 'abc') } + let!(:cc_label) { create(:label, project: project, title: 'Community Contribution') } + + let(:filtered_search) { find('.filtered-search') } + let(:filter_author_dropdown) { find("#js-dropdown-author .filter-dropdown") } + let(:filter_assignee_dropdown) { find("#js-dropdown-assignee .filter-dropdown") } + let(:filter_milestone_dropdown) { find("#js-dropdown-milestone .filter-dropdown") } + let(:filter_label_dropdown) { find("#js-dropdown-label .filter-dropdown") } + + def is_input_focused + page.evaluate_script("document.activeElement.classList.contains('filtered-search')") + end + + before do + project.add_user(user, :master) + project.add_user(user_rock, :master) + login_as(user) + create(:issue, project: project) + + visit namespace_project_issues_path(project.namespace, project) + end + + describe 'editing author token' do + before do + input_filtered_search('author:@root assignee:none', submit: false) + first('.tokens-container .filtered-search-token').double_click + end + + it 'opens author dropdown' do + expect(page).to have_css('#js-dropdown-author', visible: true) + end + + it 'makes value editable' do + expect_filtered_search_input('@root') + end + + it 'filters value' do + filtered_search.send_keys(:backspace) + + expect(page).to have_css('#js-dropdown-author .filter-dropdown .filter-dropdown-item', count: 1) + end + + it 'ends editing mode when document is clicked' do + find('#content-body').click + + expect_filtered_search_input_empty + expect(page).to have_css('#js-dropdown-author', visible: false) + end + + it 'ends editing mode when scroll container is clicked' do + find('.scroll-container').click + + expect_filtered_search_input_empty + expect(page).to have_css('#js-dropdown-author', visible: false) + end + + describe 'selecting different author from dropdown' do + before do + filter_author_dropdown.find('.filter-dropdown-item .dropdown-light-content', text: "@#{user_rock.username}").click + end + + it 'changes value in visual token' do + expect(first('.tokens-container .filtered-search-token .value').text).to eq("@#{user_rock.username}") + end + + it 'moves input to the right' do + expect(is_input_focused).to eq(true) + end + end + end + + describe 'editing assignee token' do + before do + input_filtered_search('assignee:@root author:none', submit: false) + first('.tokens-container .filtered-search-token').double_click + end + + it 'opens assignee dropdown' do + expect(page).to have_css('#js-dropdown-assignee', visible: true) + end + + it 'makes value editable' do + expect_filtered_search_input('@root') + end + + it 'filters value' do + filtered_search.send_keys(:backspace) + + expect(page).to have_css('#js-dropdown-assignee .filter-dropdown .filter-dropdown-item', count: 1) + end + + it 'ends editing mode when document is clicked' do + find('#content-body').click + + expect_filtered_search_input_empty + expect(page).to have_css('#js-dropdown-assignee', visible: false) + end + + it 'ends editing mode when scroll container is clicked' do + find('.scroll-container').click + + expect_filtered_search_input_empty + expect(page).to have_css('#js-dropdown-assignee', visible: false) + end + + describe 'selecting static option from dropdown' do + before do + find("#js-dropdown-assignee").find('.filter-dropdown-item', text: 'No Assignee').click + end + + it 'changes value in visual token' do + expect(first('.tokens-container .filtered-search-token .value').text).to eq('none') + end + + it 'moves input to the right' do + expect(is_input_focused).to eq(true) + end + end + end + + describe 'editing milestone token' do + before do + input_filtered_search('milestone:%10.0 author:none', submit: false) + first('.tokens-container .filtered-search-token').double_click + first('#js-dropdown-milestone .filter-dropdown .filter-dropdown-item') + end + + it 'opens milestone dropdown' do + expect(filter_milestone_dropdown.find('.filter-dropdown-item', text: milestone_ten.title)).to be_visible + expect(filter_milestone_dropdown.find('.filter-dropdown-item', text: milestone_nine.title)).to be_visible + expect(page).to have_css('#js-dropdown-milestone', visible: true) + end + + it 'selects static option from dropdown' do + find("#js-dropdown-milestone").find('.filter-dropdown-item', text: 'Upcoming').click + + expect(first('.tokens-container .filtered-search-token .value').text).to eq('upcoming') + expect(is_input_focused).to eq(true) + end + + it 'makes value editable' do + expect_filtered_search_input('%10.0') + end + + it 'filters value' do + filtered_search.send_keys(:backspace) + + expect(page).to have_css('#js-dropdown-milestone .filter-dropdown .filter-dropdown-item', count: 1) + end + + it 'ends editing mode when document is clicked' do + find('#content-body').click + + expect_filtered_search_input_empty + expect(page).to have_css('#js-dropdown-milestone', visible: false) + end + + it 'ends editing mode when scroll container is clicked' do + find('.scroll-container').click + + expect_filtered_search_input_empty + expect(page).to have_css('#js-dropdown-milestone', visible: false) + end + end + + describe 'editing label token' do + before do + input_filtered_search("label:~#{label.title} author:none", submit: false) + first('.tokens-container .filtered-search-token').double_click + first('#js-dropdown-label .filter-dropdown .filter-dropdown-item') + end + + it 'opens label dropdown' do + expect(filter_label_dropdown.find('.filter-dropdown-item', text: label.title)).to be_visible + expect(filter_label_dropdown.find('.filter-dropdown-item', text: cc_label.title)).to be_visible + expect(page).to have_css('#js-dropdown-label', visible: true) + end + + it 'selects option from dropdown' do + expect(filter_label_dropdown.find('.filter-dropdown-item', text: label.title)).to be_visible + expect(filter_label_dropdown.find('.filter-dropdown-item', text: cc_label.title)).to be_visible + + find("#js-dropdown-label").find('.filter-dropdown-item', text: cc_label.title).click + + expect(first('.tokens-container .filtered-search-token .value').text).to eq("~\"#{cc_label.title}\"") + expect(is_input_focused).to eq(true) + end + + it 'makes value editable' do + expect_filtered_search_input("~#{label.title}") + end + + it 'filters value' do + expect(filter_label_dropdown.find('.filter-dropdown-item', text: label.title)).to be_visible + expect(filter_label_dropdown.find('.filter-dropdown-item', text: cc_label.title)).to be_visible + + filtered_search.send_keys(:backspace) + + filter_label_dropdown.find('.filter-dropdown-item') + + expect(page.all('#js-dropdown-label .filter-dropdown .filter-dropdown-item').size).to eq(1) + end + + it 'ends editing mode when document is clicked' do + find('#content-body').click + + expect_filtered_search_input_empty + expect(page).to have_css('#js-dropdown-label', visible: false) + end + + it 'ends editing mode when scroll container is clicked' do + find('.scroll-container').click + + expect_filtered_search_input_empty + expect(page).to have_css('#js-dropdown-label', visible: false) + end + end + + describe 'editing multiple tokens' do + before do + input_filtered_search('author:@root assignee:none', submit: false) + first('.tokens-container .filtered-search-token').double_click + end + + it 'opens author dropdown' do + expect(page).to have_css('#js-dropdown-author', visible: true) + end + + it 'opens assignee dropdown' do + find('.tokens-container .filtered-search-token', text: 'Assignee').double_click + expect(page).to have_css('#js-dropdown-assignee', visible: true) + end + end + + describe 'editing a search term while editing another filter token' do + before do + input_filtered_search('author assignee:', submit: false) + first('.tokens-container .filtered-search-term').double_click + end + + it 'opens hint dropdown' do + expect(page).to have_css('#js-dropdown-hint', visible: true) + end + + it 'opens author dropdown' do + find('#js-dropdown-hint .filter-dropdown .filter-dropdown-item', text: 'author').click + + expect(page).to have_css('#js-dropdown-author', visible: true) + end + end + + describe 'add new token after editing existing token' do + before do + input_filtered_search('author:@root assignee:none', submit: false) + first('.tokens-container .filtered-search-token').double_click + filtered_search.send_keys(' ') + end + + describe 'opens dropdowns' do + it 'opens hint dropdown' do + expect(page).to have_css('#js-dropdown-hint', visible: true) + end + + it 'opens author dropdown' do + filtered_search.send_keys('author:') + expect(page).to have_css('#js-dropdown-author', visible: true) + end + + it 'opens assignee dropdown' do + filtered_search.send_keys('assignee:') + expect(page).to have_css('#js-dropdown-assignee', visible: true) + end + + it 'opens milestone dropdown' do + filtered_search.send_keys('milestone:') + expect(page).to have_css('#js-dropdown-milestone', visible: true) + end + + it 'opens label dropdown' do + filtered_search.send_keys('label:') + expect(page).to have_css('#js-dropdown-label', visible: true) + end + end + + describe 'creates visual tokens' do + it 'creates author token' do + filtered_search.send_keys('author:@thomas ') + token = page.all('.tokens-container .filtered-search-token')[1] + + expect(token.find('.name').text).to eq('Author') + expect(token.find('.value').text).to eq('@thomas') + end + + it 'creates assignee token' do + filtered_search.send_keys('assignee:@thomas ') + token = page.all('.tokens-container .filtered-search-token')[1] + + expect(token.find('.name').text).to eq('Assignee') + expect(token.find('.value').text).to eq('@thomas') + end + + it 'creates milestone token' do + filtered_search.send_keys('milestone:none ') + token = page.all('.tokens-container .filtered-search-token')[1] + + expect(token.find('.name').text).to eq('Milestone') + expect(token.find('.value').text).to eq('none') + end + + it 'creates label token' do + filtered_search.send_keys('label:~Backend ') + token = page.all('.tokens-container .filtered-search-token')[1] + + expect(token.find('.name').text).to eq('Label') + expect(token.find('.value').text).to eq('~Backend') + end + end + + it 'does not tokenize incomplete token' do + filtered_search.send_keys('author:') + + find('#content-body').click + token = page.all('.tokens-container .js-visual-token')[1] + + expect_filtered_search_input_empty + expect(token.find('.name').text).to eq('Author') + end + end + + describe 'search using incomplete visual tokens' do + before do + input_filtered_search('author:@root assignee:none', extra_space: false) + end + + it 'tokenizes the search term to complete visual token' do + expect_tokens([ + { name: 'author', value: '@root' }, + { name: 'assignee', value: 'none' } + ]) + end + end +end diff --git a/spec/features/issues/form_spec.rb b/spec/features/issues/form_spec.rb index 741ca95f1ca..755992069ff 100644 --- a/spec/features/issues/form_spec.rb +++ b/spec/features/issues/form_spec.rb @@ -1,8 +1,11 @@ require 'rails_helper' describe 'New/edit issue', feature: true, js: true do + include GitlabRoutingHelper + let!(:project) { create(:project) } let!(:user) { create(:user)} + let!(:user2) { create(:user)} let!(:milestone) { create(:milestone, project: project) } let!(:label) { create(:label, project: project) } let!(:label2) { create(:label, project: project) } @@ -10,6 +13,7 @@ describe 'New/edit issue', feature: true, js: true do before do project.team << [user, :master] + project.team << [user2, :master] login_as(user) end @@ -22,14 +26,23 @@ describe 'New/edit issue', feature: true, js: true do fill_in 'issue_title', with: 'title' fill_in 'issue_description', with: 'title' + expect(find('a', text: 'Assign to me')).to be_visible click_button 'Assignee' page.within '.dropdown-menu-user' do - click_link user.name + click_link user2.name + end + expect(find('input[name="issue[assignee_id]"]', visible: false).value).to match(user2.id.to_s) + page.within '.js-assignee-search' do + expect(page).to have_content user2.name end + expect(find('a', text: 'Assign to me')).to be_visible + + click_link 'Assign to me' expect(find('input[name="issue[assignee_id]"]', visible: false).value).to match(user.id.to_s) page.within '.js-assignee-search' do expect(page).to have_content user.name end + expect(find('a', text: 'Assign to me', visible: false)).not_to be_visible click_button 'Milestone' page.within '.issue-milestone' do @@ -67,6 +80,14 @@ describe 'New/edit issue', feature: true, js: true do expect(page).to have_content label2.title end end + + page.within '.issuable-meta' do + issue = Issue.find_by(title: 'title') + + expect(page).to have_text("Issue #{issue.to_reference}") + # compare paths because the host differ in test + expect(find_link(issue.to_reference)[:href]).to end_with(issue_path(issue)) + end end it 'correctly updates the dropdown toggle when removing a label' do @@ -94,6 +115,7 @@ describe 'New/edit issue', feature: true, js: true do it 'allows user to update issue' do expect(find('input[name="issue[assignee_id]"]', visible: false).value).to match(user.id.to_s) expect(find('input[name="issue[milestone_id]"]', visible: false).value).to match(milestone.id.to_s) + expect(find('a', text: 'Assign to me', visible: false)).not_to be_visible page.within '.js-user-search' do expect(page).to have_content user.name diff --git a/spec/features/issues_spec.rb b/spec/features/issues_spec.rb index 094f645a077..a58aedc924e 100644 --- a/spec/features/issues_spec.rb +++ b/spec/features/issues_spec.rb @@ -1,11 +1,12 @@ require 'spec_helper' describe 'Issues', feature: true do + include DropzoneHelper include IssueHelpers include SortingHelper include WaitForAjax - let(:project) { create(:project) } + let(:project) { create(:project, :public) } before do login_as :user @@ -150,7 +151,7 @@ describe 'Issues', feature: true do describe 'Filter issue' do before do - ['foobar', 'barbaz', 'gitlab'].each do |title| + %w(foobar barbaz gitlab).each do |title| create(:issue, author: @user, assignee: @user, @@ -564,19 +565,40 @@ describe 'Issues', feature: true do end describe 'new issue' do + context 'by unauthenticated user' do + before do + logout + end + + it 'redirects to signin then back to new issue after signin' do + visit namespace_project_issues_path(project.namespace, project) + + click_link 'New issue' + + expect(current_path).to eq new_user_session_path + + login_as :user + + expect(current_path).to eq new_namespace_project_issue_path(project.namespace, project) + end + end + context 'dropzone upload file', js: true do before do visit new_namespace_project_issue_path(project.namespace, project) end it 'uploads file when dragging into textarea' do - drop_in_dropzone test_image_file - - # Wait for the file to upload - sleep 1 + dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif') expect(page.find_field("issue_description").value).to have_content 'banana_sample' end + + it 'adds double newline to end of attachment markdown' do + dropzone_file Rails.root.join('spec', 'fixtures', 'banana_sample.gif') + + expect(page.find_field("issue_description").value).to match /\n\n$/ + end end end @@ -656,25 +678,4 @@ describe 'Issues', feature: true do end end end - - def drop_in_dropzone(file_path) - # Generate a fake input selector - page.execute_script <<-JS - var fakeFileInput = window.$('<input/>').attr( - {id: 'fakeFileInput', type: 'file'} - ).appendTo('body'); - JS - # Attach the file to the fake input selector with Capybara - attach_file("fakeFileInput", file_path) - # Add the file to a fileList array and trigger the fake drop event - page.execute_script <<-JS - var fileList = [$('#fakeFileInput')[0].files[0]]; - var e = jQuery.Event('drop', { dataTransfer : { files : fileList } }); - $('.div-dropzone')[0].dropzone.listeners[0].events.drop(e); - JS - end - - def test_image_file - File.join(Rails.root, 'spec', 'fixtures', 'banana_sample.gif') - end end diff --git a/spec/features/login_spec.rb b/spec/features/login_spec.rb index ae609160e18..f32d1f78b40 100644 --- a/spec/features/login_spec.rb +++ b/spec/features/login_spec.rb @@ -48,6 +48,18 @@ feature 'Login', feature: true do end end + describe 'with the ghost user' do + it 'disallows login' do + login_with(User.ghost) + + expect(page).to have_content('Invalid Login or password.') + end + + it 'does not update Devise trackable attributes' do + expect { login_with(User.ghost) }.not_to change { User.ghost.reload.sign_in_count } + end + end + describe 'with two-factor authentication' do def enter_code(code) fill_in 'user_otp_attempt', with: code diff --git a/spec/features/markdown_spec.rb b/spec/features/markdown_spec.rb index 32159559c37..894df13a2dc 100644 --- a/spec/features/markdown_spec.rb +++ b/spec/features/markdown_spec.rb @@ -115,6 +115,14 @@ describe 'GitLab Markdown', feature: true do expect(doc).to have_selector('span:contains("span tag")') end + it 'permits details elements' do + expect(doc).to have_selector('details:contains("Hiding the details")') + end + + it 'permits summary elements' do + expect(doc).to have_selector('details summary:contains("collapsible")') + end + it 'permits style attribute in th elements' do aggregate_failures do expect(doc.at_css('th:contains("Header")')['style']).to eq 'text-align: center' diff --git a/spec/features/merge_requests/conflicts_spec.rb b/spec/features/merge_requests/conflicts_spec.rb index d710a780111..18508a44184 100644 --- a/spec/features/merge_requests/conflicts_spec.rb +++ b/spec/features/merge_requests/conflicts_spec.rb @@ -154,7 +154,7 @@ feature 'Merge request conflict resolution', js: true, feature: true do 'conflict-binary-file' => 'when the conflicts contain a binary file', 'conflict-missing-side' => 'when the conflicts contain a file edited in one branch and deleted in another', 'conflict-non-utf8' => 'when the conflicts contain a non-UTF-8 file', - } + }.freeze UNRESOLVABLE_CONFLICTS.each do |source_branch, description| context description do diff --git a/spec/features/merge_requests/created_from_fork_spec.rb b/spec/features/merge_requests/created_from_fork_spec.rb index 73c5ef31edc..18833ba7266 100644 --- a/spec/features/merge_requests/created_from_fork_spec.rb +++ b/spec/features/merge_requests/created_from_fork_spec.rb @@ -60,9 +60,6 @@ feature 'Merge request created from fork' do expect(page).to have_content pipeline.status expect(page).to have_content pipeline.id end - - expect(page.find('a.btn-remove')[:href]) - .to include fork_project.path_with_namespace end end diff --git a/spec/features/merge_requests/diff_notes_avatars_spec.rb b/spec/features/merge_requests/diff_notes_avatars_spec.rb new file mode 100644 index 00000000000..a6c72b0b3ac --- /dev/null +++ b/spec/features/merge_requests/diff_notes_avatars_spec.rb @@ -0,0 +1,186 @@ +require 'spec_helper' + +feature 'Diff note avatars', feature: true, js: true do + include WaitForAjax + + let(:user) { create(:user) } + let(:project) { create(:project, :public) } + let(:merge_request) { create(:merge_request_with_diffs, source_project: project, author: user, title: "Bug NS-04") } + let(:path) { "files/ruby/popen.rb" } + let(:position) do + Gitlab::Diff::Position.new( + old_path: path, + new_path: path, + old_line: nil, + new_line: 9, + diff_refs: merge_request.diff_refs + ) + end + let!(:note) { create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: position) } + + before do + project.team << [user, :master] + login_as user + end + + context 'discussion tab' do + before do + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + it 'does not show avatars on discussion tab' do + expect(page).not_to have_selector('.js-avatar-container') + expect(page).not_to have_selector('.diff-comment-avatar-holders') + end + + it 'does not render avatars after commening on discussion tab' do + click_button 'Reply...' + + page.within('.js-discussion-note-form') do + find('.note-textarea').native.send_keys('Test comment') + + click_button 'Comment' + end + + expect(page).to have_content('Test comment') + expect(page).not_to have_selector('.js-avatar-container') + expect(page).not_to have_selector('.diff-comment-avatar-holders') + end + end + + context 'commit view' do + before do + visit namespace_project_commit_path(project.namespace, project, merge_request.commits.first.id) + end + + it 'does not render avatar after commenting' do + first('.diff-line-num').trigger('mouseover') + find('.js-add-diff-note-button').click + + page.within('.js-discussion-note-form') do + find('.note-textarea').native.send_keys('test comment') + + click_button 'Comment' + + wait_for_ajax + end + + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + + expect(page).to have_content('test comment') + expect(page).not_to have_selector('.js-avatar-container') + expect(page).not_to have_selector('.diff-comment-avatar-holders') + end + end + + %w(inline parallel).each do |view| + context "#{view} view" do + before do + visit diffs_namespace_project_merge_request_path(project.namespace, project, merge_request, view: view) + + wait_for_ajax + end + + it 'shows note avatar' do + page.within find("[id='#{position.line_code(project.repository)}']") do + find('.diff-notes-collapse').click + + expect(page).to have_selector('img.js-diff-comment-avatar', count: 1) + end + end + + it 'shows comment on note avatar' do + page.within find("[id='#{position.line_code(project.repository)}']") do + find('.diff-notes-collapse').click + + expect(first('img.js-diff-comment-avatar')["title"]).to eq("#{note.author.name}: #{note.note.truncate(17)}") + end + end + + it 'toggles comments when clicking avatar' do + page.within find("[id='#{position.line_code(project.repository)}']") do + find('.diff-notes-collapse').click + end + + expect(page).to have_selector('.notes_holder', visible: false) + + page.within find("[id='#{position.line_code(project.repository)}']") do + first('img.js-diff-comment-avatar').click + end + + expect(page).to have_selector('.notes_holder') + end + + it 'removes avatar when note is deleted' do + page.within find(".note-row-#{note.id}") do + find('.js-note-delete').click + end + + wait_for_ajax + + page.within find("[id='#{position.line_code(project.repository)}']") do + expect(page).not_to have_selector('img.js-diff-comment-avatar') + end + end + + it 'adds avatar when commenting' do + click_button 'Reply...' + + page.within '.js-discussion-note-form' do + find('.js-note-text').native.send_keys('Test') + + click_button 'Comment' + + wait_for_ajax + end + + page.within find("[id='#{position.line_code(project.repository)}']") do + find('.diff-notes-collapse').click + + expect(page).to have_selector('img.js-diff-comment-avatar', count: 2) + end + end + + it 'adds multiple comments' do + 3.times do + click_button 'Reply...' + + page.within '.js-discussion-note-form' do + find('.js-note-text').native.send_keys('Test') + + find('.js-comment-button').trigger 'click' + + wait_for_ajax + end + end + + page.within find("[id='#{position.line_code(project.repository)}']") do + find('.diff-notes-collapse').click + + expect(page).to have_selector('img.js-diff-comment-avatar', count: 3) + expect(find('.diff-comments-more-count')).to have_content '+1' + end + end + + context 'multiple comments' do + before do + create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: position) + create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: position) + create(:diff_note_on_merge_request, project: project, noteable: merge_request, position: position) + + visit diffs_namespace_project_merge_request_path(project.namespace, project, merge_request, view: view) + + wait_for_ajax + end + + it 'shows extra comment count' do + page.within find("[id='#{position.line_code(project.repository)}']") do + find('.diff-notes-collapse').click + + expect(find('.diff-comments-more-count')).to have_content '+1' + end + end + end + end + end +end diff --git a/spec/features/merge_requests/filter_by_milestone_spec.rb b/spec/features/merge_requests/filter_by_milestone_spec.rb index 5608cda28f8..265a0cfc198 100644 --- a/spec/features/merge_requests/filter_by_milestone_spec.rb +++ b/spec/features/merge_requests/filter_by_milestone_spec.rb @@ -25,6 +25,9 @@ feature 'Merge Request filtering by Milestone', feature: true do visit_merge_requests(project) input_filtered_search('milestone:none') + expect_tokens([{ name: 'milestone', value: 'none' }]) + expect_filtered_search_input_empty + expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1) expect(page).to have_css('.merge-request', count: 1) end diff --git a/spec/features/merge_requests/filter_merge_requests_spec.rb b/spec/features/merge_requests/filter_merge_requests_spec.rb index 6579a88d4ab..70e3997e716 100644 --- a/spec/features/merge_requests/filter_merge_requests_spec.rb +++ b/spec/features/merge_requests/filter_merge_requests_spec.rb @@ -24,6 +24,11 @@ describe 'Filter merge requests', feature: true do describe 'for assignee from mr#index' do let(:search_query) { "assignee:@#{user.username}" } + def expect_assignee_visual_tokens + expect_tokens([{ name: 'assignee', value: "@#{user.username}" }]) + expect_filtered_search_input_empty + end + before do input_filtered_search(search_query) @@ -32,25 +37,30 @@ describe 'Filter merge requests', feature: true do context 'assignee', js: true do it 'updates to current user' do - expect_filtered_search_input(search_query) + expect_assignee_visual_tokens() end it 'does not change when closed link is clicked' do find('.issues-state-filters a', text: "Closed").click - expect_filtered_search_input(search_query) + expect_assignee_visual_tokens() end it 'does not change when all link is clicked' do find('.issues-state-filters a', text: "All").click - expect_filtered_search_input(search_query) + expect_assignee_visual_tokens() end end end describe 'for milestone from mr#index' do - let(:search_query) { "milestone:%#{milestone.title}" } + let(:search_query) { "milestone:%\"#{milestone.title}\"" } + + def expect_milestone_visual_tokens + expect_tokens([{ name: 'milestone', value: "%\"#{milestone.title}\"" }]) + expect_filtered_search_input_empty + end before do input_filtered_search(search_query) @@ -60,19 +70,19 @@ describe 'Filter merge requests', feature: true do context 'milestone', js: true do it 'updates to current milestone' do - expect_filtered_search_input(search_query) + expect_milestone_visual_tokens() end it 'does not change when closed link is clicked' do find('.issues-state-filters a', text: "Closed").click - expect_filtered_search_input(search_query) + expect_milestone_visual_tokens() end it 'does not change when all link is clicked' do find('.issues-state-filters a', text: "All").click - expect_filtered_search_input(search_query) + expect_milestone_visual_tokens() end end end @@ -82,35 +92,44 @@ describe 'Filter merge requests', feature: true do input_filtered_search('label:none') expect_mr_list_count(1) - expect_filtered_search_input('label:none') + expect_tokens([{ name: 'label', value: 'none' }]) + expect_filtered_search_input_empty end it 'filters by a label' do input_filtered_search("label:~#{label.title}") expect_mr_list_count(0) - expect_filtered_search_input("label:~#{label.title}") + expect_tokens([{ name: 'label', value: "~#{label.title}" }]) + expect_filtered_search_input_empty end it "filters by `won't fix` and another label" do input_filtered_search("label:~\"#{wontfix.title}\" label:~#{label.title}") expect_mr_list_count(0) - expect_filtered_search_input("label:~\"#{wontfix.title}\" label:~#{label.title}") + expect_tokens([ + { name: 'label', value: "~\"#{wontfix.title}\"" }, + { name: 'label', value: "~#{label.title}" } + ]) + expect_filtered_search_input_empty end it "filters by `won't fix` label followed by another label after page load" do input_filtered_search("label:~\"#{wontfix.title}\"") expect_mr_list_count(0) - expect_filtered_search_input("label:~\"#{wontfix.title}\"") - - input_filtered_search_keys(" label:~#{label.title}") + expect_tokens([{ name: 'label', value: "~\"#{wontfix.title}\"" }]) + expect_filtered_search_input_empty - expect_filtered_search_input("label:~\"#{wontfix.title}\" label:~#{label.title}") + input_filtered_search_keys("label:~#{label.title}") expect_mr_list_count(0) - expect_filtered_search_input("label:~\"#{wontfix.title}\" label:~#{label.title}") + expect_tokens([ + { name: 'label', value: "~\"#{wontfix.title}\"" }, + { name: 'label', value: "~#{label.title}" } + ]) + expect_filtered_search_input_empty end end @@ -121,9 +140,10 @@ describe 'Filter merge requests', feature: true do input_filtered_search("assignee:@#{user.username}") expect_mr_list_count(1) - expect_filtered_search_input("assignee:@#{user.username}") + expect_tokens([{ name: 'assignee', value: "@#{user.username}" }]) + expect_filtered_search_input_empty - input_filtered_search_keys(" label:~#{label.title}") + input_filtered_search_keys("label:~#{label.title} ") expect_mr_list_count(1) @@ -131,20 +151,28 @@ describe 'Filter merge requests', feature: true do end context 'assignee and label', js: true do + def expect_assignee_label_visual_tokens + expect_tokens([ + { name: 'assignee', value: "@#{user.username}" }, + { name: 'label', value: "~#{label.title}" } + ]) + expect_filtered_search_input_empty + end + it 'updates to current assignee and label' do - expect_filtered_search_input(search_query) + expect_assignee_label_visual_tokens() end it 'does not change when closed link is clicked' do find('.issues-state-filters a', text: "Closed").click - expect_filtered_search_input(search_query) + expect_assignee_label_visual_tokens() end it 'does not change when all link is clicked' do find('.issues-state-filters a', text: "All").click - expect_filtered_search_input(search_query) + expect_assignee_label_visual_tokens() end end end @@ -195,6 +223,8 @@ describe 'Filter merge requests', feature: true do input_filtered_search_keys(' label:~bug') expect_mr_list_count(1) + expect_tokens([{ name: 'label', value: '~bug' }]) + expect_filtered_search_input('Bug') end it 'filters by text and milestone' do @@ -206,6 +236,8 @@ describe 'Filter merge requests', feature: true do input_filtered_search_keys(' milestone:%8') expect_mr_list_count(1) + expect_tokens([{ name: 'milestone', value: '%8' }]) + expect_filtered_search_input('Bug') end it 'filters by text and assignee' do @@ -217,6 +249,8 @@ describe 'Filter merge requests', feature: true do input_filtered_search_keys(" assignee:@#{user.username}") expect_mr_list_count(1) + expect_tokens([{ name: 'assignee', value: "@#{user.username}" }]) + expect_filtered_search_input('Bug') end it 'filters by text and author' do @@ -228,6 +262,8 @@ describe 'Filter merge requests', feature: true do input_filtered_search_keys(" author:@#{user.username}") expect_mr_list_count(1) + expect_tokens([{ name: 'author', value: "@#{user.username}" }]) + expect_filtered_search_input('Bug') end end end @@ -266,7 +302,8 @@ describe 'Filter merge requests', feature: true do it 'filter by current user' do visit namespace_project_merge_requests_path(project.namespace, project, assignee_id: user.id) - expect_filtered_search_input("assignee:@#{user.username}") + expect_tokens([{ name: 'assignee', value: "@#{user.username}" }]) + expect_filtered_search_input_empty end it 'filter by new user' do @@ -275,7 +312,8 @@ describe 'Filter merge requests', feature: true do visit namespace_project_merge_requests_path(project.namespace, project, assignee_id: new_user.id) - expect_filtered_search_input("assignee:@#{new_user.username}") + expect_tokens([{ name: 'assignee', value: "@#{new_user.username}" }]) + expect_filtered_search_input_empty end end @@ -283,7 +321,8 @@ describe 'Filter merge requests', feature: true do it 'filter by current user' do visit namespace_project_merge_requests_path(project.namespace, project, author_id: user.id) - expect_filtered_search_input("author:@#{user.username}") + expect_tokens([{ name: 'author', value: "@#{user.username}" }]) + expect_filtered_search_input_empty end it 'filter by new user' do @@ -292,7 +331,8 @@ describe 'Filter merge requests', feature: true do visit namespace_project_merge_requests_path(project.namespace, project, author_id: new_user.id) - expect_filtered_search_input("author:@#{new_user.username}") + expect_tokens([{ name: 'author', value: "@#{new_user.username}" }]) + expect_filtered_search_input_empty end end end diff --git a/spec/features/merge_requests/form_spec.rb b/spec/features/merge_requests/form_spec.rb index 7594cbf54e8..f8518f450dc 100644 --- a/spec/features/merge_requests/form_spec.rb +++ b/spec/features/merge_requests/form_spec.rb @@ -1,15 +1,19 @@ require 'rails_helper' describe 'New/edit merge request', feature: true, js: true do + include GitlabRoutingHelper + let!(:project) { create(:project, visibility_level: Gitlab::VisibilityLevel::PUBLIC) } let(:fork_project) { create(:project, forked_from_project: project) } let!(:user) { create(:user)} + let!(:user2) { create(:user)} let!(:milestone) { create(:milestone, project: project) } let!(:label) { create(:label, project: project) } let!(:label2) { create(:label, project: project) } before do project.team << [user, :master] + project.team << [user2, :master] end context 'owned projects' do @@ -33,8 +37,14 @@ describe 'New/edit merge request', feature: true, js: true do it 'creates new merge request' do click_button 'Assignee' page.within '.dropdown-menu-user' do - click_link user.name + click_link user2.name + end + expect(find('input[name="merge_request[assignee_id]"]', visible: false).value).to match(user2.id.to_s) + page.within '.js-assignee-search' do + expect(page).to have_content user2.name end + + click_link 'Assign to me' expect(find('input[name="merge_request[assignee_id]"]', visible: false).value).to match(user.id.to_s) page.within '.js-assignee-search' do expect(page).to have_content user.name @@ -76,6 +86,15 @@ describe 'New/edit merge request', feature: true, js: true do expect(page).to have_content label2.title end end + + page.within '.issuable-meta' do + merge_request = MergeRequest.find_by(source_branch: 'fix') + + expect(page).to have_text("Merge Request #{merge_request.to_reference}") + # compare paths because the host differ in test + expect(find_link(merge_request.to_reference)[:href]) + .to end_with(merge_request_path(merge_request)) + end end end diff --git a/spec/features/merge_requests/merge_immediately_with_pipeline_spec.rb b/spec/features/merge_requests/merge_immediately_with_pipeline_spec.rb index f2f8f11ab28..0ceaf7bc830 100644 --- a/spec/features/merge_requests/merge_immediately_with_pipeline_spec.rb +++ b/spec/features/merge_requests/merge_immediately_with_pipeline_spec.rb @@ -34,7 +34,7 @@ feature 'Merge immediately', :feature, :js do click_link 'Merge Immediately' - expect(find('.js-merge-button')).to have_content('Merge in progress') + expect(find('.js-merge-when-pipeline-succeeds-button')).to have_content('Merge in progress') end end end diff --git a/spec/features/merge_requests/merge_when_pipeline_succeeds_spec.rb b/spec/features/merge_requests/merge_when_pipeline_succeeds_spec.rb index 2ea9c317bd1..ed7193b9777 100644 --- a/spec/features/merge_requests/merge_when_pipeline_succeeds_spec.rb +++ b/spec/features/merge_requests/merge_when_pipeline_succeeds_spec.rb @@ -75,7 +75,7 @@ feature 'Merge When Pipeline Succeeds', :feature, :js do context 'when it was enabled and then canceled' do let(:merge_request) do create(:merge_request_with_diffs, - :merge_when_build_succeeds, + :merge_when_pipeline_succeeds, source_project: project, title: 'Bug NS-04', author: user, @@ -97,7 +97,7 @@ feature 'Merge When Pipeline Succeeds', :feature, :js do author: user, merge_user: user, title: 'MepMep', - merge_when_build_succeeds: true) + merge_when_pipeline_succeeds: true) end let!(:build) do diff --git a/spec/features/merge_requests/only_allow_merge_if_build_succeeds_spec.rb b/spec/features/merge_requests/only_allow_merge_if_build_succeeds_spec.rb index d2f5c4afc93..447764566e0 100644 --- a/spec/features/merge_requests/only_allow_merge_if_build_succeeds_spec.rb +++ b/spec/features/merge_requests/only_allow_merge_if_build_succeeds_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -feature 'Only allow merge requests to be merged if the build succeeds', feature: true do +feature 'Only allow merge requests to be merged if the pipeline succeeds', feature: true do let(:merge_request) { create(:merge_request_with_diffs) } let(:project) { merge_request.target_project } @@ -27,9 +27,9 @@ feature 'Only allow merge requests to be merged if the build succeeds', feature: status: status) end - context 'when merge requests can only be merged if the build succeeds' do + context 'when merge requests can only be merged if the pipeline succeeds' do before do - project.update_attribute(:only_allow_merge_if_build_succeeds, true) + project.update_attribute(:only_allow_merge_if_pipeline_succeeds, true) end context 'when CI is running' do @@ -88,7 +88,7 @@ feature 'Only allow merge requests to be merged if the build succeeds', feature: context 'when merge requests can be merged when the build failed' do before do - project.update_attribute(:only_allow_merge_if_build_succeeds, false) + project.update_attribute(:only_allow_merge_if_pipeline_succeeds, false) end context 'when CI is running' do diff --git a/spec/features/merge_requests/reset_filters_spec.rb b/spec/features/merge_requests/reset_filters_spec.rb index 58f11499e3f..14511707af4 100644 --- a/spec/features/merge_requests/reset_filters_spec.rb +++ b/spec/features/merge_requests/reset_filters_spec.rb @@ -1,6 +1,6 @@ require 'rails_helper' -feature 'Issues filter reset button', feature: true, js: true do +feature 'Merge requests filter clear button', feature: true, js: true do include FilteredSearchHelpers include MergeRequestHelpers include WaitForAjax @@ -24,67 +24,113 @@ feature 'Issues filter reset button', feature: true, js: true do context 'when a milestone filter has been applied' do it 'resets the milestone filter' do visit_merge_requests(project, milestone_title: milestone.title) + expect(page).to have_css(merge_request_css, count: 1) + expect(get_filtered_search_placeholder).to eq('') reset_filters + expect(page).to have_css(merge_request_css, count: 2) + expect(get_filtered_search_placeholder).to eq(default_placeholder) end end context 'when a label filter has been applied' do it 'resets the label filter' do visit_merge_requests(project, label_name: bug.name) + expect(page).to have_css(merge_request_css, count: 1) + expect(get_filtered_search_placeholder).to eq('') reset_filters + expect(page).to have_css(merge_request_css, count: 2) + expect(get_filtered_search_placeholder).to eq(default_placeholder) + end + end + + context 'when multiple label filters have been applied' do + let!(:label) { create(:label, project: project, name: 'Frontend') } + let(:filter_dropdown) { find("#js-dropdown-label .filter-dropdown") } + + before do + visit_merge_requests(project) + init_label_search + end + + it 'filters bug label' do + filtered_search.set('~bug') + + filter_dropdown.find('.filter-dropdown-item', text: bug.title).click + init_label_search + + expect(filter_dropdown.find('.filter-dropdown-item', text: bug.title)).to be_visible + expect(filter_dropdown.find('.filter-dropdown-item', text: label.title)).to be_visible end end context 'when a text search has been conducted' do it 'resets the text search filter' do visit_merge_requests(project, search: 'Bug') + expect(page).to have_css(merge_request_css, count: 1) + expect(get_filtered_search_placeholder).to eq('') reset_filters + expect(page).to have_css(merge_request_css, count: 2) + expect(get_filtered_search_placeholder).to eq(default_placeholder) end end context 'when author filter has been applied' do it 'resets the author filter' do visit_merge_requests(project, author_username: user.username) + expect(page).to have_css(merge_request_css, count: 1) + expect(get_filtered_search_placeholder).to eq('') reset_filters + expect(page).to have_css(merge_request_css, count: 2) + expect(get_filtered_search_placeholder).to eq(default_placeholder) end end context 'when assignee filter has been applied' do it 'resets the assignee filter' do visit_merge_requests(project, assignee_username: user.username) + expect(page).to have_css(merge_request_css, count: 1) + expect(get_filtered_search_placeholder).to eq('') reset_filters + expect(page).to have_css(merge_request_css, count: 2) + expect(get_filtered_search_placeholder).to eq(default_placeholder) end end context 'when all filters have been applied' do - it 'resets all filters' do + it 'clears all filters' do visit_merge_requests(project, assignee_username: user.username, author_username: user.username, milestone_title: milestone.title, label_name: bug.name, search: 'Bug') + expect(page).to have_css(merge_request_css, count: 0) + expect(get_filtered_search_placeholder).to eq('') reset_filters + expect(page).to have_css(merge_request_css, count: 2) + expect(get_filtered_search_placeholder).to eq(default_placeholder) end end context 'when no filters have been applied' do - it 'the reset link should not be visible' do + it 'the clear button should not be visible' do visit_merge_requests(project) + expect(page).to have_css(merge_request_css, count: 2) + expect(get_filtered_search_placeholder).to eq(default_placeholder) expect(page).not_to have_css(clear_search_css) end end diff --git a/spec/features/merge_requests/user_uses_slash_commands_spec.rb b/spec/features/merge_requests/user_uses_slash_commands_spec.rb index 2f3c3e45ae6..a1f4eb2688b 100644 --- a/spec/features/merge_requests/user_uses_slash_commands_spec.rb +++ b/spec/features/merge_requests/user_uses_slash_commands_spec.rb @@ -133,7 +133,6 @@ feature 'Merge Requests > User uses slash commands', feature: true, js: true do it 'changes target_branch in new merge_request' do visit new_namespace_project_merge_request_path(another_project.namespace, another_project, new_url_opts) - click_button "Compare branches and continue" fill_in "merge_request_title", with: 'My brand new feature' fill_in "merge_request_description", with: "le feature \n/target_branch fix\nFeature description:" diff --git a/spec/features/merge_requests/widget_spec.rb b/spec/features/merge_requests/widget_spec.rb index 4ad944366c8..c2db7d8da3c 100644 --- a/spec/features/merge_requests/widget_spec.rb +++ b/spec/features/merge_requests/widget_spec.rb @@ -3,8 +3,8 @@ require 'rails_helper' describe 'Merge request', :feature, :js do include WaitForAjax - let(:project) { create(:project) } let(:user) { create(:user) } + let(:project) { create(:project) } let(:merge_request) { create(:merge_request, source_project: project) } before do @@ -31,13 +31,18 @@ describe 'Merge request', :feature, :js do wait_for_ajax - expect(page).to have_selector('.accept_merge_request') + expect(page).to have_selector('.accept-merge-request') end end context 'view merge request' do let!(:environment) { create(:environment, project: project) } - let!(:deployment) { create(:deployment, environment: environment, ref: 'feature', sha: merge_request.diff_head_sha) } + + let!(:deployment) do + create(:deployment, environment: environment, + ref: 'feature', + sha: merge_request.diff_head_sha) + end before do visit namespace_project_merge_request_path(project.namespace, project, merge_request) @@ -51,6 +56,89 @@ describe 'Merge request', :feature, :js do expect(find('.js-environment-link')[:href]).to include(environment.formatted_external_url) end end + + it 'shows green accept merge request button' do + # Wait for the `ci_status` and `merge_check` requests + wait_for_ajax + expect(page).to have_selector('.accept-merge-request.btn-create') + end + end + + context 'view merge request with external CI service' do + before do + create(:service, project: project, + active: true, + type: 'CiService', + category: 'ci') + + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + it 'has danger button while waiting for external CI status' do + # Wait for the `ci_status` and `merge_check` requests + wait_for_ajax + expect(page).to have_selector('.accept-merge-request.btn-danger') + end + end + + context 'view merge request with failed GitLab CI pipelines' do + before do + commit_status = create(:commit_status, project: project, status: 'failed') + pipeline = create(:ci_pipeline, project: project, + sha: merge_request.diff_head_sha, + ref: merge_request.source_branch, + status: 'failed', + statuses: [commit_status]) + create(:ci_build, :pending, pipeline: pipeline) + + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + it 'has danger button when not succeeded' do + # Wait for the `ci_status` and `merge_check` requests + wait_for_ajax + expect(page).to have_selector('.accept-merge-request.btn-danger') + end + end + + context 'when merge request is in the blocked pipeline state' do + before do + create(:ci_pipeline, project: project, + sha: merge_request.diff_head_sha, + ref: merge_request.source_branch, + status: :manual) + + visit namespace_project_merge_request_path(project.namespace, + project, + merge_request) + end + + it 'shows information about blocked pipeline' do + expect(page).to have_content("Pipeline blocked") + expect(page).to have_content( + "The pipeline for this merge request requires a manual action") + expect(page).to have_css('.ci-status-icon-manual') + end + end + + context 'view merge request with MWBS button' do + before do + commit_status = create(:commit_status, project: project, status: 'pending') + pipeline = create(:ci_pipeline, project: project, + sha: merge_request.diff_head_sha, + ref: merge_request.source_branch, + status: 'pending', + statuses: [commit_status]) + create(:ci_build, :pending, pipeline: pipeline) + + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + it 'has info button when MWBS button' do + # Wait for the `ci_status` and `merge_check` requests + wait_for_ajax + expect(page).to have_selector('.merge-when-pipeline-succeeds.btn-info') + end end context 'merge error' do diff --git a/spec/features/milestone_spec.rb b/spec/features/milestone_spec.rb index a2e40546588..c3297de709a 100644 --- a/spec/features/milestone_spec.rb +++ b/spec/features/milestone_spec.rb @@ -24,7 +24,7 @@ feature 'Milestone', feature: true do find('input[name="commit"]').click expect(find('.alert-success')).to have_content('Assign some issues to this milestone.') - expect(page).to have_content('Nov 16, 2016 - Dec 16, 2016') + expect(page).to have_content('Nov 16, 2016–Dec 16, 2016') end end diff --git a/spec/features/profile_spec.rb b/spec/features/profile_spec.rb index 7a562b5e03d..e63feb14b7e 100644 --- a/spec/features/profile_spec.rb +++ b/spec/features/profile_spec.rb @@ -4,7 +4,7 @@ describe 'Profile account page', feature: true do let(:user) { create(:user) } before do - login_as :user + login_as(user) end describe 'when signup is enabled' do @@ -16,7 +16,7 @@ describe 'Profile account page', feature: true do it { expect(page).to have_content('Remove account') } it 'deletes the account' do - expect { click_link 'Delete account' }.to change { User.count }.by(-1) + expect { click_link 'Delete account' }.to change { User.where(id: user.id).count }.by(-1) expect(current_path).to eq(new_user_session_path) end end @@ -61,4 +61,18 @@ describe 'Profile account page', feature: true do expect(find('#incoming-email-token').value).not_to eq(previous_token) end end + + describe 'when I change my username' do + before do + visit profile_account_path + end + + it 'changes my username' do + fill_in 'user_username', with: 'new-username' + + click_button('Update username') + + expect(page).to have_content('new-username') + end + end end diff --git a/spec/features/profiles/keys_spec.rb b/spec/features/profiles/keys_spec.rb index eb1050d21c6..2f436f153aa 100644 --- a/spec/features/profiles/keys_spec.rb +++ b/spec/features/profiles/keys_spec.rb @@ -15,7 +15,7 @@ feature 'Profile > SSH Keys', feature: true do scenario 'auto-populates the title', js: true do fill_in('Key', with: attributes_for(:key).fetch(:key)) - expect(find_field('Title').value).to eq 'dummy@gitlab.com' + expect(page).to have_field("Title", with: "dummy@gitlab.com") end scenario 'saves the new key' do diff --git a/spec/features/profiles/personal_access_tokens_spec.rb b/spec/features/profiles/personal_access_tokens_spec.rb index eb7b8a24669..0917d4dc3ef 100644 --- a/spec/features/profiles/personal_access_tokens_spec.rb +++ b/spec/features/profiles/personal_access_tokens_spec.rb @@ -4,11 +4,11 @@ describe 'Profile > Personal Access Tokens', feature: true, js: true do let(:user) { create(:user) } def active_personal_access_tokens - find(".table.active-personal-access-tokens") + find(".table.active-tokens") end def inactive_personal_access_tokens - find(".table.inactive-personal-access-tokens") + find(".table.inactive-tokens") end def created_personal_access_token @@ -26,7 +26,7 @@ describe 'Profile > Personal Access Tokens', feature: true, js: true do end describe "token creation" do - it "allows creation of a token" do + it "allows creation of a personal access token" do name = FFaker::Product.brand visit profile_personal_access_tokens_path @@ -43,7 +43,7 @@ describe 'Profile > Personal Access Tokens', feature: true, js: true do click_on "Create Personal Access Token" expect(active_personal_access_tokens).to have_text(name) - expect(active_personal_access_tokens).to have_text(Date.today.next_month.at_beginning_of_month.to_s(:medium)) + expect(active_personal_access_tokens).to have_text('In') expect(active_personal_access_tokens).to have_text('api') expect(active_personal_access_tokens).to have_text('read_user') end @@ -60,6 +60,18 @@ describe 'Profile > Personal Access Tokens', feature: true, js: true do end end + describe 'active tokens' do + let!(:impersonation_token) { create(:personal_access_token, :impersonation, user: user) } + let!(:personal_access_token) { create(:personal_access_token, user: user) } + + it 'only shows personal access tokens' do + visit profile_personal_access_tokens_path + + expect(active_personal_access_tokens).to have_text(personal_access_token.name) + expect(active_personal_access_tokens).not_to have_text(impersonation_token.name) + end + end + describe "inactive tokens" do let!(:personal_access_token) { create(:personal_access_token, user: user) } diff --git a/spec/features/profiles/user_changes_notified_of_own_activity_spec.rb b/spec/features/profiles/user_changes_notified_of_own_activity_spec.rb deleted file mode 100644 index e05fbb3715c..00000000000 --- a/spec/features/profiles/user_changes_notified_of_own_activity_spec.rb +++ /dev/null @@ -1,32 +0,0 @@ -require 'spec_helper' - -feature 'Profile > Notifications > User changes notified_of_own_activity setting', feature: true, js: true do - let(:user) { create(:user) } - - before do - login_as(user) - end - - scenario 'User opts into receiving notifications about their own activity' do - visit profile_notifications_path - - expect(page).not_to have_checked_field('user[notified_of_own_activity]') - - check 'user[notified_of_own_activity]' - - expect(page).to have_content('Notification settings saved') - expect(page).to have_checked_field('user[notified_of_own_activity]') - end - - scenario 'User opts out of receiving notifications about their own activity' do - user.update!(notified_of_own_activity: true) - visit profile_notifications_path - - expect(page).to have_checked_field('user[notified_of_own_activity]') - - uncheck 'user[notified_of_own_activity]' - - expect(page).to have_content('Notification settings saved') - expect(page).not_to have_checked_field('user[notified_of_own_activity]') - end -end diff --git a/spec/features/projects/activity/rss_spec.rb b/spec/features/projects/activity/rss_spec.rb new file mode 100644 index 00000000000..b47c6d431eb --- /dev/null +++ b/spec/features/projects/activity/rss_spec.rb @@ -0,0 +1,29 @@ +require 'spec_helper' + +feature 'Project Activity RSS' do + let(:project) { create(:empty_project, visibility_level: Gitlab::VisibilityLevel::PUBLIC) } + let(:path) { activity_namespace_project_path(project.namespace, project) } + + before do + create(:issue, project: project) + end + + context 'when signed in' do + before do + user = create(:user) + project.team << [user, :developer] + login_as(user) + visit path + end + + it_behaves_like "it has an RSS button with current_user's private token" + end + + context 'when signed out' do + before do + visit path + end + + it_behaves_like "it has an RSS button without a private token" + end +end diff --git a/spec/features/projects/blobs/blob_line_permalink_updater_spec.rb b/spec/features/projects/blobs/blob_line_permalink_updater_spec.rb new file mode 100644 index 00000000000..d94204230f6 --- /dev/null +++ b/spec/features/projects/blobs/blob_line_permalink_updater_spec.rb @@ -0,0 +1,97 @@ +require 'spec_helper' + +feature 'Blob button line permalinks (BlobLinePermalinkUpdater)', feature: true, js: true do + include TreeHelper + + let(:project) { create(:project, :public, :repository) } + let(:path) { 'CHANGELOG' } + let(:sha) { project.repository.commit.sha } + + describe 'On a file(blob)' do + def get_absolute_url(path = "") + "http://#{page.server.host}:#{page.server.port}#{path}" + end + + def visit_blob(fragment = nil) + visit namespace_project_blob_path(project.namespace, project, tree_join('master', path), anchor: fragment) + end + + describe 'Click "Permalink" button' do + it 'works with no initial line number fragment hash' do + visit_blob + + expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(namespace_project_blob_path(project.namespace, project, tree_join(sha, path)))) + end + + it 'maintains intitial fragment hash' do + fragment = "L3" + + visit_blob(fragment) + + expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(namespace_project_blob_path(project.namespace, project, tree_join(sha, path), anchor: fragment))) + end + + it 'changes fragment hash if line number clicked' do + ending_fragment = "L5" + + visit_blob + + find('#L3').click + find("##{ending_fragment}").click + + expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(namespace_project_blob_path(project.namespace, project, tree_join(sha, path), anchor: ending_fragment))) + end + + it 'with initial fragment hash, changes fragment hash if line number clicked' do + fragment = "L1" + ending_fragment = "L5" + + visit_blob(fragment) + + find('#L3').click + find("##{ending_fragment}").click + + expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(namespace_project_blob_path(project.namespace, project, tree_join(sha, path), anchor: ending_fragment))) + end + end + + describe 'Click "Blame" button' do + it 'works with no initial line number fragment hash' do + visit_blob + + expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(namespace_project_blame_path(project.namespace, project, tree_join('master', path)))) + end + + it 'maintains intitial fragment hash' do + fragment = "L3" + + visit_blob(fragment) + + expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(namespace_project_blame_path(project.namespace, project, tree_join('master', path), anchor: fragment))) + end + + it 'changes fragment hash if line number clicked' do + ending_fragment = "L5" + + visit_blob + + find('#L3').click + find("##{ending_fragment}").click + + expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(namespace_project_blame_path(project.namespace, project, tree_join('master', path), anchor: ending_fragment))) + end + + it 'with initial fragment hash, changes fragment hash if line number clicked' do + fragment = "L1" + ending_fragment = "L5" + + visit_blob(fragment) + + find('#L3').click + find("##{ending_fragment}").click + + expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(namespace_project_blame_path(project.namespace, project, tree_join('master', path), anchor: ending_fragment))) + end + end + end +end diff --git a/spec/features/projects/blobs/user_create_spec.rb b/spec/features/projects/blobs/user_create_spec.rb new file mode 100644 index 00000000000..03d08c12612 --- /dev/null +++ b/spec/features/projects/blobs/user_create_spec.rb @@ -0,0 +1,107 @@ +require 'spec_helper' + +feature 'New blob creation', feature: true, js: true do + include WaitForAjax + + given(:user) { create(:user) } + given(:role) { :developer } + given(:project) { create(:project) } + given(:content) { 'class NextFeature\nend\n' } + + background do + login_as(user) + project.team << [user, role] + visit namespace_project_new_blob_path(project.namespace, project, 'master') + end + + def edit_file + wait_for_ajax + fill_in 'file_name', with: 'feature.rb' + execute_script("ace.edit('editor').setValue('#{content}')") + end + + def select_branch_index(index) + first('button.js-target-branch').click + wait_for_ajax + all('a[data-group="Branches"]')[index].click + end + + def create_new_branch(name) + first('button.js-target-branch').click + click_link 'Create new branch' + fill_in 'new_branch_name', with: name + click_button 'Create' + end + + def commit_file + click_button 'Commit Changes' + end + + context 'with default target branch' do + background do + edit_file + commit_file + end + + scenario 'creates the blob in the default branch' do + expect(page).to have_content 'master' + expect(page).to have_content 'successfully created' + expect(page).to have_content 'NextFeature' + end + end + + context 'with different target branch' do + background do + edit_file + select_branch_index(0) + commit_file + end + + scenario 'creates the blob in the different branch' do + expect(page).to have_content 'test' + expect(page).to have_content 'successfully created' + end + end + + context 'with a new target branch' do + given(:new_branch_name) { 'new-feature' } + + background do + edit_file + create_new_branch(new_branch_name) + commit_file + end + + scenario 'creates the blob in the new branch' do + expect(page).to have_content new_branch_name + expect(page).to have_content 'successfully created' + end + scenario 'returns you to the mr' do + expect(page).to have_content 'New Merge Request' + expect(page).to have_content "From #{new_branch_name} into master" + expect(page).to have_content 'Add new file' + end + end + + context 'the file already exist in the source branch' do + background do + Files::CreateService.new( + project, + user, + start_branch: 'master', + target_branch: 'master', + commit_message: 'Create file', + file_path: 'feature.rb', + file_content: content + ).execute + edit_file + commit_file + end + + scenario 'shows error message' do + expect(page).to have_content('Your changes could not be committed because a file with the same name already exists') + expect(page).to have_content('New File') + expect(page).to have_content('NextFeature') + end + end +end diff --git a/spec/features/projects/branches_spec.rb b/spec/features/projects/branches_spec.rb index d26a0caf036..8e0306ce83b 100644 --- a/spec/features/projects/branches_spec.rb +++ b/spec/features/projects/branches_spec.rb @@ -17,6 +17,14 @@ describe 'Branches', feature: true do repository.branches { |branch| expect(page).to have_content("#{branch.name}") } expect(page).to have_content("Protected branches can be managed in project settings") end + + it 'avoids a N+1 query in branches index' do + control_count = ActiveRecord::QueryRecorder.new { visit namespace_project_branches_path(project.namespace, project) }.count + + %w(one two three four five).each { |ref| repository.add_branch(@user, ref, 'master') } + + expect { visit namespace_project_branches_path(project.namespace, project) }.not_to exceed_query_limit(control_count) + end end describe 'Find branches' do diff --git a/spec/features/projects/commit/cherry_pick_spec.rb b/spec/features/projects/commit/cherry_pick_spec.rb index 7baf7913424..0b972d2a439 100644 --- a/spec/features/projects/commit/cherry_pick_spec.rb +++ b/spec/features/projects/commit/cherry_pick_spec.rb @@ -60,6 +60,7 @@ describe 'Cherry-pick Commits' do click_button 'Cherry-pick' end expect(page).to have_content('The commit has been successfully cherry-picked. You can now submit a merge request to get this change into the original branch.') + expect(page).to have_content("From cherry-pick-#{master_pickable_commit.short_id} into master") end end diff --git a/spec/features/projects/commit/mini_pipeline_graph_spec.rb b/spec/features/projects/commit/mini_pipeline_graph_spec.rb new file mode 100644 index 00000000000..30a2b2bcf8c --- /dev/null +++ b/spec/features/projects/commit/mini_pipeline_graph_spec.rb @@ -0,0 +1,55 @@ +require 'rails_helper' + +feature 'Mini Pipeline Graph in Commit View', :js, :feature do + include WaitForAjax + + let(:user) { create(:user) } + let(:project) { create(:project, :public) } + + before do + login_as(user) + end + + context 'when commit has pipelines' do + let(:pipeline) do + create(:ci_empty_pipeline, + project: project, + ref: project.default_branch, + sha: project.commit.sha) + end + + let(:build) do + create(:ci_build, pipeline: pipeline) + end + + before do + build.run + visit namespace_project_commit_path(project.namespace, project, project.commit.id) + end + + it 'should display a mini pipeline graph' do + expect(page).to have_selector('.mr-widget-pipeline-graph') + end + + it 'should show the builds list when stage is clicked' do + first('.mini-pipeline-graph-dropdown-toggle').click + + wait_for_ajax + + page.within '.js-builds-dropdown-list' do + expect(page).to have_selector('.ci-status-icon-running') + expect(page).to have_content(build.stage) + end + end + end + + context 'when commit does not have pipelines' do + before do + visit namespace_project_commit_path(project.namespace, project, project.commit.id) + end + + it 'should not display a mini pipeline graph' do + expect(page).not_to have_selector('.mr-widget-pipeline-graph') + end + end +end diff --git a/spec/features/projects/commit/rss_spec.rb b/spec/features/projects/commit/rss_spec.rb new file mode 100644 index 00000000000..6e0e1916f87 --- /dev/null +++ b/spec/features/projects/commit/rss_spec.rb @@ -0,0 +1,27 @@ +require 'spec_helper' + +feature 'Project Commits RSS' do + let(:project) { create(:project, :repository, visibility_level: Gitlab::VisibilityLevel::PUBLIC) } + let(:path) { namespace_project_commits_path(project.namespace, project, :master) } + + context 'when signed in' do + before do + user = create(:user) + project.team << [user, :developer] + login_as(user) + visit path + end + + it_behaves_like "it has an RSS button with current_user's private token" + it_behaves_like "an autodiscoverable RSS feed with current_user's private token" + end + + context 'when signed out' do + before do + visit path + end + + it_behaves_like "it has an RSS button without a private token" + it_behaves_like "an autodiscoverable RSS feed without a private token" + end +end diff --git a/spec/features/projects/compare_spec.rb b/spec/features/projects/compare_spec.rb index 43eb4000e58..030043d14aa 100644 --- a/spec/features/projects/compare_spec.rb +++ b/spec/features/projects/compare_spec.rb @@ -26,6 +26,14 @@ describe "Compare", js: true do click_button "Compare" expect(page).to have_content "Commits" end + + it "filters branches" do + select_using_dropdown("from", "wip") + + find(".js-compare-from-dropdown .compare-dropdown-toggle").click + + expect(find(".js-compare-from-dropdown .dropdown-content")).to have_selector("li", count: 3) + end end describe "tags" do diff --git a/spec/features/projects/developer_views_empty_project_instructions_spec.rb b/spec/features/projects/developer_views_empty_project_instructions_spec.rb index 0c51fe72ca4..2352329d58c 100644 --- a/spec/features/projects/developer_views_empty_project_instructions_spec.rb +++ b/spec/features/projects/developer_views_empty_project_instructions_spec.rb @@ -56,8 +56,14 @@ feature 'Developer views empty project instructions', feature: true do end def expect_instructions_for(protocol) - msg = :"#{protocol.downcase}_url_to_repo" - - expect(page).to have_content("git clone #{project.send(msg)}") + url = + case protocol + when 'ssh' + project.ssh_url_to_repo + when 'http' + project.http_url_to_repo(developer) + end + + expect(page).to have_content("git clone #{url}") end end diff --git a/spec/features/projects/edit_spec.rb b/spec/features/projects/edit_spec.rb index a1643fd1f43..7c319af893b 100644 --- a/spec/features/projects/edit_spec.rb +++ b/spec/features/projects/edit_spec.rb @@ -21,36 +21,28 @@ feature 'Project edit', feature: true, js: true do expect(page).to have_selector('.merge-requests-feature', visible: false) end - it 'hides merge requests section after save' do - select('Disabled', from: 'project_project_feature_attributes_merge_requests_access_level') - - expect(page).to have_selector('.merge-requests-feature', visible: false) - - click_button 'Save changes' + context 'given project with merge_requests_disabled access level' do + let(:project) { create(:project, :merge_requests_disabled) } - wait_for_ajax - - expect(page).to have_selector('.merge-requests-feature', visible: false) + it 'hides merge requests section' do + expect(page).to have_selector('.merge-requests-feature', visible: false) + end end end context 'builds select' do - it 'hides merge requests section' do + it 'hides builds select section' do select('Disabled', from: 'project_project_feature_attributes_builds_access_level') expect(page).to have_selector('.builds-feature', visible: false) end - it 'hides merge requests section after save' do - select('Disabled', from: 'project_project_feature_attributes_builds_access_level') - - expect(page).to have_selector('.builds-feature', visible: false) + context 'given project with builds_disabled access level' do + let(:project) { create(:project, :builds_disabled) } - click_button 'Save changes' - - wait_for_ajax - - expect(page).to have_selector('.builds-feature', visible: false) + it 'hides builds select section' do + expect(page).to have_selector('.builds-feature', visible: false) + end end end end diff --git a/spec/features/projects/environments/environment_metrics_spec.rb b/spec/features/projects/environments/environment_metrics_spec.rb new file mode 100644 index 00000000000..ee925e811e1 --- /dev/null +++ b/spec/features/projects/environments/environment_metrics_spec.rb @@ -0,0 +1,39 @@ +require 'spec_helper' + +feature 'Environment > Metrics', :feature do + include PrometheusHelpers + + given(:user) { create(:user) } + given(:project) { create(:prometheus_project) } + given(:pipeline) { create(:ci_pipeline, project: project) } + given(:build) { create(:ci_build, pipeline: pipeline) } + given(:environment) { create(:environment, project: project) } + given(:current_time) { Time.now.utc } + + background do + project.add_developer(user) + create(:deployment, environment: environment, deployable: build) + stub_all_prometheus_requests(environment.slug) + + login_as(user) + visit_environment(environment) + end + + around do |example| + Timecop.freeze(current_time) { example.run } + end + + context 'with deployments and related deployable present' do + scenario 'shows metrics' do + click_link('See metrics') + + expect(page).to have_css('svg.prometheus-graph') + end + end + + def visit_environment(environment) + visit namespace_project_environment_path(environment.project.namespace, + environment.project, + environment) + end +end diff --git a/spec/features/environment_spec.rb b/spec/features/projects/environments/environment_spec.rb index c203e1f20c1..e2d16e0830a 100644 --- a/spec/features/environment_spec.rb +++ b/spec/features/projects/environments/environment_spec.rb @@ -13,7 +13,7 @@ feature 'Environment', :feature do feature 'environment details page' do given!(:environment) { create(:environment, project: project) } given!(:deployment) { } - given!(:manual) { } + given!(:action) { } before do visit_environment(environment) @@ -37,13 +37,7 @@ feature 'Environment', :feature do scenario 'does show deployment SHA' do expect(page).to have_link(deployment.short_sha) - end - - scenario 'does not show a re-deploy button for deployment without build' do expect(page).not_to have_link('Re-deploy') - end - - scenario 'does not show terminal button' do expect(page).not_to have_terminal_button end end @@ -58,28 +52,28 @@ feature 'Environment', :feature do scenario 'does show build name' do expect(page).to have_link("#{build.name} (##{build.id})") - end - - scenario 'does show re-deploy button' do expect(page).to have_link('Re-deploy') - end - - scenario 'does not show terminal button' do expect(page).not_to have_terminal_button end context 'with manual action' do - given(:manual) { create(:ci_build, :manual, pipeline: pipeline, name: 'deploy to production') } + given(:action) do + create(:ci_build, :manual, pipeline: pipeline, + name: 'deploy to production') + end scenario 'does show a play button' do - expect(page).to have_link(manual.name.humanize) + expect(page).to have_link(action.name.humanize) end scenario 'does allow to play manual action' do - expect(manual).to be_skipped - expect{ click_link(manual.name.humanize) }.not_to change { Ci::Pipeline.count } - expect(page).to have_content(manual.name) - expect(manual.reload).to be_pending + expect(action).to be_manual + + expect { click_link(action.name.humanize) } + .not_to change { Ci::Pipeline.count } + + expect(page).to have_content(action.name) + expect(action.reload).to be_pending end context 'with external_url' do @@ -111,9 +105,6 @@ feature 'Environment', :feature do it 'displays a web terminal' do expect(page).to have_selector('#terminal') - end - - it 'displays a link to the environment external url' do expect(page).to have_link(nil, href: environment.external_url) end end @@ -130,11 +121,15 @@ feature 'Environment', :feature do context 'when environment is available' do context 'with stop action' do - given(:manual) { create(:ci_build, :manual, pipeline: pipeline, name: 'close_app') } - given(:deployment) { create(:deployment, environment: environment, deployable: build, on_stop: 'close_app') } + given(:action) do + create(:ci_build, :manual, pipeline: pipeline, + name: 'close_app') + end - scenario 'does show stop button' do - expect(page).to have_link('Stop') + given(:deployment) do + create(:deployment, environment: environment, + deployable: build, + on_stop: 'close_app') end scenario 'does allow to stop environment' do diff --git a/spec/features/environments_spec.rb b/spec/features/projects/environments/environments_spec.rb index 78be7d36f47..641e2cf7402 100644 --- a/spec/features/environments_spec.rb +++ b/spec/features/projects/environments/environments_spec.rb @@ -12,7 +12,7 @@ feature 'Environments page', :feature, :js do given!(:environment) { } given!(:deployment) { } - given!(:manual) { } + given!(:action) { } before do visit_environments(project) @@ -90,7 +90,7 @@ feature 'Environments page', :feature, :js do given(:pipeline) { create(:ci_pipeline, project: project) } given(:build) { create(:ci_build, pipeline: pipeline) } - given(:manual) do + given(:action) do create(:ci_build, :manual, pipeline: pipeline, name: 'deploy to production') end @@ -102,19 +102,17 @@ feature 'Environments page', :feature, :js do scenario 'does show a play button' do find('.js-dropdown-play-icon-container').click - expect(page).to have_content(manual.name.humanize) + expect(page).to have_content(action.name.humanize) end scenario 'does allow to play manual action', js: true do - expect(manual).to be_skipped + expect(action).to be_manual find('.js-dropdown-play-icon-container').click - expect(page).to have_content(manual.name.humanize) + expect(page).to have_content(action.name.humanize) - expect { click_link(manual.name.humanize) } + expect { find('.js-manual-action-link').click } .not_to change { Ci::Pipeline.count } - - expect(manual.reload).to be_pending end scenario 'does show build name and id' do @@ -144,17 +142,18 @@ feature 'Environments page', :feature, :js do end context 'with stop action' do - given(:manual) { create(:ci_build, :manual, pipeline: pipeline, name: 'close_app') } - given(:deployment) { create(:deployment, environment: environment, deployable: build, on_stop: 'close_app') } - - scenario 'does show stop button' do - expect(page).to have_selector('.stop-env-link') + given(:action) do + create(:ci_build, :manual, pipeline: pipeline, name: 'close_app') end - scenario 'starts build when stop button clicked' do - find('.stop-env-link').click + given(:deployment) do + create(:deployment, environment: environment, + deployable: build, + on_stop: 'close_app') + end - expect(page).to have_content('close_app') + scenario 'does show stop button' do + expect(page).to have_selector('.stop-env-link') end context 'for reporter' do diff --git a/spec/features/projects/files/browse_files_spec.rb b/spec/features/projects/files/browse_files_spec.rb index 69295e450d0..d281043caa3 100644 --- a/spec/features/projects/files/browse_files_spec.rb +++ b/spec/features/projects/files/browse_files_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -feature 'user checks git blame', feature: true do +feature 'user browses project', feature: true do let(:project) { create(:project) } let(:user) { create(:user) } @@ -18,4 +18,16 @@ feature 'user checks git blame', feature: true do expect(page).to have_content "Dmitriy Zaporozhets" expect(page).to have_content "Initial commit" end + + scenario 'can see raw content of LFS pointer with LFS disabled' do + allow_any_instance_of(Project).to receive(:lfs_enabled?).and_return(false) + click_link 'files' + click_link 'lfs' + click_link 'lfs_object.iso' + + expect(page).not_to have_content 'Download (1.5 MB)' + expect(page).to have_content 'version https://git-lfs.github.com/spec/v1' + expect(page).to have_content 'oid sha256:91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897' + expect(page).to have_content 'size 1575078' + end end diff --git a/spec/features/projects/files/project_owner_creates_license_file_spec.rb b/spec/features/projects/files/project_owner_creates_license_file_spec.rb index 64094af29c0..ccadc936567 100644 --- a/spec/features/projects/files/project_owner_creates_license_file_spec.rb +++ b/spec/features/projects/files/project_owner_creates_license_file_spec.rb @@ -6,7 +6,7 @@ feature 'project owner creates a license file', feature: true, js: true do let(:project_master) { create(:user) } let(:project) { create(:project) } background do - project.repository.remove_file(project_master, 'LICENSE', + project.repository.delete_file(project_master, 'LICENSE', message: 'Remove LICENSE', branch_name: 'master') project.team << [project_master, :master] login_as(project_master) @@ -25,7 +25,7 @@ feature 'project owner creates a license file', feature: true, js: true do select_template('MIT License') file_content = first('.file-editor') - expect(file_content).to have_content('The MIT License (MIT)') + expect(file_content).to have_content('MIT License') expect(file_content).to have_content("Copyright (c) #{Time.now.year} #{project.namespace.human_name}") fill_in :commit_message, with: 'Add a LICENSE file', visible: true @@ -33,7 +33,7 @@ feature 'project owner creates a license file', feature: true, js: true do expect(current_path).to eq( namespace_project_blob_path(project.namespace, project, 'master/LICENSE')) - expect(page).to have_content('The MIT License (MIT)') + expect(page).to have_content('MIT License') expect(page).to have_content("Copyright (c) #{Time.now.year} #{project.namespace.human_name}") end @@ -49,7 +49,7 @@ feature 'project owner creates a license file', feature: true, js: true do select_template('MIT License') file_content = first('.file-editor') - expect(file_content).to have_content('The MIT License (MIT)') + expect(file_content).to have_content('MIT License') expect(file_content).to have_content("Copyright (c) #{Time.now.year} #{project.namespace.human_name}") fill_in :commit_message, with: 'Add a LICENSE file', visible: true @@ -57,7 +57,7 @@ feature 'project owner creates a license file', feature: true, js: true do expect(current_path).to eq( namespace_project_blob_path(project.namespace, project, 'master/LICENSE')) - expect(page).to have_content('The MIT License (MIT)') + expect(page).to have_content('MIT License') expect(page).to have_content("Copyright (c) #{Time.now.year} #{project.namespace.human_name}") end diff --git a/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb b/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb index 4453b6d485f..420db962318 100644 --- a/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb +++ b/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb @@ -24,7 +24,7 @@ feature 'project owner sees a link to create a license file in empty project', f select_template('MIT License') file_content = first('.file-editor') - expect(file_content).to have_content('The MIT License (MIT)') + expect(file_content).to have_content('MIT License') expect(file_content).to have_content("Copyright (c) #{Time.now.year} #{project.namespace.human_name}") fill_in :commit_message, with: 'Add a LICENSE file', visible: true @@ -34,7 +34,7 @@ feature 'project owner sees a link to create a license file in empty project', f expect(current_path).to eq( namespace_project_blob_path(project.namespace, project, 'master/LICENSE')) - expect(page).to have_content('The MIT License (MIT)') + expect(page).to have_content('MIT License') expect(page).to have_content("Copyright (c) #{Time.now.year} #{project.namespace.human_name}") end diff --git a/spec/features/projects/guest_navigation_menu_spec.rb b/spec/features/projects/guest_navigation_menu_spec.rb index 8120a51c515..726469daba4 100644 --- a/spec/features/projects/guest_navigation_menu_spec.rb +++ b/spec/features/projects/guest_navigation_menu_spec.rb @@ -15,13 +15,11 @@ describe "Guest navigation menu" do within(".nav-links") do expect(page).to have_content 'Project' - expect(page).to have_content 'Activity' expect(page).to have_content 'Issues' expect(page).to have_content 'Wiki' expect(page).not_to have_content 'Repository' expect(page).not_to have_content 'Pipelines' - expect(page).not_to have_content 'Graphs' expect(page).not_to have_content 'Merge Requests' end end diff --git a/spec/features/projects/import_export/export_file_spec.rb b/spec/features/projects/import_export/export_file_spec.rb index 16dddb2a86b..40caf89dd54 100644 --- a/spec/features/projects/import_export/export_file_spec.rb +++ b/spec/features/projects/import_export/export_file_spec.rb @@ -9,7 +9,7 @@ feature 'Import/Export - project export integration test', feature: true, js: tr include ExportFileHelper let(:user) { create(:admin) } - let(:export_path) { "#{Dir::tmpdir}/import_file_spec" } + let(:export_path) { "#{Dir.tmpdir}/import_file_spec" } let(:config_hash) { YAML.load_file(Gitlab::ImportExport.config_file).deep_stringify_keys } let(:sensitive_words) { %w[pass secret token key] } diff --git a/spec/features/projects/import_export/import_file_spec.rb b/spec/features/projects/import_export/import_file_spec.rb index 3015576f6f8..2d1106ea3e8 100644 --- a/spec/features/projects/import_export/import_file_spec.rb +++ b/spec/features/projects/import_export/import_file_spec.rb @@ -4,7 +4,7 @@ feature 'Import/Export - project import integration test', feature: true, js: tr include Select2Helper let(:file) { File.join(Rails.root, 'spec', 'features', 'projects', 'import_export', 'test_project_export.tar.gz') } - let(:export_path) { "#{Dir::tmpdir}/import_file_spec" } + let(:export_path) { "#{Dir.tmpdir}/import_file_spec" } background do allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path) diff --git a/spec/features/projects/import_export/namespace_export_file_spec.rb b/spec/features/projects/import_export/namespace_export_file_spec.rb index d0bafc6168c..cb399ea55df 100644 --- a/spec/features/projects/import_export/namespace_export_file_spec.rb +++ b/spec/features/projects/import_export/namespace_export_file_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' feature 'Import/Export - Namespace export file cleanup', feature: true, js: true do - let(:export_path) { "#{Dir::tmpdir}/import_file_spec" } + let(:export_path) { "#{Dir.tmpdir}/import_file_spec" } let(:config_hash) { YAML.load_file(Gitlab::ImportExport.config_file).deep_stringify_keys } let(:project) { create(:empty_project) } diff --git a/spec/features/projects/import_export/test_project_export.tar.gz b/spec/features/projects/import_export/test_project_export.tar.gz Binary files differindex 20cdfbae24f..399c1d478c5 100644 --- a/spec/features/projects/import_export/test_project_export.tar.gz +++ b/spec/features/projects/import_export/test_project_export.tar.gz diff --git a/spec/features/projects/issuable_templates_spec.rb b/spec/features/projects/issuable_templates_spec.rb index e90a033b8c4..62d0aedda48 100644 --- a/spec/features/projects/issuable_templates_spec.rb +++ b/spec/features/projects/issuable_templates_spec.rb @@ -18,20 +18,18 @@ feature 'issuable templates', feature: true, js: true do let(:description_addition) { ' appending to description' } background do - project.repository.commit_file( + project.repository.create_file( user, '.gitlab/issue_templates/bug.md', template_content, message: 'added issue template', - branch_name: 'master', - update: false) - project.repository.commit_file( + branch_name: 'master') + project.repository.create_file( user, '.gitlab/issue_templates/test.md', longtemplate_content, message: 'added issue template', - branch_name: 'master', - update: false) + branch_name: 'master') visit edit_namespace_project_issue_path project.namespace, project, issue fill_in :'issue[title]', with: 'test issue title' end @@ -79,13 +77,12 @@ feature 'issuable templates', feature: true, js: true do let(:issue) { create(:issue, author: user, assignee: user, project: project) } background do - project.repository.commit_file( + project.repository.create_file( user, '.gitlab/issue_templates/bug.md', template_content, message: 'added issue template', - branch_name: 'master', - update: false) + branch_name: 'master') visit edit_namespace_project_issue_path project.namespace, project, issue fill_in :'issue[title]', with: 'test issue title' fill_in :'issue[description]', with: prior_description @@ -104,13 +101,12 @@ feature 'issuable templates', feature: true, js: true do let(:merge_request) { create(:merge_request, :with_diffs, source_project: project) } background do - project.repository.commit_file( + project.repository.create_file( user, '.gitlab/merge_request_templates/feature-proposal.md', template_content, message: 'added merge request template', - branch_name: 'master', - update: false) + branch_name: 'master') visit edit_namespace_project_merge_request_path project.namespace, project, merge_request fill_in :'merge_request[title]', with: 'test merge request title' end @@ -135,13 +131,12 @@ feature 'issuable templates', feature: true, js: true do fork_project.team << [fork_user, :master] create(:forked_project_link, forked_to_project: fork_project, forked_from_project: project) login_as fork_user - project.repository.commit_file( + project.repository.create_file( fork_user, '.gitlab/merge_request_templates/feature-proposal.md', template_content, message: 'added merge request template', - branch_name: 'master', - update: false) + branch_name: 'master') visit edit_namespace_project_merge_request_path project.namespace, project, merge_request fill_in :'merge_request[title]', with: 'test merge request title' end diff --git a/spec/features/projects/issues/rss_spec.rb b/spec/features/projects/issues/rss_spec.rb new file mode 100644 index 00000000000..71429f00095 --- /dev/null +++ b/spec/features/projects/issues/rss_spec.rb @@ -0,0 +1,31 @@ +require 'spec_helper' + +feature 'Project Issues RSS' do + let(:project) { create(:empty_project, visibility_level: Gitlab::VisibilityLevel::PUBLIC) } + let(:path) { namespace_project_issues_path(project.namespace, project) } + + before do + create(:issue, project: project) + end + + context 'when signed in' do + before do + user = create(:user) + project.team << [user, :developer] + login_as(user) + visit path + end + + it_behaves_like "it has an RSS button with current_user's private token" + it_behaves_like "an autodiscoverable RSS feed with current_user's private token" + end + + context 'when signed out' do + before do + visit path + end + + it_behaves_like "it has an RSS button without a private token" + it_behaves_like "an autodiscoverable RSS feed without a private token" + end +end diff --git a/spec/features/projects/labels/issues_sorted_by_priority_spec.rb b/spec/features/projects/labels/issues_sorted_by_priority_spec.rb index 81b0c991d4f..e2911a37e40 100644 --- a/spec/features/projects/labels/issues_sorted_by_priority_spec.rb +++ b/spec/features/projects/labels/issues_sorted_by_priority_spec.rb @@ -29,15 +29,15 @@ feature 'Issue prioritization', feature: true do issue_1.labels << label_5 login_as user - visit namespace_project_issues_path(project.namespace, project, sort: 'priority') + visit namespace_project_issues_path(project.namespace, project, sort: 'label_priority') # Ensure we are indicating that issues are sorted by priority - expect(page).to have_selector('.dropdown-toggle', text: 'Priority') + expect(page).to have_selector('.dropdown-toggle', text: 'Label priority') page.within('.issues-holder') do issue_titles = all('.issues-list .issue-title-text').map(&:text) - expect(issue_titles).to eq(['issue_4', 'issue_3', 'issue_5', 'issue_2', 'issue_1']) + expect(issue_titles).to eq(%w(issue_4 issue_3 issue_5 issue_2 issue_1)) end end end @@ -68,16 +68,16 @@ feature 'Issue prioritization', feature: true do issue_6.labels << label_5 # 8 - No priority login_as user - visit namespace_project_issues_path(project.namespace, project, sort: 'priority') + visit namespace_project_issues_path(project.namespace, project, sort: 'label_priority') - expect(page).to have_selector('.dropdown-toggle', text: 'Priority') + expect(page).to have_selector('.dropdown-toggle', text: 'Label priority') page.within('.issues-holder') do issue_titles = all('.issues-list .issue-title-text').map(&:text) expect(issue_titles[0..1]).to contain_exactly('issue_5', 'issue_8') expect(issue_titles[2..4]).to contain_exactly('issue_1', 'issue_3', 'issue_7') - expect(issue_titles[5..-1]).to eq(['issue_2', 'issue_4', 'issue_6']) + expect(issue_titles[5..-1]).to eq(%w(issue_2 issue_4 issue_6)) end end end diff --git a/spec/features/projects/main/rss_spec.rb b/spec/features/projects/main/rss_spec.rb new file mode 100644 index 00000000000..b1a3af612a1 --- /dev/null +++ b/spec/features/projects/main/rss_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +feature 'Project RSS' do + let(:project) { create(:project, :repository, visibility_level: Gitlab::VisibilityLevel::PUBLIC) } + let(:path) { namespace_project_path(project.namespace, project) } + + context 'when signed in' do + before do + user = create(:user) + project.team << [user, :developer] + login_as(user) + visit path + end + + it_behaves_like "an autodiscoverable RSS feed with current_user's private token" + end + + context 'when signed out' do + before do + visit path + end + + it_behaves_like "an autodiscoverable RSS feed without a private token" + end +end diff --git a/spec/features/projects/members/sorting_spec.rb b/spec/features/projects/members/sorting_spec.rb index d6ebb523f95..c7a32a65e49 100644 --- a/spec/features/projects/members/sorting_spec.rb +++ b/spec/features/projects/members/sorting_spec.rb @@ -85,7 +85,7 @@ feature 'Projects > Members > Sorting', feature: true do end def visit_members_list(sort:) - visit namespace_project_project_members_path(project.namespace.to_param, project.to_param, sort: sort) + visit namespace_project_project_members_path(project.namespace.to_param, project, sort: sort) end def first_member diff --git a/spec/features/projects/members/user_requests_access_spec.rb b/spec/features/projects/members/user_requests_access_spec.rb index 0b4dcaa39c6..b64c15e0adc 100644 --- a/spec/features/projects/members/user_requests_access_spec.rb +++ b/spec/features/projects/members/user_requests_access_spec.rb @@ -57,6 +57,12 @@ feature 'Projects > Members > User requests access', feature: true do end def open_project_settings_menu - find('#project-settings-button').click + page.within('.layout-nav .nav-links') do + click_link('Settings') + end + + page.within('.page-with-layout-nav .sub-nav') do + click_link('Members') + end end end diff --git a/spec/features/projects/milestones/milestone_spec.rb b/spec/features/projects/milestones/milestone_spec.rb new file mode 100644 index 00000000000..df229d0aa78 --- /dev/null +++ b/spec/features/projects/milestones/milestone_spec.rb @@ -0,0 +1,64 @@ +require 'spec_helper' + +feature 'Project milestone', :feature do + let(:user) { create(:user) } + let(:project) { create(:empty_project, name: 'test', namespace: user.namespace) } + let(:milestone) { create(:milestone, project: project) } + + before do + login_as(user) + end + + context 'when project has enabled issues' do + before do + visit namespace_project_milestone_path(project.namespace, project, milestone) + end + + it 'shows issues tab' do + within('#content-body') do + expect(page).to have_link 'Issues', href: '#tab-issues' + expect(page).to have_selector '.nav-links li.active', count: 1 + expect(find('.nav-links li.active')).to have_content 'Issues' + end + end + + it 'shows issues stats' do + expect(page).to have_content 'issues:' + end + + it 'shows Browse Issues button' do + within('#content-body') do + expect(page).to have_link 'Browse Issues' + end + end + end + + context 'when project has disabled issues' do + before do + project.project_feature.update_attribute(:issues_access_level, ProjectFeature::DISABLED) + visit namespace_project_milestone_path(project.namespace, project, milestone) + end + + it 'hides issues tab' do + within('#content-body') do + expect(page).not_to have_link 'Issues', href: '#tab-issues' + expect(page).to have_selector '.nav-links li.active', count: 1 + expect(find('.nav-links li.active')).to have_content 'Merge Requests' + end + end + + it 'hides issues stats' do + expect(page).to have_no_content 'issues:' + end + + it 'hides Browse Issues button' do + within('#content-body') do + expect(page).not_to have_link 'Browse Issues' + end + end + + it 'does not show an informative message' do + expect(page).not_to have_content('Assign some issues to this milestone.') + end + end +end diff --git a/spec/features/projects/new_project_spec.rb b/spec/features/projects/new_project_spec.rb index 45185f2dd1f..52196ce49bd 100644 --- a/spec/features/projects/new_project_spec.rb +++ b/spec/features/projects/new_project_spec.rb @@ -16,6 +16,15 @@ feature "New project", feature: true do expect(find_field("project_visibility_level_#{level}")).to be_checked end + + it 'saves visibility level on validation error' do + visit new_project_path + + choose(key) + click_button('Create project') + + expect(find_field("project_visibility_level_#{level}")).to be_checked + end end end diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb index 8d1214dedb4..162056671e0 100644 --- a/spec/features/projects/pipelines/pipelines_spec.rb +++ b/spec/features/projects/pipelines/pipelines_spec.rb @@ -26,22 +26,66 @@ describe 'Pipelines', :feature, :js do ) end - [:all, :running, :branches].each do |scope| - context "when displaying #{scope}" do - before do - visit_project_pipelines(scope: scope) - end + context 'scope' do + before do + create(:ci_empty_pipeline, status: 'pending', project: project, sha: project.commit.id, ref: 'master') + create(:ci_empty_pipeline, status: 'running', project: project, sha: project.commit.id, ref: 'master') + create(:ci_empty_pipeline, status: 'created', project: project, sha: project.commit.id, ref: 'master') + create(:ci_empty_pipeline, status: 'success', project: project, sha: project.commit.id, ref: 'master') + end - it 'contains pipeline commit short SHA' do - expect(page).to have_content(pipeline.short_sha) - end + [:all, :running, :pending, :finished, :branches].each do |scope| + context "when displaying #{scope}" do + before do + visit_project_pipelines(scope: scope) + end - it 'contains branch name' do - expect(page).to have_content(pipeline.ref) + it 'contains pipeline commit short SHA' do + expect(page).to have_content(pipeline.short_sha) + end + + it 'contains branch name' do + expect(page).to have_content(pipeline.ref) + end end end end + context 'header tabs' do + before do + visit namespace_project_pipelines_path(project.namespace, project) + wait_for_vue_resource + end + + it 'shows a tab for All pipelines and count' do + expect(page.find('.js-pipelines-tab-all a').text).to include('All') + expect(page.find('.js-pipelines-tab-all .badge').text).to include('1') + end + + it 'shows a tab for Pending pipelines and count' do + expect(page.find('.js-pipelines-tab-pending a').text).to include('Pending') + expect(page.find('.js-pipelines-tab-pending .badge').text).to include('0') + end + + it 'shows a tab for Running pipelines and count' do + expect(page.find('.js-pipelines-tab-running a').text).to include('Running') + expect(page.find('.js-pipelines-tab-running .badge').text).to include('1') + end + + it 'shows a tab for Finished pipelines and count' do + expect(page.find('.js-pipelines-tab-finished a').text).to include('Finished') + expect(page.find('.js-pipelines-tab-finished .badge').text).to include('0') + end + + it 'shows a tab for Branches' do + expect(page.find('.js-pipelines-tab-branches a').text).to include('Branches') + end + + it 'shows a tab for Tags' do + expect(page.find('.js-pipelines-tab-tags a').text).to include('Tags') + end + end + context 'when pipeline is cancelable' do let!(:build) do create(:ci_build, pipeline: pipeline, @@ -55,15 +99,18 @@ describe 'Pipelines', :feature, :js do end it 'indicates that pipeline can be canceled' do - expect(page).to have_link('Cancel') + expect(page).to have_selector('.js-pipelines-cancel-button') expect(page).to have_selector('.ci-running') end context 'when canceling' do - before { click_link('Cancel') } + before do + find('.js-pipelines-cancel-button').click + wait_for_vue_resource + end it 'indicated that pipelines was canceled' do - expect(page).not_to have_link('Cancel') + expect(page).not_to have_selector('.js-pipelines-cancel-button') expect(page).to have_selector('.ci-canceled') end end @@ -82,15 +129,18 @@ describe 'Pipelines', :feature, :js do end it 'indicates that pipeline can be retried' do - expect(page).to have_link('Retry') + expect(page).to have_selector('.js-pipelines-retry-button') expect(page).to have_selector('.ci-failed') end context 'when retrying' do - before { click_link('Retry') } + before do + find('.js-pipelines-retry-button').click + wait_for_vue_resource + end it 'shows running pipeline that is not retryable' do - expect(page).not_to have_link('Retry') + expect(page).not_to have_selector('.js-pipelines-retry-button') expect(page).to have_selector('.ci-running') end end @@ -132,17 +182,17 @@ describe 'Pipelines', :feature, :js do it 'has link to the manual action' do find('.js-pipeline-dropdown-manual-actions').click - expect(page).to have_link('manual build') + expect(page).to have_button('manual build') end context 'when manual action was played' do before do find('.js-pipeline-dropdown-manual-actions').click - click_link('manual build') + click_button('manual build') end it 'enqueues manual action job' do - expect(manual.reload).to be_pending + expect(page).to have_selector('.js-pipeline-dropdown-manual-actions:disabled') end end end @@ -159,7 +209,7 @@ describe 'Pipelines', :feature, :js do before { visit_project_pipelines } it 'is cancelable' do - expect(page).to have_link('Cancel') + expect(page).to have_selector('.js-pipelines-cancel-button') end it 'has pipeline running' do @@ -167,10 +217,10 @@ describe 'Pipelines', :feature, :js do end context 'when canceling' do - before { click_link('Cancel') } + before { find('.js-pipelines-cancel-button').trigger('click') } it 'indicates that pipeline was canceled' do - expect(page).not_to have_link('Cancel') + expect(page).not_to have_selector('.js-pipelines-cancel-button') expect(page).to have_selector('.ci-canceled') end end @@ -189,7 +239,7 @@ describe 'Pipelines', :feature, :js do end it 'is not retryable' do - expect(page).not_to have_link('Retry') + expect(page).not_to have_selector('.js-pipelines-retry-button') end it 'has failed pipeline' do @@ -284,6 +334,18 @@ describe 'Pipelines', :feature, :js do expect(build.reload).to be_canceled end end + + context 'dropdown jobs list' do + it 'should keep the dropdown open when the user ctr/cmd + clicks in the job name' do + find('.js-builds-dropdown-button').trigger('click') + + execute_script('var e = $.Event("keydown", { keyCode: 64 }); $("body").trigger(e);') + + find('.mini-pipeline-graph-dropdown-item').trigger('click') + + expect(page).to have_selector('.js-ci-action-icon') + end + end end context 'with pagination' do @@ -315,8 +377,14 @@ describe 'Pipelines', :feature, :js do visit new_namespace_project_pipeline_path(project.namespace, project) end - context 'for valid commit' do - before { fill_in('pipeline[ref]', with: 'master') } + context 'for valid commit', js: true do + before do + click_button project.default_branch + + page.within '.dropdown-menu' do + click_link 'master' + end + end context 'with gitlab-ci.yml' do before { stub_ci_pipeline_to_return_yaml_file } @@ -333,15 +401,6 @@ describe 'Pipelines', :feature, :js do it { expect(page).to have_content('Missing .gitlab-ci.yml file') } end end - - context 'for invalid commit' do - before do - fill_in('pipeline[ref]', with: 'invalid-reference') - click_on 'Create pipeline' - end - - it { expect(page).to have_content('Reference not found') } - end end describe 'Create pipelines' do @@ -353,18 +412,22 @@ describe 'Pipelines', :feature, :js do describe 'new pipeline page' do it 'has field to add a new pipeline' do - expect(page).to have_field('pipeline[ref]') + expect(page).to have_selector('.js-branch-select') + expect(find('.js-branch-select')).to have_content project.default_branch expect(page).to have_content('Create for') end end describe 'find pipelines' do it 'shows filtered pipelines', js: true do - fill_in('pipeline[ref]', with: 'fix') - find('input#ref').native.send_keys(:keydown) + click_button project.default_branch + + page.within '.dropdown-menu' do + find('.dropdown-input-field').native.send_keys('fix') - within('.ui-autocomplete') do - expect(page).to have_selector('li', text: 'fix') + page.within '.dropdown-content' do + expect(page).to have_content('fix') + end end end end diff --git a/spec/features/projects/services/mattermost_slash_command_spec.rb b/spec/features/projects/services/mattermost_slash_command_spec.rb index f5adb53a2dc..24d22a092d4 100644 --- a/spec/features/projects/services/mattermost_slash_command_spec.rb +++ b/spec/features/projects/services/mattermost_slash_command_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -feature 'Setup Mattermost slash commands', feature: true do +feature 'Setup Mattermost slash commands', :feature, :js do let(:user) { create(:user) } let(:project) { create(:empty_project) } let(:service) { project.create_mattermost_slash_commands_service } @@ -62,11 +62,11 @@ feature 'Setup Mattermost slash commands', feature: true do click_link 'Add to Mattermost' - team_name = teams.first[1]['display_name'] - select_element = find('select#mattermost_team_id') + team_name = teams.first['display_name'] + select_element = find('#mattermost_team_id') selected_option = select_element.find('option[selected]') - expect(select_element['disabled']).to eq('disabled') + expect(select_element['disabled']).to be(true) expect(selected_option).to have_content(team_name.to_s) end @@ -75,7 +75,7 @@ feature 'Setup Mattermost slash commands', feature: true do click_link 'Add to Mattermost' - expect(find('input#mattermost_team_id', visible: false).value).to eq(teams.first[0].to_s) + expect(find('input#mattermost_team_id', visible: false).value).to eq(teams.first['id']) end it 'shows an explanation user is a member of multiple teams' do @@ -92,12 +92,9 @@ feature 'Setup Mattermost slash commands', feature: true do click_link 'Add to Mattermost' - select_element = find('select#mattermost_team_id') - selected_option = select_element.find('option[selected]') + select_element = find('#mattermost_team_id') - expect(select_element['disabled']).to be(nil) - expect(selected_option).to have_content('Select team...') - # The 'Select team...' placeholder is item `0`. + expect(select_element['disabled']).to be(false) expect(select_element.all('option').count).to eq(3) end @@ -110,20 +107,37 @@ feature 'Setup Mattermost slash commands', feature: true do expect(page).to have_content('test mattermost error message') end + it 'enables the submit button if the required fields are provided', :js do + stub_teams(count: 1) + + click_link 'Add to Mattermost' + + expect(find('input[type="submit"]')['disabled']).not_to be(true) + end + + it 'disables the submit button if the required fields are not provided', :js do + stub_teams(count: 1) + + click_link 'Add to Mattermost' + + fill_in('mattermost_trigger', with: '') + + expect(find('input[type="submit"]')['disabled']).to be(true) + end + def stub_teams(count: 0) teams = create_teams(count) - allow_any_instance_of(MattermostSlashCommandsService).to receive(:list_teams) { teams } + allow_any_instance_of(MattermostSlashCommandsService).to receive(:list_teams) { [teams, nil] } teams end def create_teams(count = 0) - teams = {} + teams = [] count.times do |i| - i += 1 - teams[i] = { id: i, display_name: i } + teams.push({ "id" => "x#{i}", "display_name" => "x#{i}-name" }) end teams diff --git a/spec/features/projects/services/slack_service_spec.rb b/spec/features/projects/services/slack_service_spec.rb index 16541f51d98..c0a4a1e4bf5 100644 --- a/spec/features/projects/services/slack_service_spec.rb +++ b/spec/features/projects/services/slack_service_spec.rb @@ -7,7 +7,7 @@ feature 'Projects > Slack service > Setup events', feature: true do background do service.fields - service.update_attributes(push_channel: 1, issue_channel: 2, merge_request_channel: 3, note_channel: 4, tag_push_channel: 5, build_channel: 6, wiki_page_channel: 7) + service.update_attributes(push_channel: 1, issue_channel: 2, merge_request_channel: 3, note_channel: 4, tag_push_channel: 5, pipeline_channel: 6, wiki_page_channel: 7) project.team << [user, :master] login_as(user) end @@ -20,7 +20,7 @@ feature 'Projects > Slack service > Setup events', feature: true do expect(page.find_field("service_merge_request_channel").value).to have_content '3' expect(page.find_field("service_note_channel").value).to have_content '4' expect(page.find_field("service_tag_push_channel").value).to have_content '5' - expect(page.find_field("service_build_channel").value).to have_content '6' + expect(page.find_field("service_pipeline_channel").value).to have_content '6' expect(page.find_field("service_wiki_page_channel").value).to have_content '7' end end diff --git a/spec/features/projects/settings/merge_requests_settings_spec.rb b/spec/features/projects/settings/merge_requests_settings_spec.rb index 6815039d5ed..321af416c91 100644 --- a/spec/features/projects/settings/merge_requests_settings_spec.rb +++ b/spec/features/projects/settings/merge_requests_settings_spec.rb @@ -62,4 +62,27 @@ feature 'Project settings > Merge Requests', feature: true, js: true do expect(page).to have_content('Only allow merge requests to be merged if all discussions are resolved') end end + + describe 'Checkbox to enable merge request link' do + before do + visit edit_project_path(project) + end + + scenario 'is initially checked' do + checkbox = find_field('project_printing_merge_request_link_enabled') + expect(checkbox).to be_checked + end + + scenario 'when unchecked sets :printing_merge_request_link_enabled to false' do + uncheck('project_printing_merge_request_link_enabled') + click_on('Save') + + # Wait for save to complete and page to reload + checkbox = find_field('project_printing_merge_request_link_enabled') + expect(checkbox).not_to be_checked + + project.reload + expect(project.printing_merge_request_link_enabled).to be(false) + end + end end diff --git a/spec/features/projects/tree/rss_spec.rb b/spec/features/projects/tree/rss_spec.rb new file mode 100644 index 00000000000..9ac51997d65 --- /dev/null +++ b/spec/features/projects/tree/rss_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +feature 'Project Tree RSS' do + let(:project) { create(:project, :repository, visibility_level: Gitlab::VisibilityLevel::PUBLIC) } + let(:path) { namespace_project_tree_path(project.namespace, project, :master) } + + context 'when signed in' do + before do + user = create(:user) + project.team << [user, :developer] + login_as(user) + visit path + end + + it_behaves_like "an autodiscoverable RSS feed with current_user's private token" + end + + context 'when signed out' do + before do + visit path + end + + it_behaves_like "an autodiscoverable RSS feed without a private token" + end +end diff --git a/spec/features/projects/wiki/user_git_access_wiki_page_spec.rb b/spec/features/projects/wiki/user_git_access_wiki_page_spec.rb new file mode 100644 index 00000000000..6825b95c8aa --- /dev/null +++ b/spec/features/projects/wiki/user_git_access_wiki_page_spec.rb @@ -0,0 +1,26 @@ +require 'spec_helper' + +describe 'Projects > Wiki > User views Git access wiki page', :feature do + let(:user) { create(:user) } + let(:project) { create(:project, :public) } + let(:wiki_page) do + WikiPages::CreateService.new( + project, + user, + title: 'home', + content: '[some link](other-page)' + ).execute + end + + before do + login_as(user) + end + + scenario 'Visit Wiki Page Current Commit' do + visit namespace_project_wiki_path(project.namespace, project, wiki_page) + + click_link 'Clone repository' + expect(page).to have_text("Clone repository #{project.wiki.path_with_namespace}") + expect(page).to have_text(project.wiki.http_url_to_repo(user)) + end +end diff --git a/spec/features/projects/wiki/user_updates_wiki_page_spec.rb b/spec/features/projects/wiki/user_updates_wiki_page_spec.rb index f842d14fa96..aedc0333cb9 100644 --- a/spec/features/projects/wiki/user_updates_wiki_page_spec.rb +++ b/spec/features/projects/wiki/user_updates_wiki_page_spec.rb @@ -15,15 +15,30 @@ feature 'Projects > Wiki > User updates wiki page', feature: true do context 'in the user namespace' do let(:project) { create(:project, namespace: user.namespace) } - scenario 'the home page' do - click_link 'Edit' - - fill_in :wiki_content, with: 'My awesome wiki!' - click_button 'Save changes' - - expect(page).to have_content('Home') - expect(page).to have_content("Last edited by #{user.name}") - expect(page).to have_content('My awesome wiki!') + context 'the home page' do + scenario 'success when the wiki content is not empty' do + click_link 'Edit' + + fill_in :wiki_content, with: 'My awesome wiki!' + click_button 'Save changes' + + expect(page).to have_content('Home') + expect(page).to have_content("Last edited by #{user.name}") + expect(page).to have_content('My awesome wiki!') + end + + scenario 'failure when the wiki content is empty' do + click_link 'Edit' + + fill_in :wiki_content, with: '' + click_button 'Save changes' + + expect(page).to have_selector('.wiki-form') + expect(page).to have_content('Edit Page') + expect(page).to have_content('The form contains the following error:') + expect(page).to have_content('Content can\'t be blank') + expect(find('textarea#wiki_content').value).to eq '' + end end end diff --git a/spec/features/projects/wiki/user_views_project_wiki_page_spec.rb b/spec/features/projects/wiki/user_views_project_wiki_page_spec.rb new file mode 100644 index 00000000000..c17e06612de --- /dev/null +++ b/spec/features/projects/wiki/user_views_project_wiki_page_spec.rb @@ -0,0 +1,44 @@ +require 'spec_helper' + +feature 'Projects > Wiki > User views the wiki page', feature: true do + let(:user) { create(:user) } + let(:project) { create(:project, :public) } + let(:old_page_version_id) { wiki_page.versions.last.id } + let(:wiki_page) do + WikiPages::CreateService.new( + project, + user, + title: 'home', + content: '[some link](other-page)' + ).execute + end + + background do + project.team << [user, :master] + login_as(user) + WikiPages::UpdateService.new( + project, + user, + message: 'updated home', + content: 'updated [some link](other-page)', + format: :markdown + ).execute(wiki_page) + end + + scenario 'Visit Wiki Page Current Commit' do + visit namespace_project_wiki_path(project.namespace, project, wiki_page) + + expect(page).to have_selector('a.btn', text: 'Edit') + end + + scenario 'Visit Wiki Page Historical Commit' do + visit namespace_project_wiki_path( + project.namespace, + project, + wiki_page, + version_id: old_page_version_id + ) + + expect(page).not_to have_selector('a.btn', text: 'Edit') + end +end diff --git a/spec/features/projects_spec.rb b/spec/features/projects_spec.rb index c30d38b6508..ba56030e28d 100644 --- a/spec/features/projects_spec.rb +++ b/spec/features/projects_spec.rb @@ -18,7 +18,7 @@ feature 'Project', feature: true do it 'passes through html-pipeline' do project.update_attribute(:description, 'This project is the :poop:') visit path - expect(page).to have_css('.project-home-desc > p > img') + expect(page).to have_css('.project-home-desc > p > gl-emoji') end it 'sanitizes unwanted tags' do @@ -56,7 +56,7 @@ feature 'Project', feature: true do end describe 'removal', js: true do - let(:user) { create(:user) } + let(:user) { create(:user, username: 'test', name: 'test') } let(:project) { create(:project, namespace: user.namespace, name: 'project1') } before do @@ -67,7 +67,7 @@ feature 'Project', feature: true do it 'removes a project' do expect { remove_with_confirm('Remove project', project.path) }.to change {Project.count}.by(-1) - expect(page).to have_content "Project 'project1' will be deleted." + expect(page).to have_content "Project 'test / project1' will be deleted." expect(Project.all.count).to be_zero expect(project.issues).to be_empty expect(project.merge_requests).to be_empty diff --git a/spec/features/search_spec.rb b/spec/features/search_spec.rb index 7da05defa81..a6560a81096 100644 --- a/spec/features/search_spec.rb +++ b/spec/features/search_spec.rb @@ -1,6 +1,7 @@ require 'spec_helper' describe "Search", feature: true do + include FilteredSearchHelpers include WaitForAjax let(:user) { create(:user) } @@ -170,7 +171,8 @@ describe "Search", feature: true do sleep 2 expect(page).to have_selector('.filtered-search') - expect(find('.filtered-search').value).to eq("assignee:@#{user.username}") + expect_tokens([{ name: 'assignee', value: "@#{user.username}" }]) + expect_filtered_search_input_empty end it 'takes user to her issues page when issues authored is clicked' do @@ -178,7 +180,8 @@ describe "Search", feature: true do sleep 2 expect(page).to have_selector('.filtered-search') - expect(find('.filtered-search').value).to eq("author:@#{user.username}") + expect_tokens([{ name: 'author', value: "@#{user.username}" }]) + expect_filtered_search_input_empty end it 'takes user to her MR page when MR assigned is clicked' do @@ -186,7 +189,8 @@ describe "Search", feature: true do sleep 2 expect(page).to have_selector('.merge-requests-holder') - expect(find('.filtered-search').value).to eq("assignee:@#{user.username}") + expect_tokens([{ name: 'assignee', value: "@#{user.username}" }]) + expect_filtered_search_input_empty end it 'takes user to her MR page when MR authored is clicked' do @@ -194,7 +198,8 @@ describe "Search", feature: true do sleep 2 expect(page).to have_selector('.merge-requests-holder') - expect(find('.filtered-search').value).to eq("author:@#{user.username}") + expect_tokens([{ name: 'author', value: "@#{user.username}" }]) + expect_filtered_search_input_empty end end diff --git a/spec/features/security/project/internal_access_spec.rb b/spec/features/security/project/internal_access_spec.rb index 4e7a2c0ecc0..350de2e5b6b 100644 --- a/spec/features/security/project/internal_access_spec.rb +++ b/spec/features/security/project/internal_access_spec.rb @@ -110,6 +110,20 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_denied_for(:external) } end + describe "GET /:project_path/settings/repository" do + subject { namespace_project_settings_repository_path(project.namespace, project) } + + it { is_expected.to be_allowed_for(:admin) } + it { is_expected.to be_allowed_for(:owner).of(project) } + it { is_expected.to be_allowed_for(:master).of(project) } + it { is_expected.to be_denied_for(:developer).of(project) } + it { is_expected.to be_denied_for(:reporter).of(project) } + it { is_expected.to be_denied_for(:guest).of(project) } + it { is_expected.to be_denied_for(:user) } + it { is_expected.to be_denied_for(:visitor) } + it { is_expected.to be_denied_for(:external) } + end + describe "GET /:project_path/blob" do let(:commit) { project.repository.commit } subject { namespace_project_blob_path(project.namespace, project, File.join(commit.id, '.gitignore')) } diff --git a/spec/features/security/project/private_access_spec.rb b/spec/features/security/project/private_access_spec.rb index c74cdc05593..62364206440 100644 --- a/spec/features/security/project/private_access_spec.rb +++ b/spec/features/security/project/private_access_spec.rb @@ -110,6 +110,20 @@ describe "Private Project Access", feature: true do it { is_expected.to be_denied_for(:external) } end + describe "GET /:project_path/settings/repository" do + subject { namespace_project_settings_repository_path(project.namespace, project) } + + it { is_expected.to be_allowed_for(:admin) } + it { is_expected.to be_allowed_for(:owner).of(project) } + it { is_expected.to be_allowed_for(:master).of(project) } + it { is_expected.to be_denied_for(:developer).of(project) } + it { is_expected.to be_denied_for(:reporter).of(project) } + it { is_expected.to be_denied_for(:guest).of(project) } + it { is_expected.to be_denied_for(:user) } + it { is_expected.to be_denied_for(:external) } + it { is_expected.to be_denied_for(:visitor) } + end + describe "GET /:project_path/blob" do let(:commit) { project.repository.commit } subject { namespace_project_blob_path(project.namespace, project, File.join(commit.id, '.gitignore'))} diff --git a/spec/features/security/project/public_access_spec.rb b/spec/features/security/project/public_access_spec.rb index 485ef335b78..0e0c3140fd0 100644 --- a/spec/features/security/project/public_access_spec.rb +++ b/spec/features/security/project/public_access_spec.rb @@ -110,6 +110,20 @@ describe "Public Project Access", feature: true do it { is_expected.to be_denied_for(:external) } end + describe "GET /:project_path/settings/repository" do + subject { namespace_project_settings_repository_path(project.namespace, project) } + + it { is_expected.to be_allowed_for(:admin) } + it { is_expected.to be_allowed_for(:owner).of(project) } + it { is_expected.to be_allowed_for(:master).of(project) } + it { is_expected.to be_denied_for(:developer).of(project) } + it { is_expected.to be_denied_for(:reporter).of(project) } + it { is_expected.to be_denied_for(:guest).of(project) } + it { is_expected.to be_denied_for(:user) } + it { is_expected.to be_denied_for(:visitor) } + it { is_expected.to be_denied_for(:external) } + end + describe "GET /:project_path/pipelines" do subject { namespace_project_pipelines_path(project.namespace, project) } diff --git a/spec/features/tags/master_deletes_tag_spec.rb b/spec/features/tags/master_deletes_tag_spec.rb index 0f30f562539..ccfafe6db7d 100644 --- a/spec/features/tags/master_deletes_tag_spec.rb +++ b/spec/features/tags/master_deletes_tag_spec.rb @@ -10,16 +10,12 @@ feature 'Master deletes tag', feature: true do visit namespace_project_tags_path(project.namespace, project) end - context 'from the tags list page' do + context 'from the tags list page', js: true do scenario 'deletes the tag' do expect(page).to have_content 'v1.1.0' - page.within('.content') do - first('.btn-remove').click - end + delete_first_tag - expect(current_path).to eq( - namespace_project_tags_path(project.namespace, project)) expect(page).not_to have_content 'v1.1.0' end end @@ -37,4 +33,23 @@ feature 'Master deletes tag', feature: true do expect(page).not_to have_content 'v1.0.0' end end + + context 'when pre-receive hook fails', js: true do + before do + allow_any_instance_of(GitHooksService).to receive(:execute) + .and_raise(GitHooksService::PreReceiveError, 'Do not delete tags') + end + + scenario 'shows the error message' do + delete_first_tag + + expect(page).to have_content('Do not delete tags') + end + end + + def delete_first_tag + page.within('.content') do + first('.btn-remove').click + end + end end diff --git a/spec/features/tags/master_views_tags_spec.rb b/spec/features/tags/master_views_tags_spec.rb index 29d2c244720..555f84c4772 100644 --- a/spec/features/tags/master_views_tags_spec.rb +++ b/spec/features/tags/master_views_tags_spec.rb @@ -27,10 +27,20 @@ feature 'Master views tags', feature: true do context 'when project has tags' do let(:project) { create(:project, namespace: user.namespace) } + let(:repository) { project.repository } + before do visit namespace_project_tags_path(project.namespace, project) end + scenario 'avoids a N+1 query in branches index' do + control_count = ActiveRecord::QueryRecorder.new { visit namespace_project_tags_path(project.namespace, project) }.count + + %w(one two three four five).each { |tag| repository.add_tag(user, tag, 'master', 'foo') } + + expect { visit namespace_project_tags_path(project.namespace, project) }.not_to exceed_query_limit(control_count) + end + scenario 'views the tags list page' do expect(page).to have_content 'v1.0.0' end diff --git a/spec/features/todos/todos_sorting_spec.rb b/spec/features/todos/todos_sorting_spec.rb index fec28c55d30..4d5bd476301 100644 --- a/spec/features/todos/todos_sorting_spec.rb +++ b/spec/features/todos/todos_sorting_spec.rb @@ -56,8 +56,8 @@ describe "Dashboard > User sorts todos", feature: true do expect(results_list.all('p')[4]).to have_content("merge_request_1") end - it "sorts by priority" do - click_link "Priority" + it "sorts by label priority" do + click_link "Label priority" results_list = page.find('.todos-list') expect(results_list.all('p')[0]).to have_content("issue_3") @@ -85,8 +85,8 @@ describe "Dashboard > User sorts todos", feature: true do visit dashboard_todos_path end - it "doesn't mix issues and merge requests priorities" do - click_link "Priority" + it "doesn't mix issues and merge requests label priorities" do + click_link "Label priority" results_list = page.find('.todos-list') expect(results_list.all('p')[0]).to have_content("issue_1") diff --git a/spec/features/todos/todos_spec.rb b/spec/features/todos/todos_spec.rb index 3495091a0d5..850020109d4 100644 --- a/spec/features/todos/todos_spec.rb +++ b/spec/features/todos/todos_spec.rb @@ -31,14 +31,16 @@ describe 'Dashboard Todos', feature: true do end it 'shows due date as today' do - page.within first('.todo') do + within first('.todo') do expect(page).to have_content 'Due today' end end shared_examples 'deleting the todo' do before do - first('.js-done-todo').click + within first('.todo') do + click_link 'Done' + end end it 'is marked as done-reversible in the list' do @@ -62,9 +64,11 @@ describe 'Dashboard Todos', feature: true do shared_examples 'deleting and restoring the todo' do before do - first('.js-done-todo').click - wait_for_ajax - first('.js-undo-todo').click + within first('.todo') do + click_link 'Done' + wait_for_ajax + click_link 'Undo' + end end it 'is marked back as pending in the list' do @@ -97,6 +101,35 @@ describe 'Dashboard Todos', feature: true do end end + context 'User has done todos', js: true do + before do + create(:todo, :mentioned, :done, user: user, project: project, target: issue, author: author) + login_as(user) + visit dashboard_todos_path(state: :done) + end + + it 'has the done todo present' do + expect(page).to have_selector('.todos-list .todo.todo-done', count: 1) + end + + describe 'restoring the todo' do + before do + within first('.todo') do + click_link 'Add todo' + end + end + + it 'is removed from the list' do + expect(page).not_to have_selector('.todos-list .todo.todo-done') + end + + it 'updates todo count' do + expect(page).to have_content 'To do 1' + expect(page).to have_content 'Done 0' + end + end + end + context 'User has Todos with labels spanning multiple projects' do before do label1 = create(:label, project: project) @@ -143,7 +176,7 @@ describe 'Dashboard Todos', feature: true do describe 'mark all as done', js: true do before do visit dashboard_todos_path - click_link('Mark all as done') + click_link 'Mark all as done' end it 'shows "All done" message!' do @@ -151,6 +184,60 @@ describe 'Dashboard Todos', feature: true do expect(page).to have_content "You're all done!" expect(page).not_to have_selector('.gl-pagination') end + + it 'shows "Undo mark all as done" button' do + expect(page).to have_selector('.js-todos-mark-all', visible: false) + expect(page).to have_selector('.js-todos-undo-all', visible: true) + end + end + + describe 'undo mark all as done', js: true do + before do + visit dashboard_todos_path + end + + it 'shows the restored todo list' do + mark_all_and_undo + + expect(page).to have_selector('.todos-list .todo', count: 1) + expect(page).to have_selector('.gl-pagination') + expect(page).not_to have_content "You're all done!" + end + + it 'updates todo count' do + mark_all_and_undo + + expect(page).to have_content 'To do 2' + expect(page).to have_content 'Done 0' + end + + it 'shows "Mark all as done" button' do + mark_all_and_undo + + expect(page).to have_selector('.js-todos-mark-all', visible: true) + expect(page).to have_selector('.js-todos-undo-all', visible: false) + end + + context 'User has deleted a todo' do + before do + within first('.todo') do + click_link 'Done' + end + end + + it 'shows the restored todo list with the deleted todo' do + mark_all_and_undo + + expect(page).to have_selector('.todos-list .todo.todo-pending', count: 1) + end + end + + def mark_all_and_undo + click_link 'Mark all as done' + wait_for_ajax + click_link 'Undo mark all as done' + wait_for_ajax + end end end diff --git a/spec/features/triggers_spec.rb b/spec/features/triggers_spec.rb index 4a7511589d6..c1ae6db00c6 100644 --- a/spec/features/triggers_spec.rb +++ b/spec/features/triggers_spec.rb @@ -1,28 +1,175 @@ require 'spec_helper' -describe 'Triggers' do +feature 'Triggers', feature: true, js: true do + let(:trigger_title) { 'trigger desc' } let(:user) { create(:user) } + let(:user2) { create(:user) } + let(:guest_user) { create(:user) } before { login_as(user) } before do - @project = FactoryGirl.create :empty_project + @project = create(:empty_project) @project.team << [user, :master] + @project.team << [user2, :master] + @project.team << [guest_user, :guest] visit namespace_project_settings_ci_cd_path(@project.namespace, @project) end - context 'create a trigger' do - before do - click_on 'Add trigger' - expect(@project.triggers.count).to eq(1) + describe 'create trigger workflow' do + scenario 'prevents adding new trigger with no description' do + fill_in 'trigger_description', with: '' + click_button 'Add trigger' + + # See if input has error due to empty value + expect(page.find('form.gl-show-field-errors .gl-field-error')['style']).to eq 'display: block;' + end + + scenario 'adds new trigger with description' do + fill_in 'trigger_description', with: 'trigger desc' + click_button 'Add trigger' + + # See if "trigger creation successful" message displayed and description and owner are correct + expect(page.find('.flash-notice')).to have_content 'Trigger was created successfully.' + expect(page.find('.triggers-list')).to have_content 'trigger desc' + expect(page.find('.triggers-list .trigger-owner')).to have_content @user.name + end + end + + describe 'edit trigger workflow' do + let(:new_trigger_title) { 'new trigger' } + + scenario 'click on edit trigger opens edit trigger page' do + create(:ci_trigger, owner: user, project: @project, description: trigger_title) + visit namespace_project_settings_ci_cd_path(@project.namespace, @project) + + # See if edit page has correct descrption + find('a[title="Edit"]').click + expect(page.find('#trigger_description').value).to have_content 'trigger desc' + end + + scenario 'edit trigger and save' do + create(:ci_trigger, owner: user, project: @project, description: trigger_title) + visit namespace_project_settings_ci_cd_path(@project.namespace, @project) + + # See if edit page opens, then fill in new description and save + find('a[title="Edit"]').click + fill_in 'trigger_description', with: new_trigger_title + click_button 'Save trigger' + + # See if "trigger updated successfully" message displayed and description and owner are correct + expect(page.find('.flash-notice')).to have_content 'Trigger was successfully updated.' + expect(page.find('.triggers-list')).to have_content new_trigger_title + expect(page.find('.triggers-list .trigger-owner')).to have_content @user.name + end + + scenario 'edit "legacy" trigger and save' do + # Create new trigger without owner association, i.e. Legacy trigger + create(:ci_trigger, owner: nil, project: @project) + visit namespace_project_settings_ci_cd_path(@project.namespace, @project) + + # See if the trigger can be edited and description is blank + find('a[title="Edit"]').click + expect(page.find('#trigger_description').value).to have_content '' + + # See if trigger can be updated with description and saved successfully + fill_in 'trigger_description', with: new_trigger_title + click_button 'Save trigger' + expect(page.find('.flash-notice')).to have_content 'Trigger was successfully updated.' + expect(page.find('.triggers-list')).to have_content new_trigger_title + end + end + + describe 'trigger "Take ownership" workflow' do + before(:each) do + create(:ci_trigger, owner: user2, project: @project, description: trigger_title) + visit namespace_project_settings_ci_cd_path(@project.namespace, @project) + end + + scenario 'button "Take ownership" has correct alert' do + expected_alert = 'By taking ownership you will bind this trigger to your user account. With this the trigger will have access to all your projects as if it was you. Are you sure?' + expect(page.find('a.btn-trigger-take-ownership')['data-confirm']).to eq expected_alert end - it 'contains trigger token' do - expect(page).to have_content(@project.triggers.first.token) + scenario 'take trigger ownership' do + # See if "Take ownership" on trigger works post trigger creation + find('a.btn-trigger-take-ownership').click + page.accept_confirm do + expect(page.find('.flash-notice')).to have_content 'Trigger was re-assigned.' + expect(page.find('.triggers-list')).to have_content trigger_title + expect(page.find('.triggers-list .trigger-owner')).to have_content @user.name + end end + end + + describe 'trigger "Revoke" workflow' do + before(:each) do + create(:ci_trigger, owner: user2, project: @project, description: trigger_title) + visit namespace_project_settings_ci_cd_path(@project.namespace, @project) + end + + scenario 'button "Revoke" has correct alert' do + expected_alert = 'By revoking a trigger you will break any processes making use of it. Are you sure?' + expect(page.find('a.btn-trigger-revoke')['data-confirm']).to eq expected_alert + end + + scenario 'revoke trigger' do + # See if "Revoke" on trigger works post trigger creation + find('a.btn-trigger-revoke').click + page.accept_confirm do + expect(page.find('.flash-notice')).to have_content 'Trigger removed' + expect(page).to have_selector('p.settings-message.text-center.append-bottom-default') + end + end + end + + describe 'show triggers workflow' do + scenario 'contains trigger description placeholder' do + expect(page.find('#trigger_description')['placeholder']).to eq 'Trigger description' + end + + scenario 'show "legacy" badge for legacy trigger' do + create(:ci_trigger, owner: nil, project: @project) + visit namespace_project_settings_ci_cd_path(@project.namespace, @project) + + # See if trigger without owner (i.e. legacy) shows "legacy" badge and is editable + expect(page.find('.triggers-list')).to have_content 'legacy' + expect(page.find('.triggers-list')).to have_selector('a[title="Edit"]') + end + + scenario 'show "invalid" badge for trigger with owner having insufficient permissions' do + create(:ci_trigger, owner: guest_user, project: @project, description: trigger_title) + visit namespace_project_settings_ci_cd_path(@project.namespace, @project) + + # See if trigger without owner (i.e. legacy) shows "legacy" badge and is non-editable + expect(page.find('.triggers-list')).to have_content 'invalid' + expect(page.find('.triggers-list')).not_to have_selector('a[title="Edit"]') + end + + scenario 'do not show "Edit" or full token for not owned trigger' do + # Create trigger with user different from current_user + create(:ci_trigger, owner: user2, project: @project, description: trigger_title) + visit namespace_project_settings_ci_cd_path(@project.namespace, @project) + + # See if trigger not owned by current_user shows only first few token chars and doesn't have copy-to-clipboard button + expect(page.find('.triggers-list')).to have_content(@project.triggers.first.token[0..3]) + expect(page.find('.triggers-list')).not_to have_selector('button.btn-clipboard') + + # See if trigger owner name doesn't match with current_user and trigger is non-editable + expect(page.find('.triggers-list .trigger-owner')).not_to have_content @user.name + expect(page.find('.triggers-list')).not_to have_selector('a[title="Edit"]') + end + + scenario 'show "Edit" and full token for owned trigger' do + create(:ci_trigger, owner: user, project: @project, description: trigger_title) + visit namespace_project_settings_ci_cd_path(@project.namespace, @project) + + # See if trigger shows full token and has copy-to-clipboard button + expect(page.find('.triggers-list')).to have_content @project.triggers.first.token + expect(page.find('.triggers-list')).to have_selector('button.btn-clipboard') - it 'revokes the trigger' do - click_on 'Revoke' - expect(@project.triggers.count).to eq(0) + # See if trigger owner name matches with current_user and is editable + expect(page.find('.triggers-list .trigger-owner')).to have_content @user.name + expect(page.find('.triggers-list')).to have_selector('a[title="Edit"]') end end end diff --git a/spec/features/uploads/user_uploads_avatar_to_group_spec.rb b/spec/features/uploads/user_uploads_avatar_to_group_spec.rb new file mode 100644 index 00000000000..f88a515f7fc --- /dev/null +++ b/spec/features/uploads/user_uploads_avatar_to_group_spec.rb @@ -0,0 +1,26 @@ +require 'rails_helper' + +feature 'User uploads avatar to group', feature: true do + scenario 'they see the new avatar' do + user = create(:user) + group = create(:group) + group.add_owner(user) + login_as(user) + + visit edit_group_path(group) + attach_file( + 'group_avatar', + Rails.root.join('spec', 'fixtures', 'dk.png'), + visible: false + ) + + click_button 'Save group' + + visit group_path(group) + + expect(page).to have_selector(%Q(img[src$="/uploads/group/avatar/#{group.id}/dk.png"])) + + # Cheating here to verify something that isn't user-facing, but is important + expect(group.reload.avatar.file).to exist + end +end diff --git a/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb b/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb new file mode 100644 index 00000000000..0dfd29045e5 --- /dev/null +++ b/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb @@ -0,0 +1,24 @@ +require 'rails_helper' + +feature 'User uploads avatar to profile', feature: true do + scenario 'they see their new avatar' do + user = create(:user) + login_as(user) + + visit profile_path + attach_file( + 'user_avatar', + Rails.root.join('spec', 'fixtures', 'dk.png'), + visible: false + ) + + click_button 'Update profile settings' + + visit user_path(user) + + expect(page).to have_selector(%Q(img[src$="/uploads/user/avatar/#{user.id}/dk.png"])) + + # Cheating here to verify something that isn't user-facing, but is important + expect(user.reload.avatar.file).to exist + end +end diff --git a/spec/features/uploads/user_uploads_file_to_note_spec.rb b/spec/features/uploads/user_uploads_file_to_note_spec.rb new file mode 100644 index 00000000000..0c160dd74b4 --- /dev/null +++ b/spec/features/uploads/user_uploads_file_to_note_spec.rb @@ -0,0 +1,22 @@ +require 'rails_helper' + +feature 'User uploads file to note', feature: true do + include DropzoneHelper + + let(:user) { create(:user) } + let(:project) { create(:empty_project, creator: user, namespace: user.namespace) } + + scenario 'they see the attached file', js: true do + issue = create(:issue, project: project, author: user) + + login_as(user) + visit namespace_project_issue_path(project.namespace, project, issue) + + dropzone_file(Rails.root.join('spec', 'fixtures', 'dk.png')) + click_button 'Comment' + wait_for_ajax + + expect(find('a.no-attachment-icon img[alt="dk"]')['src']) + .to match(%r{/#{project.full_path}/uploads/\h{32}/dk\.png$}) + end +end diff --git a/spec/features/user_callout_spec.rb b/spec/features/user_callout_spec.rb new file mode 100644 index 00000000000..336c4092c98 --- /dev/null +++ b/spec/features/user_callout_spec.rb @@ -0,0 +1,37 @@ +require 'spec_helper' + +describe 'User Callouts', js: true do + let(:user) { create(:user) } + let(:project) { create(:empty_project, path: 'gitlab', name: 'sample') } + + before do + login_as(user) + project.team << [user, :master] + end + + it 'takes you to the profile preferences when the link is clicked' do + visit dashboard_projects_path + click_link 'Check it out' + expect(current_path).to eq profile_preferences_path + end + + describe 'user callout should appear in two routes' do + it 'shows up on the user profile' do + visit user_path(user) + expect(find('.user-callout')).to have_content 'Customize your experience' + end + + it 'shows up on the dashboard projects' do + visit dashboard_projects_path + expect(find('.user-callout')).to have_content 'Customize your experience' + end + end + + it 'hides the user callout when click on the dismiss icon' do + visit user_path(user) + within('.user-callout') do + find('.close-user-callout').click + end + expect(page).not_to have_selector('#user-callout') + end +end diff --git a/spec/features/users/rss_spec.rb b/spec/features/users/rss_spec.rb new file mode 100644 index 00000000000..14564abb16d --- /dev/null +++ b/spec/features/users/rss_spec.rb @@ -0,0 +1,22 @@ +require 'spec_helper' + +feature 'User RSS' do + let(:path) { user_path(create(:user)) } + + context 'when signed in' do + before do + login_as(create(:user)) + visit path + end + + it_behaves_like "it has an RSS button with current_user's private token" + end + + context 'when signed out' do + before do + visit path + end + + it_behaves_like "it has an RSS button without a private token" + end +end diff --git a/spec/finders/issues_finder_spec.rb b/spec/finders/issues_finder_spec.rb index 2a008427478..ee52dc65175 100644 --- a/spec/finders/issues_finder_spec.rb +++ b/spec/finders/issues_finder_spec.rb @@ -101,6 +101,41 @@ describe IssuesFinder do end end + context 'filtering by started milestone' do + let(:params) { { milestone_title: Milestone::Started.name } } + + let(:project_no_started_milestones) { create(:empty_project, :public) } + let(:project_started_1_and_2) { create(:empty_project, :public) } + let(:project_started_8) { create(:empty_project, :public) } + + let(:yesterday) { Date.today - 1.day } + let(:tomorrow) { Date.today + 1.day } + let(:two_days_ago) { Date.today - 2.days } + + let(:milestones) do + [ + create(:milestone, project: project_no_started_milestones, start_date: tomorrow), + create(:milestone, project: project_started_1_and_2, title: '1.0', start_date: two_days_ago), + create(:milestone, project: project_started_1_and_2, title: '2.0', start_date: yesterday), + create(:milestone, project: project_started_1_and_2, title: '3.0', start_date: tomorrow), + create(:milestone, project: project_started_8, title: '7.0'), + create(:milestone, project: project_started_8, title: '8.0', start_date: yesterday), + create(:milestone, project: project_started_8, title: '9.0', start_date: tomorrow) + ] + end + + before do + milestones.each do |milestone| + create(:issue, project: milestone.project, milestone: milestone, author: user, assignee: user) + end + end + + it 'returns issues in the started milestones for each project' do + expect(issues.map { |issue| issue.milestone.title }).to contain_exactly('1.0', '2.0', '8.0') + expect(issues.map { |issue| issue.milestone.start_date }).to contain_exactly(two_days_ago, yesterday, yesterday) + end + end + context 'filtering by label' do let(:params) { { label_name: label.title } } diff --git a/spec/finders/members_finder_spec.rb b/spec/finders/members_finder_spec.rb new file mode 100644 index 00000000000..cf691cf684b --- /dev/null +++ b/spec/finders/members_finder_spec.rb @@ -0,0 +1,22 @@ +require 'spec_helper' + +describe MembersFinder, '#execute' do + let(:group) { create(:group) } + let(:nested_group) { create(:group, :access_requestable, parent: group) } + let(:project) { create(:project, namespace: nested_group) } + let(:user1) { create(:user) } + let(:user2) { create(:user) } + let(:user3) { create(:user) } + let(:user4) { create(:user) } + + it 'returns members for project and parent groups' do + nested_group.request_access(user1) + member1 = group.add_master(user2) + member2 = nested_group.add_master(user3) + member3 = project.add_master(user4) + + result = described_class.new(project, user2).execute + + expect(result.to_a).to eq([member3, member2, member1]) + end +end diff --git a/spec/finders/notes_finder_spec.rb b/spec/finders/notes_finder_spec.rb index f8b05d4e9bc..77a04507be1 100644 --- a/spec/finders/notes_finder_spec.rb +++ b/spec/finders/notes_finder_spec.rb @@ -111,7 +111,7 @@ describe NotesFinder do end it 'raises an exception for an invalid target_type' do - params.merge!(target_type: 'invalid') + params[:target_type] = 'invalid' expect { described_class.new(project, user, params).execute }.to raise_error('invalid target_type') end diff --git a/spec/finders/personal_access_tokens_finder_spec.rb b/spec/finders/personal_access_tokens_finder_spec.rb new file mode 100644 index 00000000000..fd92664ca24 --- /dev/null +++ b/spec/finders/personal_access_tokens_finder_spec.rb @@ -0,0 +1,196 @@ +require 'spec_helper' + +describe PersonalAccessTokensFinder do + def finder(options = {}) + described_class.new(options) + end + + describe '#execute' do + let(:user) { create(:user) } + let(:params) { {} } + let!(:active_personal_access_token) { create(:personal_access_token, user: user) } + let!(:expired_personal_access_token) { create(:personal_access_token, :expired, user: user) } + let!(:revoked_personal_access_token) { create(:personal_access_token, :revoked, user: user) } + let!(:active_impersonation_token) { create(:personal_access_token, :impersonation, user: user) } + let!(:expired_impersonation_token) { create(:personal_access_token, :expired, :impersonation, user: user) } + let!(:revoked_impersonation_token) { create(:personal_access_token, :revoked, :impersonation, user: user) } + + subject { finder(params).execute } + + describe 'without user' do + it do + is_expected.to contain_exactly(active_personal_access_token, active_impersonation_token, + revoked_personal_access_token, expired_personal_access_token, + revoked_impersonation_token, expired_impersonation_token) + end + + describe 'without impersonation' do + before { params[:impersonation] = false } + + it { is_expected.to contain_exactly(active_personal_access_token, revoked_personal_access_token, expired_personal_access_token) } + + describe 'with active state' do + before { params[:state] = 'active' } + + it { is_expected.to contain_exactly(active_personal_access_token) } + end + + describe 'with inactive state' do + before { params[:state] = 'inactive' } + + it { is_expected.to contain_exactly(revoked_personal_access_token, expired_personal_access_token) } + end + end + + describe 'with impersonation' do + before { params[:impersonation] = true } + + it { is_expected.to contain_exactly(active_impersonation_token, revoked_impersonation_token, expired_impersonation_token) } + + describe 'with active state' do + before { params[:state] = 'active' } + + it { is_expected.to contain_exactly(active_impersonation_token) } + end + + describe 'with inactive state' do + before { params[:state] = 'inactive' } + + it { is_expected.to contain_exactly(revoked_impersonation_token, expired_impersonation_token) } + end + end + + describe 'with active state' do + before { params[:state] = 'active' } + + it { is_expected.to contain_exactly(active_personal_access_token, active_impersonation_token) } + end + + describe 'with inactive state' do + before { params[:state] = 'inactive' } + + it do + is_expected.to contain_exactly(expired_personal_access_token, revoked_personal_access_token, + expired_impersonation_token, revoked_impersonation_token) + end + end + + describe 'with id' do + subject { finder(params).find_by(id: active_personal_access_token.id) } + + it { is_expected.to eq(active_personal_access_token) } + + describe 'with impersonation' do + before { params[:impersonation] = true } + + it { is_expected.to be_nil } + end + end + + describe 'with token' do + subject { finder(params).find_by(token: active_personal_access_token.token) } + + it { is_expected.to eq(active_personal_access_token) } + + describe 'with impersonation' do + before { params[:impersonation] = true } + + it { is_expected.to be_nil } + end + end + end + + describe 'with user' do + let(:user2) { create(:user) } + let!(:other_user_active_personal_access_token) { create(:personal_access_token, user: user2) } + let!(:other_user_expired_personal_access_token) { create(:personal_access_token, :expired, user: user2) } + let!(:other_user_revoked_personal_access_token) { create(:personal_access_token, :revoked, user: user2) } + let!(:other_user_active_impersonation_token) { create(:personal_access_token, :impersonation, user: user2) } + let!(:other_user_expired_impersonation_token) { create(:personal_access_token, :expired, :impersonation, user: user2) } + let!(:other_user_revoked_impersonation_token) { create(:personal_access_token, :revoked, :impersonation, user: user2) } + + before { params[:user] = user } + + it do + is_expected.to contain_exactly(active_personal_access_token, active_impersonation_token, + revoked_personal_access_token, expired_personal_access_token, + revoked_impersonation_token, expired_impersonation_token) + end + + describe 'without impersonation' do + before { params[:impersonation] = false } + + it { is_expected.to contain_exactly(active_personal_access_token, revoked_personal_access_token, expired_personal_access_token) } + + describe 'with active state' do + before { params[:state] = 'active' } + + it { is_expected.to contain_exactly(active_personal_access_token) } + end + + describe 'with inactive state' do + before { params[:state] = 'inactive' } + + it { is_expected.to contain_exactly(revoked_personal_access_token, expired_personal_access_token) } + end + end + + describe 'with impersonation' do + before { params[:impersonation] = true } + + it { is_expected.to contain_exactly(active_impersonation_token, revoked_impersonation_token, expired_impersonation_token) } + + describe 'with active state' do + before { params[:state] = 'active' } + + it { is_expected.to contain_exactly(active_impersonation_token) } + end + + describe 'with inactive state' do + before { params[:state] = 'inactive' } + + it { is_expected.to contain_exactly(revoked_impersonation_token, expired_impersonation_token) } + end + end + + describe 'with active state' do + before { params[:state] = 'active' } + + it { is_expected.to contain_exactly(active_personal_access_token, active_impersonation_token) } + end + + describe 'with inactive state' do + before { params[:state] = 'inactive' } + + it do + is_expected.to contain_exactly(expired_personal_access_token, revoked_personal_access_token, + expired_impersonation_token, revoked_impersonation_token) + end + end + + describe 'with id' do + subject { finder(params).find_by(id: active_personal_access_token.id) } + + it { is_expected.to eq(active_personal_access_token) } + + describe 'with impersonation' do + before { params[:impersonation] = true } + + it { is_expected.to be_nil } + end + end + + describe 'with token' do + subject { finder(params).find_by(token: active_personal_access_token.token) } + + it { is_expected.to eq(active_personal_access_token) } + + describe 'with impersonation' do + before { params[:impersonation] = true } + + it { is_expected.to be_nil } + end + end + end + end +end diff --git a/spec/finders/pipelines_finder_spec.rb b/spec/finders/pipelines_finder_spec.rb index fdc8215aa47..6bada7b3eb9 100644 --- a/spec/finders/pipelines_finder_spec.rb +++ b/spec/finders/pipelines_finder_spec.rb @@ -39,8 +39,8 @@ describe PipelinesFinder do end end - # Scoping to running will speed up the test as it doesn't hit the FS - let(:params) { { scope: 'running' } } + # Scoping to pending will speed up the test as it doesn't hit the FS + let(:params) { { scope: 'pending' } } it 'orders in descending order on ID' do feature_pipeline = create(:ci_pipeline, project: project, ref: 'feature') diff --git a/spec/fixtures/api/schemas/issue.json b/spec/fixtures/api/schemas/issue.json index 8e19cee5440..21c078e0f44 100644 --- a/spec/fixtures/api/schemas/issue.json +++ b/spec/fixtures/api/schemas/issue.json @@ -11,6 +11,7 @@ "title": { "type": "string" }, "confidential": { "type": "boolean" }, "due_date": { "type": ["date", "null"] }, + "relative_position": { "type": "integer" }, "labels": { "type": "array", "items": { diff --git a/spec/fixtures/api/schemas/public_api/v3/issues.json b/spec/fixtures/api/schemas/public_api/v3/issues.json new file mode 100644 index 00000000000..f2ee9c925ae --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v3/issues.json @@ -0,0 +1,77 @@ +{ + "type": "array", + "items": { + "type": "object", + "properties" : { + "id": { "type": "integer" }, + "iid": { "type": "integer" }, + "project_id": { "type": "integer" }, + "title": { "type": "string" }, + "description": { "type": ["string", "null"] }, + "state": { "type": "string" }, + "created_at": { "type": "date" }, + "updated_at": { "type": "date" }, + "labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "milestone": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "iid": { "type": "integer" }, + "project_id": { "type": "integer" }, + "title": { "type": "string" }, + "description": { "type": ["string", "null"] }, + "state": { "type": "string" }, + "created_at": { "type": "date" }, + "updated_at": { "type": "date" }, + "due_date": { "type": "date" }, + "start_date": { "type": "date" } + }, + "additionalProperties": false + }, + "assignee": { + "type": ["object", "null"], + "properties": { + "name": { "type": "string" }, + "username": { "type": "string" }, + "id": { "type": "integer" }, + "state": { "type": "string" }, + "avatar_url": { "type": "uri" }, + "web_url": { "type": "uri" } + }, + "additionalProperties": false + }, + "author": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "username": { "type": "string" }, + "id": { "type": "integer" }, + "state": { "type": "string" }, + "avatar_url": { "type": "uri" }, + "web_url": { "type": "uri" } + }, + "additionalProperties": false + }, + "user_notes_count": { "type": "integer" }, + "upvotes": { "type": "integer" }, + "downvotes": { "type": "integer" }, + "due_date": { "type": ["date", "null"] }, + "confidential": { "type": "boolean" }, + "web_url": { "type": "uri" }, + "subscribed": { "type": ["boolean"] } + }, + "required": [ + "id", "iid", "project_id", "title", "description", + "state", "created_at", "updated_at", "labels", + "milestone", "assignee", "author", "user_notes_count", + "upvotes", "downvotes", "due_date", "confidential", + "web_url", "subscribed" + ], + "additionalProperties": false + } +} diff --git a/spec/fixtures/api/schemas/public_api/v3/merge_requests.json b/spec/fixtures/api/schemas/public_api/v3/merge_requests.json new file mode 100644 index 00000000000..01f9fbb2c89 --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v3/merge_requests.json @@ -0,0 +1,89 @@ +{ + "type": "array", + "items": { + "type": "object", + "properties" : { + "id": { "type": "integer" }, + "iid": { "type": "integer" }, + "project_id": { "type": "integer" }, + "title": { "type": "string" }, + "description": { "type": ["string", "null"] }, + "state": { "type": "string" }, + "created_at": { "type": "date" }, + "updated_at": { "type": "date" }, + "target_branch": { "type": "string" }, + "source_branch": { "type": "string" }, + "upvotes": { "type": "integer" }, + "downvotes": { "type": "integer" }, + "author": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "username": { "type": "string" }, + "id": { "type": "integer" }, + "state": { "type": "string" }, + "avatar_url": { "type": "uri" }, + "web_url": { "type": "uri" } + }, + "additionalProperties": false + }, + "assignee": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "username": { "type": "string" }, + "id": { "type": "integer" }, + "state": { "type": "string" }, + "avatar_url": { "type": "uri" }, + "web_url": { "type": "uri" } + }, + "additionalProperties": false + }, + "source_project_id": { "type": "integer" }, + "target_project_id": { "type": "integer" }, + "labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "work_in_progress": { "type": "boolean" }, + "milestone": { + "type": ["object", "null"], + "properties": { + "id": { "type": "integer" }, + "iid": { "type": "integer" }, + "project_id": { "type": "integer" }, + "title": { "type": "string" }, + "description": { "type": ["string", "null"] }, + "state": { "type": "string" }, + "created_at": { "type": "date" }, + "updated_at": { "type": "date" }, + "due_date": { "type": "date" }, + "start_date": { "type": "date" } + }, + "additionalProperties": false + }, + "merge_when_build_succeeds": { "type": "boolean" }, + "merge_status": { "type": "string" }, + "sha": { "type": "string" }, + "merge_commit_sha": { "type": ["string", "null"] }, + "user_notes_count": { "type": "integer" }, + "should_remove_source_branch": { "type": ["boolean", "null"] }, + "force_remove_source_branch": { "type": ["boolean", "null"] }, + "web_url": { "type": "uri" }, + "subscribed": { "type": ["boolean"] } + }, + "required": [ + "id", "iid", "project_id", "title", "description", + "state", "created_at", "updated_at", "target_branch", + "source_branch", "upvotes", "downvotes", "author", + "assignee", "source_project_id", "target_project_id", + "labels", "work_in_progress", "milestone", "merge_when_build_succeeds", + "merge_status", "sha", "merge_commit_sha", "user_notes_count", + "should_remove_source_branch", "force_remove_source_branch", + "web_url", "subscribed" + ], + "additionalProperties": false + } +} diff --git a/spec/fixtures/api/schemas/public_api/v4/issues.json b/spec/fixtures/api/schemas/public_api/v4/issues.json new file mode 100644 index 00000000000..52199e75734 --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/issues.json @@ -0,0 +1,76 @@ +{ + "type": "array", + "items": { + "type": "object", + "properties" : { + "id": { "type": "integer" }, + "iid": { "type": "integer" }, + "project_id": { "type": "integer" }, + "title": { "type": "string" }, + "description": { "type": ["string", "null"] }, + "state": { "type": "string" }, + "created_at": { "type": "date" }, + "updated_at": { "type": "date" }, + "labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "milestone": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "iid": { "type": "integer" }, + "project_id": { "type": "integer" }, + "title": { "type": "string" }, + "description": { "type": ["string", "null"] }, + "state": { "type": "string" }, + "created_at": { "type": "date" }, + "updated_at": { "type": "date" }, + "due_date": { "type": "date" }, + "start_date": { "type": "date" } + }, + "additionalProperties": false + }, + "assignee": { + "type": ["object", "null"], + "properties": { + "name": { "type": "string" }, + "username": { "type": "string" }, + "id": { "type": "integer" }, + "state": { "type": "string" }, + "avatar_url": { "type": "uri" }, + "web_url": { "type": "uri" } + }, + "additionalProperties": false + }, + "author": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "username": { "type": "string" }, + "id": { "type": "integer" }, + "state": { "type": "string" }, + "avatar_url": { "type": "uri" }, + "web_url": { "type": "uri" } + }, + "additionalProperties": false + }, + "user_notes_count": { "type": "integer" }, + "upvotes": { "type": "integer" }, + "downvotes": { "type": "integer" }, + "due_date": { "type": ["date", "null"] }, + "confidential": { "type": "boolean" }, + "web_url": { "type": "uri" } + }, + "required": [ + "id", "iid", "project_id", "title", "description", + "state", "created_at", "updated_at", "labels", + "milestone", "assignee", "author", "user_notes_count", + "upvotes", "downvotes", "due_date", "confidential", + "web_url" + ], + "additionalProperties": false + } +} diff --git a/spec/fixtures/api/schemas/public_api/v4/merge_requests.json b/spec/fixtures/api/schemas/public_api/v4/merge_requests.json new file mode 100644 index 00000000000..51642e8cbb8 --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/merge_requests.json @@ -0,0 +1,88 @@ +{ + "type": "array", + "items": { + "type": "object", + "properties" : { + "id": { "type": "integer" }, + "iid": { "type": "integer" }, + "project_id": { "type": "integer" }, + "title": { "type": "string" }, + "description": { "type": ["string", "null"] }, + "state": { "type": "string" }, + "created_at": { "type": "date" }, + "updated_at": { "type": "date" }, + "target_branch": { "type": "string" }, + "source_branch": { "type": "string" }, + "upvotes": { "type": "integer" }, + "downvotes": { "type": "integer" }, + "author": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "username": { "type": "string" }, + "id": { "type": "integer" }, + "state": { "type": "string" }, + "avatar_url": { "type": "uri" }, + "web_url": { "type": "uri" } + }, + "additionalProperties": false + }, + "assignee": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "username": { "type": "string" }, + "id": { "type": "integer" }, + "state": { "type": "string" }, + "avatar_url": { "type": "uri" }, + "web_url": { "type": "uri" } + }, + "additionalProperties": false + }, + "source_project_id": { "type": "integer" }, + "target_project_id": { "type": "integer" }, + "labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "work_in_progress": { "type": "boolean" }, + "milestone": { + "type": ["object", "null"], + "properties": { + "id": { "type": "integer" }, + "iid": { "type": "integer" }, + "project_id": { "type": "integer" }, + "title": { "type": "string" }, + "description": { "type": ["string", "null"] }, + "state": { "type": "string" }, + "created_at": { "type": "date" }, + "updated_at": { "type": "date" }, + "due_date": { "type": "date" }, + "start_date": { "type": "date" } + }, + "additionalProperties": false + }, + "merge_when_pipeline_succeeds": { "type": "boolean" }, + "merge_status": { "type": "string" }, + "sha": { "type": "string" }, + "merge_commit_sha": { "type": ["string", "null"] }, + "user_notes_count": { "type": "integer" }, + "should_remove_source_branch": { "type": ["boolean", "null"] }, + "force_remove_source_branch": { "type": ["boolean", "null"] }, + "web_url": { "type": "uri" } + }, + "required": [ + "id", "iid", "project_id", "title", "description", + "state", "created_at", "updated_at", "target_branch", + "source_branch", "upvotes", "downvotes", "author", + "assignee", "source_project_id", "target_project_id", + "labels", "work_in_progress", "milestone", "merge_when_pipeline_succeeds", + "merge_status", "sha", "merge_commit_sha", "user_notes_count", + "should_remove_source_branch", "force_remove_source_branch", + "web_url" + ], + "additionalProperties": false + } +} diff --git a/spec/fixtures/api/schemas/user/login.json b/spec/fixtures/api/schemas/public_api/v4/user/login.json index 6181b3ccc86..6181b3ccc86 100644 --- a/spec/fixtures/api/schemas/user/login.json +++ b/spec/fixtures/api/schemas/public_api/v4/user/login.json diff --git a/spec/fixtures/api/schemas/user/public.json b/spec/fixtures/api/schemas/public_api/v4/user/public.json index 5587cfec61a..5587cfec61a 100644 --- a/spec/fixtures/api/schemas/user/public.json +++ b/spec/fixtures/api/schemas/public_api/v4/user/public.json diff --git a/spec/fixtures/markdown.md.erb b/spec/fixtures/markdown.md.erb index f3e7c2d1a9f..0cdbc32431d 100644 --- a/spec/fixtures/markdown.md.erb +++ b/spec/fixtures/markdown.md.erb @@ -79,6 +79,11 @@ As permissive as it is, we've allowed even more stuff: <span>span tag</span> +<details> +<summary>Summary lines are collapsible:</summary> +Hiding the details until expanded. +</details> + <a href="#" rel="bookmark">This is a link with a defined rel attribute, which should be removed</a> <a href="javascript:alert('Hi')">This is a link trying to be sneaky. It gets its link removed entirely.</a> diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index fd40fe99941..5c07ea8a872 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -58,7 +58,7 @@ describe ApplicationHelper do project = create(:empty_project, avatar: File.open(uploaded_image_temp_path)) avatar_url = "http://#{Gitlab.config.gitlab.host}/uploads/project/avatar/#{project.id}/banana_sample.gif" - expect(helper.project_icon("#{project.namespace.to_param}/#{project.to_param}").to_s). + expect(helper.project_icon(project.full_path).to_s). to eq "<img src=\"#{avatar_url}\" alt=\"Banana sample\" />" end @@ -68,7 +68,7 @@ describe ApplicationHelper do allow_any_instance_of(Project).to receive(:avatar_in_git).and_return(true) avatar_url = "http://#{Gitlab.config.gitlab.host}#{namespace_project_avatar_path(project.namespace, project)}" - expect(helper.project_icon("#{project.namespace.to_param}/#{project.to_param}").to_s).to match( + expect(helper.project_icon(project.full_path).to_s).to match( image_tag(avatar_url)) end end @@ -193,8 +193,8 @@ describe ApplicationHelper do describe 'time_ago_with_tooltip' do def element(*arguments) Time.zone = 'UTC' - time = Time.zone.parse('2015-07-02 08:23') - element = helper.time_ago_with_tooltip(time, *arguments) + @time = Time.zone.parse('2015-07-02 08:23') + element = helper.time_ago_with_tooltip(@time, *arguments) Nokogiri::HTML::DocumentFragment.parse(element).first_element_child end @@ -204,7 +204,7 @@ describe ApplicationHelper do end it 'includes the date string' do - expect(element.text).to eq '2015-07-02 08:23:00 UTC' + expect(element.text).to eq @time.strftime("%b %d, %Y") end it 'has a datetime attribute' do diff --git a/spec/helpers/auth_helper_spec.rb b/spec/helpers/auth_helper_spec.rb index 49ea4fa6d3e..cd3281d6f51 100644 --- a/spec/helpers/auth_helper_spec.rb +++ b/spec/helpers/auth_helper_spec.rb @@ -55,7 +55,7 @@ describe AuthHelper do context 'all the button based providers are disabled via application_setting' do it 'returns false' do stub_application_setting( - disabled_oauth_sign_in_sources: ['github', 'twitter'] + disabled_oauth_sign_in_sources: %w(github twitter) ) expect(helper.button_based_providers_enabled?).to be false diff --git a/spec/helpers/blob_helper_spec.rb b/spec/helpers/blob_helper_spec.rb index fa516f9903e..bead7948486 100644 --- a/spec/helpers/blob_helper_spec.rb +++ b/spec/helpers/blob_helper_spec.rb @@ -19,12 +19,12 @@ describe BlobHelper do describe '#highlight' do it 'returns plaintext for unknown lexer context' do result = helper.highlight(blob_name, no_context_content) - expect(result).to eq(%[<pre class="code highlight"><code><span id="LC1" class="line">:type "assem"))</span></code></pre>]) + expect(result).to eq(%[<pre class="code highlight"><code><span id="LC1" class="line" lang="">:type "assem"))</span></code></pre>]) end it 'highlights single block' do - expected = %Q[<pre class="code highlight"><code><span id="LC1" class="line"><span class="p">(</span><span class="nb">make-pathname</span> <span class="ss">:defaults</span> <span class="nv">name</span></span> -<span id="LC2" class="line"><span class="ss">:type</span> <span class="s">"assem"</span><span class="p">))</span></span></code></pre>] + expected = %Q[<pre class="code highlight"><code><span id="LC1" class="line" lang="common_lisp"><span class="p">(</span><span class="nb">make-pathname</span> <span class="ss">:defaults</span> <span class="nv">name</span></span> +<span id="LC2" class="line" lang="common_lisp"><span class="ss">:type</span> <span class="s">"assem"</span><span class="p">))</span></span></code></pre>] expect(helper.highlight(blob_name, blob_content)).to eq(expected) end @@ -43,10 +43,10 @@ describe BlobHelper do let(:blob_name) { 'test.diff' } let(:blob_content) { "+aaa\n+bbb\n- ccc\n ddd\n"} let(:expected) do - %q(<pre class="code highlight"><code><span id="LC1" class="line"><span class="gi">+aaa</span></span> -<span id="LC2" class="line"><span class="gi">+bbb</span></span> -<span id="LC3" class="line"><span class="gd">- ccc</span></span> -<span id="LC4" class="line"> ddd</span></code></pre>) + %q(<pre class="code highlight"><code><span id="LC1" class="line" lang="diff"><span class="gi">+aaa</span></span> +<span id="LC2" class="line" lang="diff"><span class="gi">+bbb</span></span> +<span id="LC3" class="line" lang="diff"><span class="gd">- ccc</span></span> +<span id="LC4" class="line" lang="diff"> ddd</span></code></pre>) end it 'highlights each line properly' do diff --git a/spec/helpers/ci_status_helper_spec.rb b/spec/helpers/ci_status_helper_spec.rb index 637b02d9388..174cc84a97b 100644 --- a/spec/helpers/ci_status_helper_spec.rb +++ b/spec/helpers/ci_status_helper_spec.rb @@ -16,4 +16,11 @@ describe CiStatusHelper do helper.ci_icon_for_status(failed_commit.status) end end + + describe "#pipeline_status_cache_key" do + it "builds a cache key for pipeline status" do + pipeline_status = Ci::PipelineStatus.new(build(:project), sha: "123abc", status: "success") + expect(helper.pipeline_status_cache_key(pipeline_status)).to eq("pipeline-status/123abc-success") + end + end end diff --git a/spec/helpers/emails_helper_spec.rb b/spec/helpers/emails_helper_spec.rb index 3223556e1d3..cd112dbb2fb 100644 --- a/spec/helpers/emails_helper_spec.rb +++ b/spec/helpers/emails_helper_spec.rb @@ -43,4 +43,36 @@ describe EmailsHelper do end end end + + describe '#header_logo' do + context 'there is a brand item with a logo' do + it 'returns the brand header logo' do + appearance = create :appearance, header_logo: fixture_file_upload( + Rails.root.join('spec/fixtures/dk.png') + ) + + expect(header_logo).to eq( + %{<img style="height: 50px" src="/uploads/appearance/header_logo/#{appearance.id}/dk.png" alt="Dk" />} + ) + end + end + + context 'there is a brand item without a logo' do + it 'returns the default header logo' do + create :appearance, header_logo: nil + + expect(header_logo).to eq( + %{<img alt="GitLab" src="/images/mailers/gitlab_header_logo.gif" width="55" height="50" />} + ) + end + end + + context 'there is no brand item' do + it 'returns the default header logo' do + expect(header_logo).to eq( + %{<img alt="GitLab" src="/images/mailers/gitlab_header_logo.gif" width="55" height="50" />} + ) + end + end + end end diff --git a/spec/helpers/events_helper_spec.rb b/spec/helpers/events_helper_spec.rb index 594b40303bc..70443d27f33 100644 --- a/spec/helpers/events_helper_spec.rb +++ b/spec/helpers/events_helper_spec.rb @@ -28,7 +28,7 @@ describe EventsHelper do it 'displays the first line of a code block' do input = "```\nCode block\nwith two lines\n```" - expected = %r{<pre.+><code>Code block\.\.\.</code></pre>} + expected = %r{<pre.+><code><span class="line">Code block\.\.\.</span>\n</code></pre>} expect(helper.event_note(input)).to match(expected) end @@ -55,10 +55,15 @@ describe EventsHelper do it 'preserves code color scheme' do input = "```ruby\ndef test\n 'hello world'\nend\n```" expected = '<pre class="code highlight js-syntax-highlight ruby">' \ - "<code><span class=\"k\">def</span> <span class=\"nf\">test</span>\n" \ - " <span class=\"s1\">\'hello world\'</span>\n" \ - "<span class=\"k\">end</span>\n" \ - '</code></pre>' + "<code><span class=\"line\"><span class=\"k\">def</span> <span class=\"nf\">test</span>...</span>\n" \ + "</code></pre>" + expect(helper.event_note(input)).to eq(expected) + end + + it 'preserves style attribute within a tag' do + input = '<span class="" style="background-color: #44ad8e; color: #FFFFFF;"></span>' + expected = '<p><span style="background-color: #44ad8e; color: #FFFFFF;"></span></p>' + expect(helper.event_note(input)).to eq(expected) end end diff --git a/spec/helpers/gitlab_markdown_helper_spec.rb b/spec/helpers/gitlab_markdown_helper_spec.rb index b8ec3521edb..6cf3f86680a 100644 --- a/spec/helpers/gitlab_markdown_helper_spec.rb +++ b/spec/helpers/gitlab_markdown_helper_spec.rb @@ -113,7 +113,7 @@ describe GitlabMarkdownHelper do it 'replaces commit message with emoji to link' do actual = link_to_gfm(':book:Book', '/foo') expect(actual). - to eq %Q(<img class="emoji" title=":book:" alt=":book:" src="http://#{Gitlab.config.gitlab.host}/assets/1F4D6.png" height="20" width="20" align="absmiddle"><a href="/foo">Book</a>) + to eq '<gl-emoji data-name="book" data-unicode-version="6.0">📖</gl-emoji><a href="/foo">Book</a>' end end @@ -152,9 +152,8 @@ describe GitlabMarkdownHelper do end describe '#first_line_in_markdown' do - let(:text) { "@#{user.username}, can you look at this?\nHello world\n"} - it 'truncates Markdown properly' do + text = "@#{user.username}, can you look at this?\nHello world\n" actual = first_line_in_markdown(text, 100, project: project) doc = Nokogiri::HTML.parse(actual) @@ -169,6 +168,23 @@ describe GitlabMarkdownHelper do expect(doc.content).to eq "@#{user.username}, can you look at this?..." end + + it 'truncates Markdown with emoji properly' do + text = "foo :wink:\nbar :grinning:" + actual = first_line_in_markdown(text, 100, project: project) + + doc = Nokogiri::HTML.parse(actual) + + # Make sure we didn't create invalid markup + # But also account for the 2 errors caused by the unknown `gl-emoji` elements + expect(doc.errors.length).to eq(2) + + expect(doc.css('gl-emoji').length).to eq(2) + expect(doc.css('gl-emoji')[0].attr('data-name')).to eq 'wink' + expect(doc.css('gl-emoji')[1].attr('data-name')).to eq 'grinning' + + expect(doc.content).to eq "foo 😉\nbar 😀" + end end describe '#cross_project_reference' do diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb index df71680e44c..93bb711f29a 100644 --- a/spec/helpers/issuables_helper_spec.rb +++ b/spec/helpers/issuables_helper_spec.rb @@ -51,7 +51,7 @@ describe IssuablesHelper do utf8: '✓', author_id: '11', assignee_id: '18', - label_name: ['bug', 'discussion', 'documentation'], + label_name: %w(bug discussion documentation), milestone_title: 'v4.0', sort: 'due_date_asc', namespace_id: 'gitlab-org', diff --git a/spec/helpers/issues_helper_spec.rb b/spec/helpers/issues_helper_spec.rb index 13fb9c1f1a7..f0554cc068d 100644 --- a/spec/helpers/issues_helper_spec.rb +++ b/spec/helpers/issues_helper_spec.rb @@ -55,8 +55,8 @@ describe IssuesHelper do describe "merge_requests_sentence" do subject { merge_requests_sentence(merge_requests)} let(:merge_requests) do - [ build(:merge_request, iid: 1), build(:merge_request, iid: 2), - build(:merge_request, iid: 3)] + [build(:merge_request, iid: 1), build(:merge_request, iid: 2), + build(:merge_request, iid: 3)] end it { is_expected.to eq("!1, !2, or !3") } @@ -113,7 +113,7 @@ describe IssuesHelper do describe "awards_sort" do it "sorts a hash so thumbsup and thumbsdown are always on top" do data = { "thumbsdown" => "some value", "lifter" => "some value", "thumbsup" => "some value" } - expect(awards_sort(data).keys).to eq(["thumbsup", "thumbsdown", "lifter"]) + expect(awards_sort(data).keys).to eq(%w(thumbsup thumbsdown lifter)) end end @@ -131,4 +131,36 @@ describe IssuesHelper do expect(options).to have_selector('option', text: milestone2.title) end end + + describe "#link_to_discussions_to_resolve" do + describe "passing only a merge request" do + let(:merge_request) { create(:merge_request) } + + it "links just the merge request" do + expected_path = namespace_project_merge_request_path(merge_request.project.namespace, merge_request.project, merge_request) + + expect(link_to_discussions_to_resolve(merge_request, nil)).to include(expected_path) + end + + it "containst the reference to the merge request" do + expect(link_to_discussions_to_resolve(merge_request, nil)).to include(merge_request.to_reference) + end + end + + describe "when passing a discussion" do + let(:diff_note) { create(:diff_note_on_merge_request) } + let(:merge_request) { diff_note.noteable } + let(:discussion) { Discussion.new([diff_note]) } + + it "links to the merge request with first note if a single discussion was passed" do + expected_path = Gitlab::UrlBuilder.build(diff_note) + + expect(link_to_discussions_to_resolve(merge_request, discussion)).to include(expected_path) + end + + it "contains both the reference to the merge request and a mention of the discussion" do + expect(link_to_discussions_to_resolve(merge_request, discussion)).to include("#{merge_request.to_reference} (discussion #{diff_note.id})") + end + end + end end diff --git a/spec/helpers/milestones_helper_spec.rb b/spec/helpers/milestones_helper_spec.rb index 14a95479339..3cb809d42b5 100644 --- a/spec/helpers/milestones_helper_spec.rb +++ b/spec/helpers/milestones_helper_spec.rb @@ -17,7 +17,7 @@ describe MilestonesHelper do it { expect(result_for(due_date: yesterday)).to eq("expired on #{yesterday_formatted}") } it { expect(result_for(start_date: tomorrow)).to eq("starts on #{tomorrow_formatted}") } it { expect(result_for(start_date: yesterday)).to eq("started on #{yesterday_formatted}") } - it { expect(result_for(start_date: yesterday, due_date: tomorrow)).to eq("#{yesterday_formatted} - #{tomorrow_formatted}") } + it { expect(result_for(start_date: yesterday, due_date: tomorrow)).to eq("#{yesterday_formatted}–#{tomorrow_formatted}") } end describe '#milestone_counts' do @@ -47,4 +47,58 @@ describe MilestonesHelper do end end end + + describe '#milestone_remaining_days' do + around do |example| + Timecop.freeze(Time.utc(2017, 3, 17)) { example.run } + end + + context 'when less than 31 days remaining' do + let(:milestone_remaining) { milestone_remaining_days(build_stubbed(:milestone, due_date: 12.days.from_now.utc)) } + + it 'returns days remaining' do + expect(milestone_remaining).to eq("<strong>12</strong> days remaining") + end + end + + context 'when less than 1 year and more than 30 days remaining' do + let(:milestone_remaining) { milestone_remaining_days(build_stubbed(:milestone, due_date: 2.months.from_now.utc)) } + + it 'returns months remaining' do + expect(milestone_remaining).to eq("<strong>2</strong> months remaining") + end + end + + context 'when more than 1 year remaining' do + let(:milestone_remaining) { milestone_remaining_days(build_stubbed(:milestone, due_date: (1.year.from_now + 2.days).utc)) } + + it 'returns years remaining' do + expect(milestone_remaining).to eq("<strong>1</strong> year remaining") + end + end + + context 'when milestone is expired' do + let(:milestone_remaining) { milestone_remaining_days(build_stubbed(:milestone, due_date: 2.days.ago.utc)) } + + it 'returns "Past due"' do + expect(milestone_remaining).to eq("<strong>Past due</strong>") + end + end + + context 'when milestone has start_date in the future' do + let(:milestone_remaining) { milestone_remaining_days(build_stubbed(:milestone, start_date: 2.days.from_now.utc)) } + + it 'returns "Upcoming"' do + expect(milestone_remaining).to eq("<strong>Upcoming</strong>") + end + end + + context 'when milestone has start_date in the past' do + let(:milestone_remaining) { milestone_remaining_days(build_stubbed(:milestone, start_date: 2.days.ago.utc)) } + + it 'returns days elapsed' do + expect(milestone_remaining).to eq("<strong>2</strong> days elapsed") + end + end + end end diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb index aca0bb1d794..fc6ad6419ac 100644 --- a/spec/helpers/projects_helper_spec.rb +++ b/spec/helpers/projects_helper_spec.rb @@ -63,6 +63,46 @@ describe ProjectsHelper do end end + describe "#project_list_cache_key" do + let(:project) { create(:project) } + + it "includes the namespace" do + expect(helper.project_list_cache_key(project)).to include(project.namespace.cache_key) + end + + it "includes the project" do + expect(helper.project_list_cache_key(project)).to include(project.cache_key) + end + + it "includes the controller name" do + expect(helper.controller).to receive(:controller_name).and_return("testcontroller") + + expect(helper.project_list_cache_key(project)).to include("testcontroller") + end + + it "includes the controller action" do + expect(helper.controller).to receive(:action_name).and_return("testaction") + + expect(helper.project_list_cache_key(project)).to include("testaction") + end + + it "includes the application settings" do + settings = Gitlab::CurrentSettings.current_application_settings + + expect(helper.project_list_cache_key(project)).to include(settings.cache_key) + end + + it "includes a version" do + expect(helper.project_list_cache_key(project)).to include("v2.3") + end + + it "includes the pipeline status when there is a status" do + create(:ci_pipeline, :success, project: project, sha: project.commit.sha) + + expect(helper.project_list_cache_key(project)).to include("pipeline-status/#{project.commit.sha}-success") + end + end + describe 'link_to_member' do let(:group) { create(:group) } let(:project) { create(:empty_project, group: group) } diff --git a/spec/helpers/rss_helper_spec.rb b/spec/helpers/rss_helper_spec.rb new file mode 100644 index 00000000000..f3f174f3d14 --- /dev/null +++ b/spec/helpers/rss_helper_spec.rb @@ -0,0 +1,20 @@ +require 'spec_helper' + +describe RssHelper do + describe '#rss_url_options' do + context 'when signed in' do + it "includes the current_user's private_token" do + current_user = create(:user) + allow(helper).to receive(:current_user).and_return(current_user) + expect(helper.rss_url_options).to include private_token: current_user.private_token + end + end + + context 'when signed out' do + it "does not have a private_token" do + allow(helper).to receive(:current_user).and_return(nil) + expect(helper.rss_url_options[:private_token]).to be_nil + end + end + end +end diff --git a/spec/helpers/submodule_helper_spec.rb b/spec/helpers/submodule_helper_spec.rb index 4da1569e59f..28b8def331d 100644 --- a/spec/helpers/submodule_helper_spec.rb +++ b/spec/helpers/submodule_helper_spec.rb @@ -20,97 +20,97 @@ describe SubmoduleHelper do it 'detects ssh on standard port' do allow(Gitlab.config.gitlab_shell).to receive(:ssh_port).and_return(22) # set this just to be sure allow(Gitlab.config.gitlab_shell).to receive(:ssh_path_prefix).and_return(Settings.send(:build_gitlab_shell_ssh_path_prefix)) - stub_url([ config.user, '@', config.host, ':gitlab-org/gitlab-ce.git' ].join('')) - expect(submodule_links(submodule_item)).to eq([ namespace_project_path('gitlab-org', 'gitlab-ce'), namespace_project_tree_path('gitlab-org', 'gitlab-ce', 'hash') ]) + stub_url([config.user, '@', config.host, ':gitlab-org/gitlab-ce.git'].join('')) + expect(submodule_links(submodule_item)).to eq([namespace_project_path('gitlab-org', 'gitlab-ce'), namespace_project_tree_path('gitlab-org', 'gitlab-ce', 'hash')]) end it 'detects ssh on non-standard port' do allow(Gitlab.config.gitlab_shell).to receive(:ssh_port).and_return(2222) allow(Gitlab.config.gitlab_shell).to receive(:ssh_path_prefix).and_return(Settings.send(:build_gitlab_shell_ssh_path_prefix)) - stub_url([ 'ssh://', config.user, '@', config.host, ':2222/gitlab-org/gitlab-ce.git' ].join('')) - expect(submodule_links(submodule_item)).to eq([ namespace_project_path('gitlab-org', 'gitlab-ce'), namespace_project_tree_path('gitlab-org', 'gitlab-ce', 'hash') ]) + stub_url(['ssh://', config.user, '@', config.host, ':2222/gitlab-org/gitlab-ce.git'].join('')) + expect(submodule_links(submodule_item)).to eq([namespace_project_path('gitlab-org', 'gitlab-ce'), namespace_project_tree_path('gitlab-org', 'gitlab-ce', 'hash')]) end it 'detects http on standard port' do allow(Gitlab.config.gitlab).to receive(:port).and_return(80) allow(Gitlab.config.gitlab).to receive(:url).and_return(Settings.send(:build_gitlab_url)) - stub_url([ 'http://', config.host, '/gitlab-org/gitlab-ce.git' ].join('')) - expect(submodule_links(submodule_item)).to eq([ namespace_project_path('gitlab-org', 'gitlab-ce'), namespace_project_tree_path('gitlab-org', 'gitlab-ce', 'hash') ]) + stub_url(['http://', config.host, '/gitlab-org/gitlab-ce.git'].join('')) + expect(submodule_links(submodule_item)).to eq([namespace_project_path('gitlab-org', 'gitlab-ce'), namespace_project_tree_path('gitlab-org', 'gitlab-ce', 'hash')]) end it 'detects http on non-standard port' do allow(Gitlab.config.gitlab).to receive(:port).and_return(3000) allow(Gitlab.config.gitlab).to receive(:url).and_return(Settings.send(:build_gitlab_url)) - stub_url([ 'http://', config.host, ':3000/gitlab-org/gitlab-ce.git' ].join('')) - expect(submodule_links(submodule_item)).to eq([ namespace_project_path('gitlab-org', 'gitlab-ce'), namespace_project_tree_path('gitlab-org', 'gitlab-ce', 'hash') ]) + stub_url(['http://', config.host, ':3000/gitlab-org/gitlab-ce.git'].join('')) + expect(submodule_links(submodule_item)).to eq([namespace_project_path('gitlab-org', 'gitlab-ce'), namespace_project_tree_path('gitlab-org', 'gitlab-ce', 'hash')]) end it 'works with relative_url_root' do allow(Gitlab.config.gitlab).to receive(:port).and_return(80) # set this just to be sure allow(Gitlab.config.gitlab).to receive(:relative_url_root).and_return('/gitlab/root') allow(Gitlab.config.gitlab).to receive(:url).and_return(Settings.send(:build_gitlab_url)) - stub_url([ 'http://', config.host, '/gitlab/root/gitlab-org/gitlab-ce.git' ].join('')) - expect(submodule_links(submodule_item)).to eq([ namespace_project_path('gitlab-org', 'gitlab-ce'), namespace_project_tree_path('gitlab-org', 'gitlab-ce', 'hash') ]) + stub_url(['http://', config.host, '/gitlab/root/gitlab-org/gitlab-ce.git'].join('')) + expect(submodule_links(submodule_item)).to eq([namespace_project_path('gitlab-org', 'gitlab-ce'), namespace_project_tree_path('gitlab-org', 'gitlab-ce', 'hash')]) end end context 'submodule on github.com' do it 'detects ssh' do stub_url('git@github.com:gitlab-org/gitlab-ce.git') - expect(submodule_links(submodule_item)).to eq([ 'https://github.com/gitlab-org/gitlab-ce', 'https://github.com/gitlab-org/gitlab-ce/tree/hash' ]) + expect(submodule_links(submodule_item)).to eq(['https://github.com/gitlab-org/gitlab-ce', 'https://github.com/gitlab-org/gitlab-ce/tree/hash']) end it 'detects http' do stub_url('http://github.com/gitlab-org/gitlab-ce.git') - expect(submodule_links(submodule_item)).to eq([ 'https://github.com/gitlab-org/gitlab-ce', 'https://github.com/gitlab-org/gitlab-ce/tree/hash' ]) + expect(submodule_links(submodule_item)).to eq(['https://github.com/gitlab-org/gitlab-ce', 'https://github.com/gitlab-org/gitlab-ce/tree/hash']) end it 'detects https' do stub_url('https://github.com/gitlab-org/gitlab-ce.git') - expect(submodule_links(submodule_item)).to eq([ 'https://github.com/gitlab-org/gitlab-ce', 'https://github.com/gitlab-org/gitlab-ce/tree/hash' ]) + expect(submodule_links(submodule_item)).to eq(['https://github.com/gitlab-org/gitlab-ce', 'https://github.com/gitlab-org/gitlab-ce/tree/hash']) end it 'returns original with non-standard url' do stub_url('http://github.com/gitlab-org/gitlab-ce') - expect(submodule_links(submodule_item)).to eq([ repo.submodule_url_for, nil ]) + expect(submodule_links(submodule_item)).to eq([repo.submodule_url_for, nil]) stub_url('http://github.com/another/gitlab-org/gitlab-ce.git') - expect(submodule_links(submodule_item)).to eq([ repo.submodule_url_for, nil ]) + expect(submodule_links(submodule_item)).to eq([repo.submodule_url_for, nil]) end end context 'submodule on gitlab.com' do it 'detects ssh' do stub_url('git@gitlab.com:gitlab-org/gitlab-ce.git') - expect(submodule_links(submodule_item)).to eq([ 'https://gitlab.com/gitlab-org/gitlab-ce', 'https://gitlab.com/gitlab-org/gitlab-ce/tree/hash' ]) + expect(submodule_links(submodule_item)).to eq(['https://gitlab.com/gitlab-org/gitlab-ce', 'https://gitlab.com/gitlab-org/gitlab-ce/tree/hash']) end it 'detects http' do stub_url('http://gitlab.com/gitlab-org/gitlab-ce.git') - expect(submodule_links(submodule_item)).to eq([ 'https://gitlab.com/gitlab-org/gitlab-ce', 'https://gitlab.com/gitlab-org/gitlab-ce/tree/hash' ]) + expect(submodule_links(submodule_item)).to eq(['https://gitlab.com/gitlab-org/gitlab-ce', 'https://gitlab.com/gitlab-org/gitlab-ce/tree/hash']) end it 'detects https' do stub_url('https://gitlab.com/gitlab-org/gitlab-ce.git') - expect(submodule_links(submodule_item)).to eq([ 'https://gitlab.com/gitlab-org/gitlab-ce', 'https://gitlab.com/gitlab-org/gitlab-ce/tree/hash' ]) + expect(submodule_links(submodule_item)).to eq(['https://gitlab.com/gitlab-org/gitlab-ce', 'https://gitlab.com/gitlab-org/gitlab-ce/tree/hash']) end it 'returns original with non-standard url' do stub_url('http://gitlab.com/gitlab-org/gitlab-ce') - expect(submodule_links(submodule_item)).to eq([ repo.submodule_url_for, nil ]) + expect(submodule_links(submodule_item)).to eq([repo.submodule_url_for, nil]) stub_url('http://gitlab.com/another/gitlab-org/gitlab-ce.git') - expect(submodule_links(submodule_item)).to eq([ repo.submodule_url_for, nil ]) + expect(submodule_links(submodule_item)).to eq([repo.submodule_url_for, nil]) end end context 'submodule on unsupported' do it 'returns original' do stub_url('http://mygitserver.com/gitlab-org/gitlab-ce') - expect(submodule_links(submodule_item)).to eq([ repo.submodule_url_for, nil ]) + expect(submodule_links(submodule_item)).to eq([repo.submodule_url_for, nil]) stub_url('http://mygitserver.com/gitlab-org/gitlab-ce.git') - expect(submodule_links(submodule_item)).to eq([ repo.submodule_url_for, nil ]) + expect(submodule_links(submodule_item)).to eq([repo.submodule_url_for, nil]) end end diff --git a/spec/helpers/todos_helper_spec.rb b/spec/helpers/todos_helper_spec.rb new file mode 100644 index 00000000000..21e0e74e008 --- /dev/null +++ b/spec/helpers/todos_helper_spec.rb @@ -0,0 +1,57 @@ +require "spec_helper" + +describe TodosHelper do + include GitlabRoutingHelper + + describe '#todo_target_path' do + let(:project) { create(:project) } + let(:merge_request) { create(:merge_request, target_project: project, source_project: project) } + let(:issue) { create(:issue, project: project) } + let(:note) { create(:note_on_issue, noteable: issue, project: project) } + + let(:mr_todo) { build(:todo, project: project, target: merge_request) } + let(:issue_todo) { build(:todo, project: project, target: issue) } + let(:note_todo) { build(:todo, project: project, target: issue, note: note) } + let(:build_failed_todo) { build(:todo, :build_failed, project: project, target: merge_request) } + + it 'returns correct path to the todo MR' do + expect(todo_target_path(mr_todo)). + to eq("/#{project.full_path}/merge_requests/#{merge_request.iid}") + end + + it 'returns correct path to the todo issue' do + expect(todo_target_path(issue_todo)). + to eq("/#{project.full_path}/issues/#{issue.iid}") + end + + it 'returns correct path to the todo note' do + expect(todo_target_path(note_todo)). + to eq("/#{project.full_path}/issues/#{issue.iid}#note_#{note.id}") + end + + it 'returns correct path to build_todo MR when pipeline failed' do + expect(todo_target_path(build_failed_todo)). + to eq("/#{project.full_path}/merge_requests/#{merge_request.iid}/pipelines") + end + end + + describe '#todo_projects_options' do + let(:projects) { create_list(:empty_project, 3) } + let(:user) { create(:user) } + + it 'returns users authorised projects in json format' do + projects.first.add_developer(user) + projects.second.add_developer(user) + + allow(helper).to receive(:current_user).and_return(user) + + expected_results = [ + { 'id' => '', 'text' => 'Any Project' }, + { 'id' => projects.second.id, 'text' => projects.second.name_with_namespace }, + { 'id' => projects.first.id, 'text' => projects.first.name_with_namespace } + ] + + expect(JSON.parse(helper.todo_projects_options)).to match_array(expected_results) + end + end +end diff --git a/spec/helpers/version_check_helper_spec.rb b/spec/helpers/version_check_helper_spec.rb new file mode 100644 index 00000000000..889fe441171 --- /dev/null +++ b/spec/helpers/version_check_helper_spec.rb @@ -0,0 +1,34 @@ +require 'spec_helper' + +describe VersionCheckHelper do + describe '#version_status_badge' do + it 'should return nil if not dev environment and not enabled' do + allow(Rails.env).to receive(:production?) { false } + allow(current_application_settings).to receive(:version_check_enabled) { false } + + expect(helper.version_status_badge).to be(nil) + end + + context 'when production and enabled' do + before do + allow(Rails.env).to receive(:production?) { true } + allow(current_application_settings).to receive(:version_check_enabled) { true } + allow_any_instance_of(VersionCheck).to receive(:url) { 'https://version.host.com/check.svg?gitlab_info=xxx' } + + @image_tag = helper.version_status_badge + end + + it 'should return an image tag' do + expect(@image_tag).to match(/^<img/) + end + + it 'should have a js prefixed css class' do + expect(@image_tag).to match(/class="js-version-status-badge"/) + end + + it 'should have a VersionCheck url as the src' do + expect(@image_tag).to match(/src="https:\/\/version\.host\.com\/check\.svg\?gitlab_info=xxx"/) + end + end + end +end diff --git a/spec/initializers/6_validations_spec.rb b/spec/initializers/6_validations_spec.rb index baab30f482f..374517fec37 100644 --- a/spec/initializers/6_validations_spec.rb +++ b/spec/initializers/6_validations_spec.rb @@ -12,43 +12,77 @@ describe '6_validations', lib: true do FileUtils.rm_rf('tmp/tests/paths') end - context 'with correct settings' do - before do - mock_storages('foo' => 'tmp/tests/paths/a/b/c', 'bar' => 'tmp/tests/paths/a/b/d') + describe 'validate_storages_config' do + context 'with correct settings' do + before do + mock_storages('foo' => { 'path' => 'tmp/tests/paths/a/b/c' }, 'bar' => { 'path' => 'tmp/tests/paths/a/b/d' }) + end + + it 'passes through' do + expect { validate_storages_config }.not_to raise_error + end end - it 'passes through' do - expect { validate_storages }.not_to raise_error + context 'with invalid storage names' do + before do + mock_storages('name with spaces' => { 'path' => 'tmp/tests/paths/a/b/c' }) + end + + it 'throws an error' do + expect { validate_storages_config }.to raise_error('"name with spaces" is not a valid storage name. Please fix this in your gitlab.yml before starting GitLab.') + end end - end - context 'with invalid storage names' do - before do - mock_storages('name with spaces' => 'tmp/tests/paths/a/b/c') + context 'with incomplete settings' do + before do + mock_storages('foo' => {}) + end + + it 'throws an error suggesting the user to update its settings' do + expect { validate_storages_config }.to raise_error('foo is not a valid storage, because it has no `path` key. Refer to gitlab.yml.example for an updated example. Please fix this in your gitlab.yml before starting GitLab.') + end end - it 'throws an error' do - expect { validate_storages }.to raise_error('"name with spaces" is not a valid storage name. Please fix this in your gitlab.yml before starting GitLab.') + context 'with deprecated settings structure' do + before do + mock_storages('foo' => 'tmp/tests/paths/a/b/c') + end + + it 'throws an error suggesting the user to update its settings' do + expect { validate_storages_config }.to raise_error("foo is not a valid storage, because it has no `path` key. It may be configured as:\n\nfoo:\n path: tmp/tests/paths/a/b/c\n\nFor source installations, update your config/gitlab.yml Refer to gitlab.yml.example for an updated example.\n\nIf you're using the Gitlab Development Kit, you can update your configuration running `gdk reconfigure`.\n") + end end end - context 'with nested storage paths' do - before do - mock_storages('foo' => 'tmp/tests/paths/a/b/c', 'bar' => 'tmp/tests/paths/a/b/c/d') - end + describe 'validate_storages_paths' do + context 'with correct settings' do + before do + mock_storages('foo' => { 'path' => 'tmp/tests/paths/a/b/c' }, 'bar' => { 'path' => 'tmp/tests/paths/a/b/d' }) + end - it 'throws an error' do - expect { validate_storages }.to raise_error('bar is a nested path of foo. Nested paths are not supported for repository storages. Please fix this in your gitlab.yml before starting GitLab.') + it 'passes through' do + expect { validate_storages_paths }.not_to raise_error + end end - end - context 'with similar but un-nested storage paths' do - before do - mock_storages('foo' => 'tmp/tests/paths/a/b/c', 'bar' => 'tmp/tests/paths/a/b/c2') + context 'with nested storage paths' do + before do + mock_storages('foo' => { 'path' => 'tmp/tests/paths/a/b/c' }, 'bar' => { 'path' => 'tmp/tests/paths/a/b/c/d' }) + end + + it 'throws an error' do + expect { validate_storages_paths }.to raise_error('bar is a nested path of foo. Nested paths are not supported for repository storages. Please fix this in your gitlab.yml before starting GitLab.') + end end - it 'passes through' do - expect { validate_storages }.not_to raise_error + context 'with similar but un-nested storage paths' do + before do + mock_storages('foo' => { 'path' => 'tmp/tests/paths/a/b/c' }, 'bar' => { 'path' => 'tmp/tests/paths/a/b/c2' }) + end + + it 'passes through' do + expect { validate_storages_paths }.not_to raise_error + end end end diff --git a/spec/initializers/metrics_spec.rb b/spec/initializers/8_metrics_spec.rb index bb595162370..570754621f3 100644 --- a/spec/initializers/metrics_spec.rb +++ b/spec/initializers/8_metrics_spec.rb @@ -1,5 +1,5 @@ require 'spec_helper' -require_relative '../../config/initializers/metrics' +require_relative '../../config/initializers/8_metrics' describe 'instrument_classes', lib: true do let(:config) { double(:config) } diff --git a/spec/initializers/doorkeeper_spec.rb b/spec/initializers/doorkeeper_spec.rb new file mode 100644 index 00000000000..74bdbb01166 --- /dev/null +++ b/spec/initializers/doorkeeper_spec.rb @@ -0,0 +1,71 @@ +require 'spec_helper' +require_relative '../../config/initializers/doorkeeper' + +describe Doorkeeper.configuration do + describe '#default_scopes' do + it 'matches Gitlab::Auth::DEFAULT_SCOPES' do + expect(subject.default_scopes).to eq Gitlab::Auth::DEFAULT_SCOPES + end + end + + describe '#optional_scopes' do + it 'matches Gitlab::Auth::OPTIONAL_SCOPES' do + expect(subject.optional_scopes).to eq Gitlab::Auth::OPTIONAL_SCOPES + end + end + + describe '#resource_owner_authenticator' do + subject { controller.instance_exec(&Doorkeeper.configuration.authenticate_resource_owner) } + + let(:controller) { double } + + before do + allow(controller).to receive(:current_user).and_return(current_user) + allow(controller).to receive(:session).and_return({}) + allow(controller).to receive(:request).and_return(OpenStruct.new(fullpath: '/return-path')) + allow(controller).to receive(:redirect_to) + allow(controller).to receive(:new_user_session_url).and_return('/login') + end + + context 'with a user present' do + let(:current_user) { create(:user) } + + it 'returns the user' do + expect(subject).to eq current_user + end + + it 'does not redirect' do + expect(controller).not_to receive(:redirect_to) + + subject + end + + it 'does not store the return path' do + subject + + expect(controller.session).not_to include :user_return_to + end + end + + context 'without a user present' do + let(:current_user) { nil } + + # NOTE: this is required for doorkeeper-openid_connect + it 'returns nil' do + expect(subject).to eq nil + end + + it 'redirects to the login form' do + expect(controller).to receive(:redirect_to).with('/login') + + subject + end + + it 'stores the return path' do + subject + + expect(controller.session[:user_return_to]).to eq '/return-path' + end + end + end +end diff --git a/spec/initializers/secret_token_spec.rb b/spec/initializers/secret_token_spec.rb index ad7f032d1e5..65c97da2efd 100644 --- a/spec/initializers/secret_token_spec.rb +++ b/spec/initializers/secret_token_spec.rb @@ -6,6 +6,9 @@ describe 'create_tokens', lib: true do let(:secrets) { ActiveSupport::OrderedOptions.new } + HEX_KEY = /\h{128}/ + RSA_KEY = /\A-----BEGIN RSA PRIVATE KEY-----\n.+\n-----END RSA PRIVATE KEY-----\n\Z/m + before do allow(File).to receive(:write) allow(File).to receive(:delete) @@ -15,7 +18,7 @@ describe 'create_tokens', lib: true do allow(self).to receive(:exit) end - context 'setting secret_key_base and otp_key_base' do + context 'setting secret keys' do context 'when none of the secrets exist' do before do stub_env('SECRET_KEY_BASE', nil) @@ -24,19 +27,29 @@ describe 'create_tokens', lib: true do allow(self).to receive(:warn_missing_secret) end - it 'generates different secrets for secret_key_base, otp_key_base, and db_key_base' do + it 'generates different hashes for secret_key_base, otp_key_base, and db_key_base' do create_tokens keys = secrets.values_at(:secret_key_base, :otp_key_base, :db_key_base) expect(keys.uniq).to eq(keys) - expect(keys.map(&:length)).to all(eq(128)) + expect(keys).to all(match(HEX_KEY)) + end + + it 'generates an RSA key for jws_private_key' do + create_tokens + + keys = secrets.values_at(:jws_private_key) + + expect(keys.uniq).to eq(keys) + expect(keys).to all(match(RSA_KEY)) end it 'warns about the secrets to add to secrets.yml' do expect(self).to receive(:warn_missing_secret).with('secret_key_base') expect(self).to receive(:warn_missing_secret).with('otp_key_base') expect(self).to receive(:warn_missing_secret).with('db_key_base') + expect(self).to receive(:warn_missing_secret).with('jws_private_key') create_tokens end @@ -48,6 +61,7 @@ describe 'create_tokens', lib: true do expect(new_secrets['secret_key_base']).to eq(secrets.secret_key_base) expect(new_secrets['otp_key_base']).to eq(secrets.otp_key_base) expect(new_secrets['db_key_base']).to eq(secrets.db_key_base) + expect(new_secrets['jws_private_key']).to eq(secrets.jws_private_key) end create_tokens @@ -63,6 +77,7 @@ describe 'create_tokens', lib: true do context 'when the other secrets all exist' do before do secrets.db_key_base = 'db_key_base' + secrets.jws_private_key = 'jws_private_key' allow(File).to receive(:exist?).with('.secret').and_return(true) allow(File).to receive(:read).with('.secret').and_return('file_key') @@ -73,6 +88,7 @@ describe 'create_tokens', lib: true do stub_env('SECRET_KEY_BASE', 'env_key') secrets.secret_key_base = 'secret_key_base' secrets.otp_key_base = 'otp_key_base' + secrets.jws_private_key = 'jws_private_key' end it 'does not issue a warning' do @@ -98,6 +114,7 @@ describe 'create_tokens', lib: true do before do secrets.secret_key_base = 'secret_key_base' secrets.otp_key_base = 'otp_key_base' + secrets.jws_private_key = 'jws_private_key' end it 'does not write any files' do @@ -112,6 +129,7 @@ describe 'create_tokens', lib: true do expect(secrets.secret_key_base).to eq('secret_key_base') expect(secrets.otp_key_base).to eq('otp_key_base') expect(secrets.db_key_base).to eq('db_key_base') + expect(secrets.jws_private_key).to eq('jws_private_key') end it 'deletes the .secret file' do @@ -135,6 +153,7 @@ describe 'create_tokens', lib: true do expect(new_secrets['secret_key_base']).to eq('file_key') expect(new_secrets['otp_key_base']).to eq('file_key') expect(new_secrets['db_key_base']).to eq('db_key_base') + expect(new_secrets['jws_private_key']).to eq('jws_private_key') end create_tokens diff --git a/spec/initializers/trusted_proxies_spec.rb b/spec/initializers/trusted_proxies_spec.rb index 290e47763eb..ff8b8daa347 100644 --- a/spec/initializers/trusted_proxies_spec.rb +++ b/spec/initializers/trusted_proxies_spec.rb @@ -27,7 +27,7 @@ describe 'trusted_proxies', lib: true do context 'with private IP ranges added' do before do - set_trusted_proxies([ "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16" ]) + set_trusted_proxies(["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]) end it 'filters out private and local IPs' do @@ -39,7 +39,7 @@ describe 'trusted_proxies', lib: true do context 'with proxy IP added' do before do - set_trusted_proxies([ "60.98.25.47" ]) + set_trusted_proxies(["60.98.25.47"]) end it 'filters out proxy IP' do diff --git a/spec/javascripts/.eslintrc b/spec/javascripts/.eslintrc index fbd9bb9f0ff..3d922021978 100644 --- a/spec/javascripts/.eslintrc +++ b/spec/javascripts/.eslintrc @@ -18,7 +18,8 @@ "sandbox": false, "setFixtures": false, "setStyleFixtures": false, - "spyOnEvent": false + "spyOnEvent": false, + "ClassSpecHelper": false }, "plugins": ["jasmine"], "rules": { diff --git a/spec/javascripts/abuse_reports_spec.js.es6 b/spec/javascripts/abuse_reports_spec.js index 76b370b345b..76b370b345b 100644 --- a/spec/javascripts/abuse_reports_spec.js.es6 +++ b/spec/javascripts/abuse_reports_spec.js diff --git a/spec/javascripts/activities_spec.js.es6 b/spec/javascripts/activities_spec.js index e6a6fc36ca1..e6a6fc36ca1 100644 --- a/spec/javascripts/activities_spec.js.es6 +++ b/spec/javascripts/activities_spec.js diff --git a/spec/javascripts/ajax_loading_spinner_spec.js b/spec/javascripts/ajax_loading_spinner_spec.js new file mode 100644 index 00000000000..a68bccb16f4 --- /dev/null +++ b/spec/javascripts/ajax_loading_spinner_spec.js @@ -0,0 +1,58 @@ +require('~/extensions/array'); +require('jquery'); +require('jquery-ujs'); +require('~/ajax_loading_spinner'); + +describe('Ajax Loading Spinner', () => { + const fixtureTemplate = 'static/ajax_loading_spinner.html.raw'; + preloadFixtures(fixtureTemplate); + + beforeEach(() => { + loadFixtures(fixtureTemplate); + gl.AjaxLoadingSpinner.init(); + }); + + it('change current icon with spinner icon and disable link while waiting ajax response', (done) => { + spyOn(jQuery, 'ajax').and.callFake((req) => { + const xhr = new XMLHttpRequest(); + const ajaxLoadingSpinner = document.querySelector('.js-ajax-loading-spinner'); + const icon = ajaxLoadingSpinner.querySelector('i'); + + req.beforeSend(xhr, { dataType: 'text/html' }); + + expect(icon).not.toHaveClass('fa-trash-o'); + expect(icon).toHaveClass('fa-spinner'); + expect(icon).toHaveClass('fa-spin'); + expect(icon.dataset.icon).toEqual('fa-trash-o'); + expect(ajaxLoadingSpinner.getAttribute('disabled')).toEqual(''); + + req.complete({}); + + done(); + const deferred = $.Deferred(); + return deferred.promise(); + }); + document.querySelector('.js-ajax-loading-spinner').click(); + }); + + it('use original icon again and enabled the link after complete the ajax request', (done) => { + spyOn(jQuery, 'ajax').and.callFake((req) => { + const xhr = new XMLHttpRequest(); + const ajaxLoadingSpinner = document.querySelector('.js-ajax-loading-spinner'); + + req.beforeSend(xhr, { dataType: 'text/html' }); + req.complete({}); + + const icon = ajaxLoadingSpinner.querySelector('i'); + expect(icon).toHaveClass('fa-trash-o'); + expect(icon).not.toHaveClass('fa-spinner'); + expect(icon).not.toHaveClass('fa-spin'); + expect(ajaxLoadingSpinner.getAttribute('disabled')).toEqual(null); + + done(); + const deferred = $.Deferred(); + return deferred.promise(); + }); + document.querySelector('.js-ajax-loading-spinner').click(); + }); +}); diff --git a/spec/javascripts/awards_handler_spec.js b/spec/javascripts/awards_handler_spec.js index e5826f9c29f..ea7753c7a1d 100644 --- a/spec/javascripts/awards_handler_spec.js +++ b/spec/javascripts/awards_handler_spec.js @@ -1,11 +1,10 @@ /* eslint-disable space-before-function-paren, no-var, one-var, one-var-declaration-per-line, no-unused-expressions, comma-dangle, new-parens, no-unused-vars, quotes, jasmine/no-spec-dupes, prefer-template, max-len */ -/* global AwardsHandler */ -require('~/awards_handler'); -require('./fixtures/emoji_menu'); +import Cookies from 'js-cookie'; +import AwardsHandler from '~/awards_handler'; (function() { - var awardsHandler, lazyAssert, urlRoot; + var awardsHandler, lazyAssert, urlRoot, openAndWaitForEmojiMenu; awardsHandler = null; @@ -13,14 +12,6 @@ require('./fixtures/emoji_menu'); window.gon || (window.gon = {}); - gl.emojiAliases = function() { - return { - '+1': 'thumbsup', - '-1': 'thumbsdown' - }; - }; - - gon.award_menu_url = '/emojis'; urlRoot = gon.relative_url_root; lazyAssert = function(done, assertFn) { @@ -32,22 +23,37 @@ require('./fixtures/emoji_menu'); }; describe('AwardsHandler', function() { - preloadFixtures('issues/open-issue.html.raw'); + preloadFixtures('issues/issue_with_comment.html.raw'); beforeEach(function() { - loadFixtures('issues/open-issue.html.raw'); + loadFixtures('issues/issue_with_comment.html.raw'); awardsHandler = new AwardsHandler; spyOn(awardsHandler, 'postEmoji').and.callFake((function(_this) { return function(url, emoji, cb) { return cb(); }; })(this)); - spyOn(jQuery, 'get').and.callFake(function(req, cb) { - return cb(window.emojiMenu); - }); + + let isEmojiMenuBuilt = false; + openAndWaitForEmojiMenu = function() { + return new Promise((resolve, reject) => { + if (isEmojiMenuBuilt) { + resolve(); + } else { + $('.js-add-award').eq(0).click(); + const $menu = $('.emoji-menu'); + $menu.one('build-emoji-menu-finish', () => { + isEmojiMenuBuilt = true; + resolve(); + }); + } + }); + }; }); afterEach(function() { // restore original url root value gon.relative_url_root = urlRoot; + + awardsHandler.destroy(); }); describe('::showEmojiMenu', function() { it('should show emoji menu when Add emoji button clicked', function(done) { @@ -62,10 +68,9 @@ require('./fixtures/emoji_menu'); }); }); it('should also show emoji menu for the smiley icon in notes', function(done) { - $('.note-action-button').click(); + $('.js-add-award.note-action-button').click(); return lazyAssert(done, function() { - var $emojiMenu; - $emojiMenu = $('.emoji-menu'); + var $emojiMenu = $('.emoji-menu'); return expect($emojiMenu.length).toBe(1); }); }); @@ -86,7 +91,7 @@ require('./fixtures/emoji_menu'); var $emojiButton, $votesBlock; $votesBlock = $('.js-awards-block').eq(0); awardsHandler.addAwardToEmojiBar($votesBlock, 'heart', false); - $emojiButton = $votesBlock.find('[data-emoji=heart]'); + $emojiButton = $votesBlock.find('[data-name=heart]'); expect($emojiButton.length).toBe(1); expect($emojiButton.next('.js-counter').text()).toBe('1'); return expect($votesBlock.hasClass('hidden')).toBe(false); @@ -96,14 +101,14 @@ require('./fixtures/emoji_menu'); $votesBlock = $('.js-awards-block').eq(0); awardsHandler.addAwardToEmojiBar($votesBlock, 'heart', false); awardsHandler.addAwardToEmojiBar($votesBlock, 'heart', false); - $emojiButton = $votesBlock.find('[data-emoji=heart]'); + $emojiButton = $votesBlock.find('[data-name=heart]'); return expect($emojiButton.length).toBe(0); }); return it('should decrement the emoji counter', function() { var $emojiButton, $votesBlock; $votesBlock = $('.js-awards-block').eq(0); awardsHandler.addAwardToEmojiBar($votesBlock, 'heart', false); - $emojiButton = $votesBlock.find('[data-emoji=heart]'); + $emojiButton = $votesBlock.find('[data-name=heart]'); $emojiButton.next('.js-counter').text(5); awardsHandler.addAwardToEmojiBar($votesBlock, 'heart', false); expect($emojiButton.length).toBe(1); @@ -120,8 +125,8 @@ require('./fixtures/emoji_menu'); var $thumbsDownEmoji, $thumbsUpEmoji, $votesBlock, awardUrl; awardUrl = awardsHandler.getAwardUrl(); $votesBlock = $('.js-awards-block').eq(0); - $thumbsUpEmoji = $votesBlock.find('[data-emoji=thumbsup]').parent(); - $thumbsDownEmoji = $votesBlock.find('[data-emoji=thumbsdown]').parent(); + $thumbsUpEmoji = $votesBlock.find('[data-name=thumbsup]').parent(); + $thumbsDownEmoji = $votesBlock.find('[data-name=thumbsdown]').parent(); awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false); expect($thumbsUpEmoji.hasClass('active')).toBe(true); expect($thumbsDownEmoji.hasClass('active')).toBe(false); @@ -138,9 +143,9 @@ require('./fixtures/emoji_menu'); awardUrl = awardsHandler.getAwardUrl(); $votesBlock = $('.js-awards-block').eq(0); awardsHandler.addAward($votesBlock, awardUrl, 'fire', false); - expect($votesBlock.find('[data-emoji=fire]').length).toBe(1); - awardsHandler.removeEmoji($votesBlock.find('[data-emoji=fire]').closest('button')); - return expect($votesBlock.find('[data-emoji=fire]').length).toBe(0); + expect($votesBlock.find('[data-name=fire]').length).toBe(1); + awardsHandler.removeEmoji($votesBlock.find('[data-name=fire]').closest('button')); + return expect($votesBlock.find('[data-name=fire]').length).toBe(0); }); }); describe('::addYouToUserList', function() { @@ -148,7 +153,7 @@ require('./fixtures/emoji_menu'); var $thumbsUpEmoji, $votesBlock, awardUrl; awardUrl = awardsHandler.getAwardUrl(); $votesBlock = $('.js-awards-block').eq(0); - $thumbsUpEmoji = $votesBlock.find('[data-emoji=thumbsup]').parent(); + $thumbsUpEmoji = $votesBlock.find('[data-name=thumbsup]').parent(); $thumbsUpEmoji.attr('data-title', 'sam, jerry, max, and andy'); awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false); $thumbsUpEmoji.tooltip(); @@ -158,7 +163,7 @@ require('./fixtures/emoji_menu'); var $thumbsUpEmoji, $votesBlock, awardUrl; awardUrl = awardsHandler.getAwardUrl(); $votesBlock = $('.js-awards-block').eq(0); - $thumbsUpEmoji = $votesBlock.find('[data-emoji=thumbsup]').parent(); + $thumbsUpEmoji = $votesBlock.find('[data-name=thumbsup]').parent(); $thumbsUpEmoji.attr('data-title', 'sam'); awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false); $thumbsUpEmoji.tooltip(); @@ -170,7 +175,7 @@ require('./fixtures/emoji_menu'); var $thumbsUpEmoji, $votesBlock, awardUrl; awardUrl = awardsHandler.getAwardUrl(); $votesBlock = $('.js-awards-block').eq(0); - $thumbsUpEmoji = $votesBlock.find('[data-emoji=thumbsup]').parent(); + $thumbsUpEmoji = $votesBlock.find('[data-name=thumbsup]').parent(); $thumbsUpEmoji.attr('data-title', 'You, sam, jerry, max, and andy'); $thumbsUpEmoji.addClass('active'); awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false); @@ -181,7 +186,7 @@ require('./fixtures/emoji_menu'); var $thumbsUpEmoji, $votesBlock, awardUrl; awardUrl = awardsHandler.getAwardUrl(); $votesBlock = $('.js-awards-block').eq(0); - $thumbsUpEmoji = $votesBlock.find('[data-emoji=thumbsup]').parent(); + $thumbsUpEmoji = $votesBlock.find('[data-name=thumbsup]').parent(); $thumbsUpEmoji.attr('data-title', 'You and sam'); $thumbsUpEmoji.addClass('active'); awardsHandler.addAward($votesBlock, awardUrl, 'thumbsup', false); @@ -190,42 +195,111 @@ require('./fixtures/emoji_menu'); }); }); describe('search', function() { - return it('should filter the emoji', function() { - $('.js-add-award').eq(0).click(); - expect($('[data-emoji=angel]').is(':visible')).toBe(true); - expect($('[data-emoji=anger]').is(':visible')).toBe(true); - $('#emoji_search').val('ali').trigger('keyup'); - expect($('[data-emoji=angel]').is(':visible')).toBe(false); - expect($('[data-emoji=anger]').is(':visible')).toBe(false); - return expect($('[data-emoji=alien]').is(':visible')).toBe(true); + return it('should filter the emoji', function(done) { + return openAndWaitForEmojiMenu() + .then(() => { + expect($('[data-name=angel]').is(':visible')).toBe(true); + expect($('[data-name=anger]').is(':visible')).toBe(true); + $('#emoji_search').val('ali').trigger('input'); + expect($('[data-name=angel]').is(':visible')).toBe(false); + expect($('[data-name=anger]').is(':visible')).toBe(false); + expect($('[data-name=alien]').is(':visible')).toBe(true); + }) + .then(done) + .catch((err) => { + done.fail(`Failed to open and build emoji menu: ${err.message}`); + }); }); }); - return describe('emoji menu', function() { - var openEmojiMenuAndAddEmoji, selector; - selector = '[data-emoji=sunglasses]'; - openEmojiMenuAndAddEmoji = function() { - var $block, $emoji, $menu; - $('.js-add-award').eq(0).click(); - $menu = $('.emoji-menu'); - $block = $('.js-awards-block'); - $emoji = $menu.find('.emoji-menu-list:not(.frequent-emojis) ' + selector); - expect($emoji.length).toBe(1); - expect($block.find(selector).length).toBe(0); - $emoji.click(); - expect($menu.hasClass('.is-visible')).toBe(false); - return expect($block.find(selector).length).toBe(1); + describe('emoji menu', function() { + const emojiSelector = '[data-name="sunglasses"]'; + const openEmojiMenuAndAddEmoji = function() { + return openAndWaitForEmojiMenu() + .then(() => { + const $menu = $('.emoji-menu'); + const $block = $('.js-awards-block'); + const $emoji = $menu.find('.emoji-menu-list:not(.frequent-emojis) ' + emojiSelector); + + expect($emoji.length).toBe(1); + expect($block.find(emojiSelector).length).toBe(0); + $emoji.click(); + expect($menu.hasClass('.is-visible')).toBe(false); + expect($block.find(emojiSelector).length).toBe(1); + }); }; - it('should add selected emoji to awards block', function() { - return openEmojiMenuAndAddEmoji(); + it('should add selected emoji to awards block', function(done) { + return openEmojiMenuAndAddEmoji() + .then(done) + .catch((err) => { + done.fail(`Failed to open and build emoji menu: ${err.message}`); + }); }); - return it('should remove already selected emoji', function() { - var $block, $emoji; - openEmojiMenuAndAddEmoji(); - $('.js-add-award').eq(0).click(); - $block = $('.js-awards-block'); - $emoji = $('.emoji-menu').find(".emoji-menu-list:not(.frequent-emojis) " + selector); - $emoji.click(); - return expect($block.find(selector).length).toBe(0); + it('should remove already selected emoji', function(done) { + return openEmojiMenuAndAddEmoji() + .then(() => { + $('.js-add-award').eq(0).click(); + const $block = $('.js-awards-block'); + const $emoji = $('.emoji-menu').find(`.emoji-menu-list:not(.frequent-emojis) ${emojiSelector}`); + $emoji.click(); + expect($block.find(emojiSelector).length).toBe(0); + }) + .then(done) + .catch((err) => { + done.fail(`Failed to open and build emoji menu: ${err.message}`); + }); + }); + }); + + describe('frequently used emojis', function() { + beforeEach(() => { + // Clear it out + Cookies.set('frequently_used_emojis', ''); + }); + + it('shouldn\'t have any "Frequently used" heading if no frequently used emojis', function(done) { + return openAndWaitForEmojiMenu() + .then(() => { + const emojiMenu = document.querySelector('.emoji-menu'); + Array.prototype.forEach.call(emojiMenu.querySelectorAll('.emoji-menu-title'), (title) => { + expect(title.textContent.trim().toLowerCase()).not.toBe('frequently used'); + }); + }) + .then(done) + .catch((err) => { + done.fail(`Failed to open and build emoji menu: ${err.message}`); + }); + }); + + it('should have any frequently used section when there are frequently used emojis', function(done) { + awardsHandler.addEmojiToFrequentlyUsedList('8ball'); + + return openAndWaitForEmojiMenu() + .then(() => { + const emojiMenu = document.querySelector('.emoji-menu'); + const hasFrequentlyUsedHeading = Array.prototype.some.call(emojiMenu.querySelectorAll('.emoji-menu-title'), title => + title.textContent.trim().toLowerCase() === 'frequently used' + ); + + expect(hasFrequentlyUsedHeading).toBe(true); + }) + .then(done) + .catch((err) => { + done.fail(`Failed to open and build emoji menu: ${err.message}`); + }); + }); + + it('should disregard invalid frequently used emoji that are being attempted to be added', function() { + awardsHandler.addEmojiToFrequentlyUsedList('8ball'); + awardsHandler.addEmojiToFrequentlyUsedList('invalid_emoji'); + awardsHandler.addEmojiToFrequentlyUsedList('grinning'); + + expect(awardsHandler.getFrequentlyUsedEmojis()).toEqual(['8ball', 'grinning']); + }); + + it('should disregard invalid frequently used emoji already set in cookie', function() { + Cookies.set('frequently_used_emojis', '8ball,invalid_emoji,grinning'); + + expect(awardsHandler.getFrequentlyUsedEmojis()).toEqual(['8ball', 'grinning']); }); }); }); diff --git a/spec/javascripts/behaviors/bind_in_out_spec.js b/spec/javascripts/behaviors/bind_in_out_spec.js new file mode 100644 index 00000000000..dd9ab33289f --- /dev/null +++ b/spec/javascripts/behaviors/bind_in_out_spec.js @@ -0,0 +1,189 @@ +import BindInOut from '~/behaviors/bind_in_out'; +import ClassSpecHelper from '../helpers/class_spec_helper'; + +describe('BindInOut', function () { + describe('.constructor', function () { + beforeEach(function () { + this.in = {}; + this.out = {}; + + this.bindInOut = new BindInOut(this.in, this.out); + }); + + it('should set .in', function () { + expect(this.bindInOut.in).toBe(this.in); + }); + + it('should set .out', function () { + expect(this.bindInOut.out).toBe(this.out); + }); + + it('should set .eventWrapper', function () { + expect(this.bindInOut.eventWrapper).toEqual({}); + }); + + describe('if .in is an input', function () { + beforeEach(function () { + this.bindInOut = new BindInOut({ tagName: 'INPUT' }); + }); + + it('should set .eventType to keyup ', function () { + expect(this.bindInOut.eventType).toEqual('keyup'); + }); + }); + + describe('if .in is a textarea', function () { + beforeEach(function () { + this.bindInOut = new BindInOut({ tagName: 'TEXTAREA' }); + }); + + it('should set .eventType to keyup ', function () { + expect(this.bindInOut.eventType).toEqual('keyup'); + }); + }); + + describe('if .in is not an input or textarea', function () { + beforeEach(function () { + this.bindInOut = new BindInOut({ tagName: 'SELECT' }); + }); + + it('should set .eventType to change ', function () { + expect(this.bindInOut.eventType).toEqual('change'); + }); + }); + }); + + describe('.addEvents', function () { + beforeEach(function () { + this.in = jasmine.createSpyObj('in', ['addEventListener']); + + this.bindInOut = new BindInOut(this.in); + + this.addEvents = this.bindInOut.addEvents(); + }); + + it('should set .eventWrapper.updateOut', function () { + expect(this.bindInOut.eventWrapper.updateOut).toEqual(jasmine.any(Function)); + }); + + it('should call .addEventListener', function () { + expect(this.in.addEventListener) + .toHaveBeenCalledWith( + this.bindInOut.eventType, + this.bindInOut.eventWrapper.updateOut, + ); + }); + + it('should return the instance', function () { + expect(this.addEvents).toBe(this.bindInOut); + }); + }); + + describe('.updateOut', function () { + beforeEach(function () { + this.in = { value: 'the-value' }; + this.out = { textContent: 'not-the-value' }; + + this.bindInOut = new BindInOut(this.in, this.out); + + this.updateOut = this.bindInOut.updateOut(); + }); + + it('should set .out.textContent to .in.value', function () { + expect(this.out.textContent).toBe(this.in.value); + }); + + it('should return the instance', function () { + expect(this.updateOut).toBe(this.bindInOut); + }); + }); + + describe('.removeEvents', function () { + beforeEach(function () { + this.in = jasmine.createSpyObj('in', ['removeEventListener']); + this.updateOut = () => {}; + + this.bindInOut = new BindInOut(this.in); + this.bindInOut.eventWrapper.updateOut = this.updateOut; + + this.removeEvents = this.bindInOut.removeEvents(); + }); + + it('should call .removeEventListener', function () { + expect(this.in.removeEventListener) + .toHaveBeenCalledWith( + this.bindInOut.eventType, + this.updateOut, + ); + }); + + it('should return the instance', function () { + expect(this.removeEvents).toBe(this.bindInOut); + }); + }); + + describe('.initAll', function () { + beforeEach(function () { + this.ins = [0, 1, 2]; + this.instances = []; + + spyOn(document, 'querySelectorAll').and.returnValue(this.ins); + spyOn(Array.prototype, 'map').and.callThrough(); + spyOn(BindInOut, 'init'); + + this.initAll = BindInOut.initAll(); + }); + + ClassSpecHelper.itShouldBeAStaticMethod(BindInOut, 'initAll'); + + it('should call .querySelectorAll', function () { + expect(document.querySelectorAll).toHaveBeenCalledWith('*[data-bind-in]'); + }); + + it('should call .map', function () { + expect(Array.prototype.map).toHaveBeenCalledWith(jasmine.any(Function)); + }); + + it('should call .init for each element', function () { + expect(BindInOut.init.calls.count()).toEqual(3); + }); + + it('should return an array of instances', function () { + expect(this.initAll).toEqual(jasmine.any(Array)); + }); + }); + + describe('.init', function () { + beforeEach(function () { + spyOn(BindInOut.prototype, 'addEvents').and.callFake(function () { return this; }); + spyOn(BindInOut.prototype, 'updateOut').and.callFake(function () { return this; }); + + this.init = BindInOut.init({}, {}); + }); + + ClassSpecHelper.itShouldBeAStaticMethod(BindInOut, 'init'); + + it('should call .addEvents', function () { + expect(BindInOut.prototype.addEvents).toHaveBeenCalled(); + }); + + it('should call .updateOut', function () { + expect(BindInOut.prototype.updateOut).toHaveBeenCalled(); + }); + + describe('if no anOut is provided', function () { + beforeEach(function () { + this.anIn = { dataset: { bindIn: 'the-data-bind-in' } }; + + spyOn(document, 'querySelector'); + + BindInOut.init(this.anIn); + }); + + it('should call .querySelector', function () { + expect(document.querySelector) + .toHaveBeenCalledWith(`*[data-bind-out="${this.anIn.dataset.bindIn}"]`); + }); + }); + }); +}); diff --git a/spec/javascripts/blob/create_branch_dropdown_spec.js b/spec/javascripts/blob/create_branch_dropdown_spec.js new file mode 100644 index 00000000000..c1179e572ae --- /dev/null +++ b/spec/javascripts/blob/create_branch_dropdown_spec.js @@ -0,0 +1,107 @@ +require('~/gl_dropdown'); +require('~/lib/utils/type_utility'); +require('~/blob/create_branch_dropdown'); +require('~/blob/target_branch_dropdown'); + +describe('CreateBranchDropdown', () => { + const fixtureTemplate = 'static/target_branch_dropdown.html.raw'; + // selectors + const createBranchSel = '.js-new-branch-btn'; + const backBtnSel = '.dropdown-menu-back'; + const cancelBtnSel = '.js-cancel-branch-btn'; + const branchNameSel = '#new_branch_name'; + const branchName = 'new_name'; + let dropdown; + + function createDropdown() { + const dropdownEl = document.querySelector('.js-project-branches-dropdown'); + const projectBranches = getJSONFixture('project_branches.json'); + dropdown = new gl.TargetBranchDropDown(dropdownEl); + dropdown.cachedRefs = projectBranches; + return dropdown; + } + + function createBranchBtn() { + return document.querySelector(createBranchSel); + } + + function backBtn() { + return document.querySelector(backBtnSel); + } + + function cancelBtn() { + return document.querySelector(cancelBtnSel); + } + + function branchNameEl() { + return document.querySelector(branchNameSel); + } + + function changeBranchName(text) { + branchNameEl().value = text; + branchNameEl().dispatchEvent(new Event('change')); + } + + preloadFixtures(fixtureTemplate); + + beforeEach(() => { + loadFixtures(fixtureTemplate); + createDropdown(); + }); + + it('disable submit when branch name is empty', () => { + expect(createBranchBtn()).toBeDisabled(); + }); + + it('enable submit when branch name is present', () => { + changeBranchName(branchName); + + expect(createBranchBtn()).not.toBeDisabled(); + }); + + it('resets the form when cancel btn is clicked and triggers dropdownback', () => { + const spyBackEvent = spyOnEvent(backBtnSel, 'click'); + changeBranchName(branchName); + + cancelBtn().click(); + + expect(branchNameEl()).toHaveValue(''); + expect(spyBackEvent).toHaveBeenTriggered(); + }); + + it('resets the form when back btn is clicked', () => { + changeBranchName(branchName); + + backBtn().click(); + + expect(branchNameEl()).toHaveValue(''); + }); + + describe('new branch creation', () => { + beforeEach(() => { + changeBranchName(branchName); + }); + it('sets the new branch name and updates the dropdown', () => { + spyOn(dropdown, 'setNewBranch'); + + createBranchBtn().click(); + + expect(dropdown.setNewBranch).toHaveBeenCalledWith(branchName); + }); + + it('resets the form', () => { + createBranchBtn().click(); + + expect(branchNameEl()).toHaveValue(''); + }); + + it('is triggered with enter keypress', () => { + spyOn(dropdown, 'setNewBranch'); + const enterEvent = new Event('keydown'); + enterEvent.which = 13; + branchNameEl().dispatchEvent(enterEvent); + + expect(dropdown.setNewBranch).toHaveBeenCalledWith(branchName); + }); + }); +}); diff --git a/spec/javascripts/blob/target_branch_dropdown_spec.js b/spec/javascripts/blob/target_branch_dropdown_spec.js new file mode 100644 index 00000000000..4fb79663c51 --- /dev/null +++ b/spec/javascripts/blob/target_branch_dropdown_spec.js @@ -0,0 +1,119 @@ +require('~/gl_dropdown'); +require('~/lib/utils/type_utility'); +require('~/blob/create_branch_dropdown'); +require('~/blob/target_branch_dropdown'); + +describe('TargetBranchDropdown', () => { + const fixtureTemplate = 'static/target_branch_dropdown.html.raw'; + let dropdown; + + function createDropdown() { + const projectBranches = getJSONFixture('project_branches.json'); + const dropdownEl = document.querySelector('.js-project-branches-dropdown'); + dropdown = new gl.TargetBranchDropDown(dropdownEl); + dropdown.cachedRefs = projectBranches; + dropdown.refreshData(); + return dropdown; + } + + function submitBtn() { + return document.querySelector('button[type="submit"]'); + } + + function searchField() { + return document.querySelector('.dropdown-page-one .dropdown-input-field'); + } + + function element() { + return document.querySelectorAll('div.dropdown-content li a'); + } + + function elementAtIndex(index) { + return element()[index]; + } + + function clickElementAtIndex(index) { + elementAtIndex(index).click(); + } + + preloadFixtures(fixtureTemplate); + + beforeEach(() => { + loadFixtures(fixtureTemplate); + createDropdown(); + }); + + it('disable submit when branch is not selected', () => { + document.querySelector('input[name="target_branch"]').value = null; + clickElementAtIndex(1); + + expect(submitBtn().getAttribute('disabled')).toEqual(''); + }); + + it('enable submit when a branch is selected', () => { + clickElementAtIndex(1); + + expect(submitBtn().getAttribute('disabled')).toBe(null); + }); + + it('triggers change.branch event on a branch click', () => { + spyOnEvent(dropdown.$dropdown, 'change.branch'); + clickElementAtIndex(0); + + expect('change.branch').toHaveBeenTriggeredOn(dropdown.$dropdown); + }); + + describe('#dropdownData', () => { + it('cache the refs', () => { + const refs = dropdown.cachedRefs; + dropdown.cachedRefs = null; + + dropdown.dropdownData(refs); + + expect(dropdown.cachedRefs).toEqual(refs); + }); + + it('returns the Branches with the newBranch and defaultBranch', () => { + const refs = dropdown.cachedRefs; + dropdown.branchInput.value = 'master'; + dropdown.newBranch = { id: 'new_branch', text: 'new_branch', title: 'new_branch' }; + + const branches = dropdown.dropdownData(refs).Branches; + + expect(branches.length).toEqual(4); + expect(branches[0]).toEqual(dropdown.newBranch); + expect(branches[1]).toEqual({ id: 'master', text: 'master', title: 'master' }); + expect(branches[2]).toEqual({ id: 'development', text: 'development', title: 'development' }); + expect(branches[3]).toEqual({ id: 'staging', text: 'staging', title: 'staging' }); + }); + }); + + describe('#setNewBranch', () => { + it('adds the new branch and select it', () => { + const branchName = 'new_branch'; + + dropdown.setNewBranch(branchName); + + expect(elementAtIndex(0)).toHaveClass('is-active'); + expect(elementAtIndex(0)).toContainHtml(branchName); + }); + + it("doesn't add a new branch if already exists in the list", () => { + const branchName = elementAtIndex(0).text; + const initialLength = element().length; + + dropdown.setNewBranch(branchName); + + expect(element().length).toEqual(initialLength); + }); + + it('clears the search filter', () => { + const branchName = elementAtIndex(0).text; + searchField().value = 'searching'; + + dropdown.setNewBranch(branchName); + + expect(searchField().value).toEqual(''); + }); + }); +}); diff --git a/spec/javascripts/boards/board_blank_state_spec.js b/spec/javascripts/boards/board_blank_state_spec.js new file mode 100644 index 00000000000..47baf83512f --- /dev/null +++ b/spec/javascripts/boards/board_blank_state_spec.js @@ -0,0 +1,93 @@ +/* global BoardService */ +import Vue from 'vue'; +import '~/boards/stores/boards_store'; +import boardBlankState from '~/boards/components/board_blank_state'; +import './mock_data'; + +describe('Boards blank state', () => { + let vm; + let fail = false; + + beforeEach((done) => { + const Comp = Vue.extend(boardBlankState); + + gl.issueBoards.BoardsStore.create(); + gl.boardService = new BoardService('/test/issue-boards/board', '', '1'); + + spyOn(gl.boardService, 'generateDefaultLists').and.callFake(() => new Promise((resolve, reject) => { + if (fail) { + reject(); + } else { + resolve({ + json() { + return [{ + id: 1, + title: 'To Do', + label: { id: 1 }, + }, { + id: 2, + title: 'Doing', + label: { id: 2 }, + }]; + }, + }); + } + })); + + vm = new Comp(); + + setTimeout(() => { + vm.$mount(); + done(); + }); + }); + + it('renders pre-defined labels', () => { + expect( + vm.$el.querySelectorAll('.board-blank-state-list li').length, + ).toBe(2); + + expect( + vm.$el.querySelectorAll('.board-blank-state-list li')[0].textContent.trim(), + ).toEqual('To Do'); + + expect( + vm.$el.querySelectorAll('.board-blank-state-list li')[1].textContent.trim(), + ).toEqual('Doing'); + }); + + it('clears blank state', (done) => { + vm.$el.querySelector('.btn-default').click(); + + setTimeout(() => { + expect(gl.issueBoards.BoardsStore.welcomeIsHidden()).toBeTruthy(); + + done(); + }); + }); + + it('creates pre-defined labels', (done) => { + vm.$el.querySelector('.btn-create').click(); + + setTimeout(() => { + expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(2); + expect(gl.issueBoards.BoardsStore.state.lists[0].title).toEqual('To Do'); + expect(gl.issueBoards.BoardsStore.state.lists[1].title).toEqual('Doing'); + + done(); + }); + }); + + it('resets the store if request fails', (done) => { + fail = true; + + vm.$el.querySelector('.btn-create').click(); + + setTimeout(() => { + expect(gl.issueBoards.BoardsStore.welcomeIsHidden()).toBeFalsy(); + expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(1); + + done(); + }); + }); +}); diff --git a/spec/javascripts/boards/board_card_spec.js b/spec/javascripts/boards/board_card_spec.js new file mode 100644 index 00000000000..be31f644e20 --- /dev/null +++ b/spec/javascripts/boards/board_card_spec.js @@ -0,0 +1,168 @@ +/* global Vue */ +/* global List */ +/* global ListLabel */ +/* global listObj */ +/* global boardsMockInterceptor */ +/* global BoardService */ + +require('~/boards/models/list'); +require('~/boards/models/label'); +require('~/boards/stores/boards_store'); +const boardCard = require('~/boards/components/board_card').default; +require('./mock_data'); + +describe('Issue card', () => { + let vm; + + beforeEach((done) => { + Vue.http.interceptors.push(boardsMockInterceptor); + + gl.boardService = new BoardService('/test/issue-boards/board', '', '1'); + gl.issueBoards.BoardsStore.create(); + gl.issueBoards.BoardsStore.detail.issue = {}; + + const BoardCardComp = Vue.extend(boardCard); + const list = new List(listObj); + const label1 = new ListLabel({ + id: 3, + title: 'testing 123', + color: 'blue', + text_color: 'white', + description: 'test', + }); + + setTimeout(() => { + list.issues[0].labels.push(label1); + + vm = new BoardCardComp({ + propsData: { + list, + issue: list.issues[0], + issueLinkBase: '/', + disabled: false, + index: 0, + rootPath: '/', + }, + }).$mount(); + done(); + }, 0); + }); + + afterEach(() => { + Vue.http.interceptors = _.without(Vue.http.interceptors, boardsMockInterceptor); + }); + + it('returns false when detailIssue is empty', () => { + expect(vm.issueDetailVisible).toBe(false); + }); + + it('returns true when detailIssue is equal to card issue', () => { + gl.issueBoards.BoardsStore.detail.issue = vm.issue; + + expect(vm.issueDetailVisible).toBe(true); + }); + + it('adds user-can-drag class if not disabled', () => { + expect(vm.$el.classList.contains('user-can-drag')).toBe(true); + }); + + it('does not add user-can-drag class disabled', (done) => { + vm.disabled = true; + + setTimeout(() => { + expect(vm.$el.classList.contains('user-can-drag')).toBe(false); + done(); + }, 0); + }); + + it('does not add disabled class', () => { + expect(vm.$el.classList.contains('is-disabled')).toBe(false); + }); + + it('adds disabled class is disabled is true', (done) => { + vm.disabled = true; + + setTimeout(() => { + expect(vm.$el.classList.contains('is-disabled')).toBe(true); + done(); + }, 0); + }); + + describe('mouse events', () => { + const triggerEvent = (eventName, el = vm.$el) => { + const event = document.createEvent('MouseEvents'); + event.initMouseEvent(eventName, true, true, window, 1, 0, 0, 0, 0, false, false, + false, false, 0, null); + + el.dispatchEvent(event); + }; + + it('sets showDetail to true on mousedown', () => { + triggerEvent('mousedown'); + + expect(vm.showDetail).toBe(true); + }); + + it('sets showDetail to false on mousemove', () => { + triggerEvent('mousedown'); + + expect(vm.showDetail).toBe(true); + + triggerEvent('mousemove'); + + expect(vm.showDetail).toBe(false); + }); + + it('does not set detail issue if showDetail is false', () => { + expect(gl.issueBoards.BoardsStore.detail.issue).toEqual({}); + }); + + it('does not set detail issue if link is clicked', () => { + triggerEvent('mouseup', vm.$el.querySelector('a')); + + expect(gl.issueBoards.BoardsStore.detail.issue).toEqual({}); + }); + + it('does not set detail issue if button is clicked', () => { + triggerEvent('mouseup', vm.$el.querySelector('button')); + + expect(gl.issueBoards.BoardsStore.detail.issue).toEqual({}); + }); + + it('does not set detail issue if showDetail is false after mouseup', () => { + triggerEvent('mouseup'); + + expect(gl.issueBoards.BoardsStore.detail.issue).toEqual({}); + }); + + it('sets detail issue to card issue on mouse up', () => { + triggerEvent('mousedown'); + triggerEvent('mouseup'); + + expect(gl.issueBoards.BoardsStore.detail.issue).toEqual(vm.issue); + expect(gl.issueBoards.BoardsStore.detail.list).toEqual(vm.list); + }); + + it('adds active class if detail issue is set', (done) => { + triggerEvent('mousedown'); + triggerEvent('mouseup'); + + setTimeout(() => { + expect(vm.$el.classList.contains('is-active')).toBe(true); + done(); + }, 0); + }); + + it('resets detail issue to empty if already set', () => { + triggerEvent('mousedown'); + triggerEvent('mouseup'); + + expect(gl.issueBoards.BoardsStore.detail.issue).toEqual(vm.issue); + + triggerEvent('mousedown'); + triggerEvent('mouseup'); + + expect(gl.issueBoards.BoardsStore.detail.issue).toEqual({}); + }); + }); +}); diff --git a/spec/javascripts/boards/board_new_issue_spec.js b/spec/javascripts/boards/board_new_issue_spec.js new file mode 100644 index 00000000000..4999933c0c1 --- /dev/null +++ b/spec/javascripts/boards/board_new_issue_spec.js @@ -0,0 +1,190 @@ +/* global boardsMockInterceptor */ +/* global BoardService */ +/* global List */ +/* global listObj */ + +import Vue from 'vue'; +import boardNewIssue from '~/boards/components/board_new_issue'; + +require('~/boards/models/list'); +require('./mock_data'); + +describe('Issue boards new issue form', () => { + let vm; + let list; + const promiseReturn = { + json() { + return { + iid: 100, + }; + }, + }; + const submitIssue = () => { + vm.$el.querySelector('.btn-success').click(); + }; + + beforeEach((done) => { + const BoardNewIssueComp = Vue.extend(boardNewIssue); + + Vue.http.interceptors.push(boardsMockInterceptor); + gl.boardService = new BoardService('/test/issue-boards/board', '', '1'); + gl.issueBoards.BoardsStore.create(); + gl.IssueBoardsApp = new Vue(); + + setTimeout(() => { + list = new List(listObj); + + spyOn(gl.boardService, 'newIssue').and.callFake(() => new Promise((resolve, reject) => { + if (vm.title === 'error') { + reject(); + } else { + resolve(promiseReturn); + } + })); + + vm = new BoardNewIssueComp({ + propsData: { + list, + }, + }).$mount(); + + done(); + }, 0); + }); + + afterEach(() => { + Vue.http.interceptors = _.without(Vue.http.interceptors, boardsMockInterceptor); + }); + + it('disables submit button if title is empty', () => { + expect(vm.$el.querySelector('.btn-success').disabled).toBe(true); + }); + + it('enables submit button if title is not empty', (done) => { + vm.title = 'Testing Title'; + + setTimeout(() => { + expect(vm.$el.querySelector('.form-control').value).toBe('Testing Title'); + expect(vm.$el.querySelector('.btn-success').disabled).not.toBe(true); + + done(); + }, 0); + }); + + it('clears title after clicking cancel', (done) => { + vm.$el.querySelector('.btn-default').click(); + + setTimeout(() => { + expect(vm.title).toBe(''); + done(); + }, 0); + }); + + it('does not create new issue if title is empty', (done) => { + submitIssue(); + + setTimeout(() => { + expect(gl.boardService.newIssue).not.toHaveBeenCalled(); + done(); + }, 0); + }); + + describe('submit success', () => { + it('creates new issue', (done) => { + vm.title = 'submit title'; + + setTimeout(() => { + submitIssue(); + + expect(gl.boardService.newIssue).toHaveBeenCalled(); + done(); + }, 0); + }); + + it('enables button after submit', (done) => { + vm.title = 'submit issue'; + + setTimeout(() => { + submitIssue(); + + expect(vm.$el.querySelector('.btn-success').disbled).not.toBe(true); + done(); + }, 0); + }); + + it('clears title after submit', (done) => { + vm.title = 'submit issue'; + + setTimeout(() => { + submitIssue(); + + expect(vm.title).toBe(''); + done(); + }, 0); + }); + + it('adds new issue to list after submit', (done) => { + vm.title = 'submit issue'; + + setTimeout(() => { + submitIssue(); + + expect(list.issues.length).toBe(2); + expect(list.issues[1].title).toBe('submit issue'); + expect(list.issues[1].subscribed).toBe(true); + done(); + }, 0); + }); + + it('sets detail issue after submit', (done) => { + vm.title = 'submit issue'; + + setTimeout(() => { + submitIssue(); + + expect(gl.issueBoards.BoardsStore.detail.issue.title).toBe('submit issue'); + done(); + }); + }); + + it('sets detail list after submit', (done) => { + vm.title = 'submit issue'; + + setTimeout(() => { + submitIssue(); + + expect(gl.issueBoards.BoardsStore.detail.list.id).toBe(list.id); + done(); + }, 0); + }); + }); + + describe('submit error', () => { + it('removes issue', (done) => { + vm.title = 'error'; + + setTimeout(() => { + submitIssue(); + + setTimeout(() => { + expect(list.issues.length).toBe(1); + done(); + }, 500); + }, 0); + }); + + it('shows error', (done) => { + vm.title = 'error'; + submitIssue(); + + setTimeout(() => { + submitIssue(); + + setTimeout(() => { + expect(vm.error).toBe(true); + done(); + }, 500); + }, 0); + }); + }); +}); diff --git a/spec/javascripts/boards/boards_store_spec.js.es6 b/spec/javascripts/boards/boards_store_spec.js index 9dd741a680b..1d1069600fc 100644 --- a/spec/javascripts/boards/boards_store_spec.js.es6 +++ b/spec/javascripts/boards/boards_store_spec.js @@ -5,6 +5,7 @@ /* global Cookies */ /* global listObj */ /* global listObjDuplicate */ +/* global ListIssue */ require('~/lib/utils/url_utility'); require('~/boards/models/issue'); @@ -21,6 +22,10 @@ describe('Store', () => { gl.boardService = new BoardService('/test/issue-boards/board', '', '1'); gl.issueBoards.BoardsStore.create(); + spyOn(gl.boardService, 'moveIssue').and.callFake(() => new Promise((resolve) => { + resolve(); + })); + Cookies.set('issue_board_welcome_hidden', 'false', { expires: 365 * 10, path: '' @@ -154,5 +159,74 @@ describe('Store', () => { done(); }, 0); }); + + it('moves issue to top of another list', (done) => { + const listOne = gl.issueBoards.BoardsStore.addList(listObj); + const listTwo = gl.issueBoards.BoardsStore.addList(listObjDuplicate); + + expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(2); + + setTimeout(() => { + listOne.issues[0].id = 2; + + expect(listOne.issues.length).toBe(1); + expect(listTwo.issues.length).toBe(1); + + gl.issueBoards.BoardsStore.moveIssueToList(listOne, listTwo, listOne.findIssue(2), 0); + + expect(listOne.issues.length).toBe(0); + expect(listTwo.issues.length).toBe(2); + expect(listTwo.issues[0].id).toBe(2); + expect(gl.boardService.moveIssue).toHaveBeenCalledWith(2, listOne.id, listTwo.id, null, 1); + + done(); + }, 0); + }); + + it('moves issue to bottom of another list', (done) => { + const listOne = gl.issueBoards.BoardsStore.addList(listObj); + const listTwo = gl.issueBoards.BoardsStore.addList(listObjDuplicate); + + expect(gl.issueBoards.BoardsStore.state.lists.length).toBe(2); + + setTimeout(() => { + listOne.issues[0].id = 2; + + expect(listOne.issues.length).toBe(1); + expect(listTwo.issues.length).toBe(1); + + gl.issueBoards.BoardsStore.moveIssueToList(listOne, listTwo, listOne.findIssue(2), 1); + + expect(listOne.issues.length).toBe(0); + expect(listTwo.issues.length).toBe(2); + expect(listTwo.issues[1].id).toBe(2); + expect(gl.boardService.moveIssue).toHaveBeenCalledWith(2, listOne.id, listTwo.id, 1, null); + + done(); + }, 0); + }); + + it('moves issue in list', (done) => { + const issue = new ListIssue({ + title: 'Testing', + iid: 2, + confidential: false, + labels: [] + }); + const list = gl.issueBoards.BoardsStore.addList(listObj); + + setTimeout(() => { + list.addIssue(issue); + + expect(list.issues.length).toBe(2); + + gl.issueBoards.BoardsStore.moveIssueInList(list, issue, 0, 1, [1, 2]); + + expect(list.issues[0].id).toBe(2); + expect(gl.boardService.moveIssue).toHaveBeenCalledWith(2, null, null, 1, null); + + done(); + }); + }); }); }); diff --git a/spec/javascripts/boards/issue_card_spec.js.es6 b/spec/javascripts/boards/issue_card_spec.js index 4340a571017..4340a571017 100644 --- a/spec/javascripts/boards/issue_card_spec.js.es6 +++ b/spec/javascripts/boards/issue_card_spec.js diff --git a/spec/javascripts/boards/issue_spec.js.es6 b/spec/javascripts/boards/issue_spec.js index aab4d9c501e..c96dfe94a4a 100644 --- a/spec/javascripts/boards/issue_spec.js.es6 +++ b/spec/javascripts/boards/issue_spec.js @@ -79,4 +79,20 @@ describe('Issue model', () => { issue.removeLabels([issue.labels[0], issue.labels[1]]); expect(issue.labels.length).toBe(0); }); + + it('sets position to infinity if no position is stored', () => { + expect(issue.position).toBe(Infinity); + }); + + it('sets position', () => { + const relativePositionIssue = new ListIssue({ + title: 'Testing', + iid: 1, + confidential: false, + relative_position: 1, + labels: [] + }); + + expect(relativePositionIssue.position).toBe(1); + }); }); diff --git a/spec/javascripts/boards/list_spec.js.es6 b/spec/javascripts/boards/list_spec.js index 4397a32fedc..d49d3af33d9 100644 --- a/spec/javascripts/boards/list_spec.js.es6 +++ b/spec/javascripts/boards/list_spec.js @@ -3,7 +3,9 @@ /* global boardsMockInterceptor */ /* global BoardService */ /* global List */ +/* global ListIssue */ /* global listObj */ +/* global listObjDuplicate */ require('~/lib/utils/url_utility'); require('~/boards/models/issue'); @@ -84,4 +86,24 @@ describe('List model', () => { done(); }, 0); }); + + it('sends service request to update issue label', () => { + const listDup = new List(listObjDuplicate); + const issue = new ListIssue({ + title: 'Testing', + iid: 1, + confidential: false, + labels: [list.label, listDup.label] + }); + + list.issues.push(issue); + listDup.issues.push(issue); + + spyOn(gl.boardService, 'moveIssue').and.callThrough(); + + listDup.updateIssueLabel(list, issue); + + expect(gl.boardService.moveIssue) + .toHaveBeenCalledWith(issue.id, list.id, listDup.id, undefined, undefined); + }); }); diff --git a/spec/javascripts/boards/mock_data.js.es6 b/spec/javascripts/boards/mock_data.js index 7a399b307ad..7a399b307ad 100644 --- a/spec/javascripts/boards/mock_data.js.es6 +++ b/spec/javascripts/boards/mock_data.js diff --git a/spec/javascripts/boards/modal_store_spec.js.es6 b/spec/javascripts/boards/modal_store_spec.js index 1815847f3fa..1815847f3fa 100644 --- a/spec/javascripts/boards/modal_store_spec.js.es6 +++ b/spec/javascripts/boards/modal_store_spec.js diff --git a/spec/javascripts/extensions/jquery_spec.js b/spec/javascripts/bootstrap_jquery_spec.js index 096d3272eac..48994b7c523 100644 --- a/spec/javascripts/extensions/jquery_spec.js +++ b/spec/javascripts/bootstrap_jquery_spec.js @@ -1,9 +1,9 @@ /* eslint-disable space-before-function-paren, no-var */ -require('~/extensions/jquery'); +import '~/commons/bootstrap'; (function() { - describe('jQuery extensions', function() { + describe('Bootstrap jQuery extensions', function() { describe('disable', function() { beforeEach(function() { return setFixtures('<input type="text" />'); diff --git a/spec/javascripts/bootstrap_linked_tabs_spec.js.es6 b/spec/javascripts/bootstrap_linked_tabs_spec.js index fa9f95e16cd..fa9f95e16cd 100644 --- a/spec/javascripts/bootstrap_linked_tabs_spec.js.es6 +++ b/spec/javascripts/bootstrap_linked_tabs_spec.js diff --git a/spec/javascripts/build_spec.js.es6 b/spec/javascripts/build_spec.js index 0bd50588f5a..549c7af8ea8 100644 --- a/spec/javascripts/build_spec.js.es6 +++ b/spec/javascripts/build_spec.js @@ -9,12 +9,6 @@ require('vendor/jquery.nicescroll'); describe('Build', () => { const BUILD_URL = `${gl.TEST_HOST}/frontend-fixtures/builds-project/builds/1`; - // see spec/factories/ci/builds.rb - const BUILD_TRACE = 'BUILD TRACE'; - // see lib/ci/ansi2html.rb - const INITIAL_BUILD_TRACE_STATE = window.btoa(JSON.stringify({ - offset: BUILD_TRACE.length, n_open_tags: 0, fg_color: null, bg_color: null, style_mask: 0, - })); preloadFixtures('builds/build-with-artifacts.html.raw'); @@ -23,7 +17,7 @@ describe('Build', () => { spyOn($, 'ajax'); }); - describe('constructor', () => { + describe('class constructor', () => { beforeEach(() => { jasmine.clock().install(); }); @@ -42,7 +36,7 @@ describe('Build', () => { expect(this.build.buildUrl).toBe(`${BUILD_URL}.json`); expect(this.build.buildStatus).toBe('success'); expect(this.build.buildStage).toBe('test'); - expect(this.build.state).toBe(INITIAL_BUILD_TRACE_STATE); + expect(this.build.state).toBe(''); }); it('only shows the jobs matching the current stage', () => { @@ -108,7 +102,7 @@ describe('Build', () => { expect($.ajax.calls.count()).toBe(2); let [{ url, dataType, success, context }] = $.ajax.calls.argsFor(1); expect(url).toBe( - `${BUILD_URL}/trace.json?state=${encodeURIComponent(INITIAL_BUILD_TRACE_STATE)}`, + `${BUILD_URL}/trace.json?state=`, ); expect(dataType).toBe('json'); expect(success).toEqual(jasmine.any(Function)); diff --git a/spec/javascripts/commit/pipelines/mock_data.js.es6 b/spec/javascripts/commit/pipelines/mock_data.js index 188908d66bd..82b00b4c1ec 100644 --- a/spec/javascripts/commit/pipelines/mock_data.js.es6 +++ b/spec/javascripts/commit/pipelines/mock_data.js @@ -1,5 +1,4 @@ -/* eslint-disable no-unused-vars */ -const pipeline = { +export default { id: 73, user: { name: 'Administrator', @@ -88,5 +87,3 @@ const pipeline = { created_at: '2017-01-16T17:13:59.800Z', updated_at: '2017-01-25T00:00:17.132Z', }; - -module.exports = pipeline; diff --git a/spec/javascripts/commit/pipelines/pipelines_spec.js.es6 b/spec/javascripts/commit/pipelines/pipelines_spec.js index f09c57978a1..75efcc06585 100644 --- a/spec/javascripts/commit/pipelines/pipelines_spec.js.es6 +++ b/spec/javascripts/commit/pipelines/pipelines_spec.js @@ -1,11 +1,6 @@ -/* global pipeline, Vue */ - -require('~/flash'); -require('~/commit/pipelines/pipelines_store'); -require('~/commit/pipelines/pipelines_service'); -require('~/commit/pipelines/pipelines_table'); -require('~/vue_shared/vue_resource_interceptor'); -const pipeline = require('./mock_data'); +import Vue from 'vue'; +import PipelinesTable from '~/commit/pipelines/pipelines_table'; +import pipeline from './mock_data'; describe('Pipelines table in Commits and Merge requests', () => { preloadFixtures('static/pipelines_table.html.raw'); @@ -33,7 +28,7 @@ describe('Pipelines table in Commits and Merge requests', () => { }); it('should render the empty state', (done) => { - const component = new gl.commits.pipelines.PipelinesTableView({ + const component = new PipelinesTable({ el: document.querySelector('#commit-pipeline-table-view'), }); @@ -62,7 +57,7 @@ describe('Pipelines table in Commits and Merge requests', () => { }); it('should render a table with the received pipelines', (done) => { - const component = new gl.commits.pipelines.PipelinesTableView({ + const component = new PipelinesTable({ el: document.querySelector('#commit-pipeline-table-view'), }); @@ -92,7 +87,7 @@ describe('Pipelines table in Commits and Merge requests', () => { }); it('should render empty state', (done) => { - const component = new gl.commits.pipelines.PipelinesTableView({ + const component = new PipelinesTable({ el: document.querySelector('#commit-pipeline-table-view'), }); diff --git a/spec/javascripts/commit/pipelines/pipelines_store_spec.js.es6 b/spec/javascripts/commit/pipelines/pipelines_store_spec.js.es6 deleted file mode 100644 index 94973419979..00000000000 --- a/spec/javascripts/commit/pipelines/pipelines_store_spec.js.es6 +++ /dev/null @@ -1,33 +0,0 @@ -const PipelinesStore = require('~/commit/pipelines/pipelines_store'); - -describe('Store', () => { - let store; - - beforeEach(() => { - store = new PipelinesStore(); - }); - - // unregister intervals and event handlers - afterEach(() => gl.VueRealtimeListener.reset()); - - it('should start with a blank state', () => { - expect(store.state.pipelines.length).toBe(0); - }); - - it('should store an array of pipelines', () => { - const pipelines = [ - { - id: '1', - name: 'pipeline', - }, - { - id: '2', - name: 'pipeline_2', - }, - ]; - - store.storePipelines(pipelines); - - expect(store.state.pipelines.length).toBe(pipelines.length); - }); -}); diff --git a/spec/javascripts/commits_spec.js.es6 b/spec/javascripts/commits_spec.js index 05260760c43..05260760c43 100644 --- a/spec/javascripts/commits_spec.js.es6 +++ b/spec/javascripts/commits_spec.js diff --git a/spec/javascripts/datetime_utility_spec.js.es6 b/spec/javascripts/datetime_utility_spec.js index d5eec10be42..d5eec10be42 100644 --- a/spec/javascripts/datetime_utility_spec.js.es6 +++ b/spec/javascripts/datetime_utility_spec.js diff --git a/spec/javascripts/diff_comments_store_spec.js.es6 b/spec/javascripts/diff_comments_store_spec.js index f956394ef53..84cf98c930a 100644 --- a/spec/javascripts/diff_comments_store_spec.js.es6 +++ b/spec/javascripts/diff_comments_store_spec.js @@ -7,7 +7,16 @@ require('~/diff_notes/stores/comments'); (() => { function createDiscussion(noteId = 1, resolved = true) { - CommentsStore.create('a', noteId, true, resolved, 'test'); + CommentsStore.create({ + discussionId: 'a', + noteId, + canResolve: true, + resolved, + resolvedBy: 'test', + authorName: 'test', + authorAvatar: 'test', + noteTruncated: 'test...', + }); } beforeEach(() => { diff --git a/spec/javascripts/environments/environment_actions_spec.js b/spec/javascripts/environments/environment_actions_spec.js new file mode 100644 index 00000000000..85b73f1d4e2 --- /dev/null +++ b/spec/javascripts/environments/environment_actions_spec.js @@ -0,0 +1,47 @@ +import Vue from 'vue'; +import actionsComp from '~/environments/components/environment_actions'; + +describe('Actions Component', () => { + let ActionsComponent; + let actionsMock; + let spy; + let component; + + beforeEach(() => { + ActionsComponent = Vue.extend(actionsComp); + + actionsMock = [ + { + name: 'bar', + play_path: 'https://gitlab.com/play', + }, + { + name: 'foo', + play_path: '#', + }, + ]; + + spy = jasmine.createSpy('spy').and.returnValue(Promise.resolve()); + component = new ActionsComponent({ + propsData: { + actions: actionsMock, + service: { + postAction: spy, + }, + }, + }).$mount(); + }); + + it('should render a dropdown with the provided actions', () => { + expect( + component.$el.querySelectorAll('.dropdown-menu li').length, + ).toEqual(actionsMock.length); + }); + + it('should call the service when an action is clicked', () => { + component.$el.querySelector('.dropdown').click(); + component.$el.querySelector('.js-manual-action-link').click(); + + expect(spy).toHaveBeenCalledWith(actionsMock[0].play_path); + }); +}); diff --git a/spec/javascripts/environments/environment_actions_spec.js.es6 b/spec/javascripts/environments/environment_actions_spec.js.es6 deleted file mode 100644 index 850586f9f3a..00000000000 --- a/spec/javascripts/environments/environment_actions_spec.js.es6 +++ /dev/null @@ -1,66 +0,0 @@ -const ActionsComponent = require('~/environments/components/environment_actions'); - -describe('Actions Component', () => { - preloadFixtures('static/environments/element.html.raw'); - - beforeEach(() => { - loadFixtures('static/environments/element.html.raw'); - }); - - it('should render a dropdown with the provided actions', () => { - const actionsMock = [ - { - name: 'bar', - play_path: 'https://gitlab.com/play', - }, - { - name: 'foo', - play_path: '#', - }, - ]; - - const component = new ActionsComponent({ - el: document.querySelector('.test-dom-element'), - propsData: { - actions: actionsMock, - playIconSvg: '<svg></svg>', - }, - }); - - expect( - component.$el.querySelectorAll('.dropdown-menu li').length, - ).toEqual(actionsMock.length); - expect( - component.$el.querySelector('.dropdown-menu li a').getAttribute('href'), - ).toEqual(actionsMock[0].play_path); - }); - - it('should render a dropdown with the provided svg', () => { - const actionsMock = [ - { - name: 'bar', - play_path: 'https://gitlab.com/play', - }, - { - name: 'foo', - play_path: '#', - }, - ]; - - const component = new ActionsComponent({ - el: document.querySelector('.test-dom-element'), - propsData: { - actions: actionsMock, - playIconSvg: '<svg></svg>', - }, - }); - - expect( - component.$el.querySelector('.js-dropdown-play-icon-container').children, - ).toContain('svg'); - - expect( - component.$el.querySelector('.js-action-play-icon-container').children, - ).toContain('svg'); - }); -}); diff --git a/spec/javascripts/environments/environment_external_url_spec.js.es6 b/spec/javascripts/environments/environment_external_url_spec.js index 393dbb5aae0..9af218a27ff 100644 --- a/spec/javascripts/environments/environment_external_url_spec.js.es6 +++ b/spec/javascripts/environments/environment_external_url_spec.js @@ -1,19 +1,20 @@ -const ExternalUrlComponent = require('~/environments/components/environment_external_url'); +import Vue from 'vue'; +import externalUrlComp from '~/environments/components/environment_external_url'; describe('External URL Component', () => { - preloadFixtures('static/environments/element.html.raw'); + let ExternalUrlComponent; + beforeEach(() => { - loadFixtures('static/environments/element.html.raw'); + ExternalUrlComponent = Vue.extend(externalUrlComp); }); it('should link to the provided externalUrl prop', () => { const externalURL = 'https://gitlab.com'; const component = new ExternalUrlComponent({ - el: document.querySelector('.test-dom-element'), propsData: { externalUrl: externalURL, }, - }); + }).$mount(); expect(component.$el.getAttribute('href')).toEqual(externalURL); expect(component.$el.querySelector('fa-external-link')).toBeDefined(); diff --git a/spec/javascripts/environments/environment_item_spec.js.es6 b/spec/javascripts/environments/environment_item_spec.js index 7fea80ed799..4d42de4d549 100644 --- a/spec/javascripts/environments/environment_item_spec.js.es6 +++ b/spec/javascripts/environments/environment_item_spec.js @@ -1,10 +1,12 @@ -window.timeago = require('timeago.js'); -const EnvironmentItem = require('~/environments/components/environment_item'); +import 'timeago.js'; +import Vue from 'vue'; +import environmentItemComp from '~/environments/components/environment_item'; describe('Environment item', () => { - preloadFixtures('static/environments/table.html.raw'); + let EnvironmentItem; + beforeEach(() => { - loadFixtures('static/environments/table.html.raw'); + EnvironmentItem = Vue.extend(environmentItemComp); }); describe('When item is folder', () => { @@ -21,13 +23,13 @@ describe('Environment item', () => { }; component = new EnvironmentItem({ - el: document.querySelector('tr#environment-row'), propsData: { model: mockItem, canCreateDeployment: false, canReadEnvironment: true, + service: {}, }, - }); + }).$mount(); }); it('Should render folder icon and name', () => { @@ -109,13 +111,13 @@ describe('Environment item', () => { }; component = new EnvironmentItem({ - el: document.querySelector('tr#environment-row'), propsData: { model: environment, canCreateDeployment: true, canReadEnvironment: true, + service: {}, }, - }); + }).$mount(); }); it('should render environment name', () => { diff --git a/spec/javascripts/environments/environment_rollback_spec.js.es6 b/spec/javascripts/environments/environment_rollback_spec.js index 4a596baad09..7cb39d9df03 100644 --- a/spec/javascripts/environments/environment_rollback_spec.js.es6 +++ b/spec/javascripts/environments/environment_rollback_spec.js @@ -1,47 +1,59 @@ -const RollbackComponent = require('~/environments/components/environment_rollback'); +import Vue from 'vue'; +import rollbackComp from '~/environments/components/environment_rollback'; describe('Rollback Component', () => { - preloadFixtures('static/environments/element.html.raw'); - const retryURL = 'https://gitlab.com/retry'; + let RollbackComponent; + let spy; beforeEach(() => { - loadFixtures('static/environments/element.html.raw'); + RollbackComponent = Vue.extend(rollbackComp); + spy = jasmine.createSpy('spy').and.returnValue(Promise.resolve()); }); - it('Should link to the provided retryUrl', () => { + it('Should render Re-deploy label when isLastDeployment is true', () => { const component = new RollbackComponent({ el: document.querySelector('.test-dom-element'), propsData: { retryUrl: retryURL, isLastDeployment: true, + service: { + postAction: spy, + }, }, - }); + }).$mount(); - expect(component.$el.getAttribute('href')).toEqual(retryURL); + expect(component.$el.querySelector('span').textContent).toContain('Re-deploy'); }); - it('Should render Re-deploy label when isLastDeployment is true', () => { + it('Should render Rollback label when isLastDeployment is false', () => { const component = new RollbackComponent({ el: document.querySelector('.test-dom-element'), propsData: { retryUrl: retryURL, - isLastDeployment: true, + isLastDeployment: false, + service: { + postAction: spy, + }, }, - }); + }).$mount(); - expect(component.$el.querySelector('span').textContent).toContain('Re-deploy'); + expect(component.$el.querySelector('span').textContent).toContain('Rollback'); }); - it('Should render Rollback label when isLastDeployment is false', () => { + it('should call the service when the button is clicked', () => { const component = new RollbackComponent({ - el: document.querySelector('.test-dom-element'), propsData: { retryUrl: retryURL, isLastDeployment: false, + service: { + postAction: spy, + }, }, - }); + }).$mount(); - expect(component.$el.querySelector('span').textContent).toContain('Rollback'); + component.$el.click(); + + expect(spy).toHaveBeenCalledWith(retryURL); }); }); diff --git a/spec/javascripts/environments/environment_spec.js.es6 b/spec/javascripts/environments/environment_spec.js index edd0cad32d0..9601575577e 100644 --- a/spec/javascripts/environments/environment_spec.js.es6 +++ b/spec/javascripts/environments/environment_spec.js @@ -1,7 +1,7 @@ -const Vue = require('vue'); -require('~/flash'); -const EnvironmentsComponent = require('~/environments/components/environment'); -const { environment } = require('./mock_data'); +import Vue from 'vue'; +import '~/flash'; +import EnvironmentsComponent from '~/environments/components/environment'; +import { environment } from './mock_data'; describe('Environment', () => { preloadFixtures('static/environments/environments.html.raw'); diff --git a/spec/javascripts/environments/environment_stop_spec.js b/spec/javascripts/environments/environment_stop_spec.js new file mode 100644 index 00000000000..8f79b88f3df --- /dev/null +++ b/spec/javascripts/environments/environment_stop_spec.js @@ -0,0 +1,34 @@ +import Vue from 'vue'; +import stopComp from '~/environments/components/environment_stop'; + +describe('Stop Component', () => { + let StopComponent; + let component; + let spy; + const stopURL = '/stop'; + + beforeEach(() => { + StopComponent = Vue.extend(stopComp); + spy = jasmine.createSpy('spy').and.returnValue(Promise.resolve()); + spyOn(window, 'confirm').and.returnValue(true); + + component = new StopComponent({ + propsData: { + stopUrl: stopURL, + service: { + postAction: spy, + }, + }, + }).$mount(); + }); + + it('should render a button to stop the environment', () => { + expect(component.$el.tagName).toEqual('BUTTON'); + expect(component.$el.getAttribute('title')).toEqual('Stop Environment'); + }); + + it('should call the service when an action is clicked', () => { + component.$el.click(); + expect(spy).toHaveBeenCalled(); + }); +}); diff --git a/spec/javascripts/environments/environment_stop_spec.js.es6 b/spec/javascripts/environments/environment_stop_spec.js.es6 deleted file mode 100644 index 5ca65b1debc..00000000000 --- a/spec/javascripts/environments/environment_stop_spec.js.es6 +++ /dev/null @@ -1,28 +0,0 @@ -const StopComponent = require('~/environments/components/environment_stop'); - -describe('Stop Component', () => { - preloadFixtures('static/environments/element.html.raw'); - - let stopURL; - let component; - - beforeEach(() => { - loadFixtures('static/environments/element.html.raw'); - - stopURL = '/stop'; - component = new StopComponent({ - el: document.querySelector('.test-dom-element'), - propsData: { - stopUrl: stopURL, - }, - }); - }); - - it('should link to the provided URL', () => { - expect(component.$el.getAttribute('href')).toEqual(stopURL); - }); - - it('should have a data-confirm attribute', () => { - expect(component.$el.getAttribute('data-confirm')).toEqual('Are you sure you want to stop this environment?'); - }); -}); diff --git a/spec/javascripts/environments/environment_table_spec.js.es6 b/spec/javascripts/environments/environment_table_spec.js index be4330b5012..3df967848a7 100644 --- a/spec/javascripts/environments/environment_table_spec.js.es6 +++ b/spec/javascripts/environments/environment_table_spec.js @@ -1,4 +1,5 @@ -const EnvironmentTable = require('~/environments/components/environments_table'); +import Vue from 'vue'; +import environmentTableComp from '~/environments/components/environments_table'; describe('Environment item', () => { preloadFixtures('static/environments/element.html.raw'); @@ -16,14 +17,17 @@ describe('Environment item', () => { }, }; + const EnvironmentTable = Vue.extend(environmentTableComp); + const component = new EnvironmentTable({ el: document.querySelector('.test-dom-element'), propsData: { environments: [{ mockItem }], canCreateDeployment: false, canReadEnvironment: true, + service: {}, }, - }); + }).$mount(); expect(component.$el.tagName).toEqual('TABLE'); }); diff --git a/spec/javascripts/environments/environment_terminal_button_spec.js b/spec/javascripts/environments/environment_terminal_button_spec.js new file mode 100644 index 00000000000..b07aa4e1745 --- /dev/null +++ b/spec/javascripts/environments/environment_terminal_button_spec.js @@ -0,0 +1,24 @@ +import Vue from 'vue'; +import terminalComp from '~/environments/components/environment_terminal_button'; + +describe('Stop Component', () => { + let TerminalComponent; + let component; + const terminalPath = '/path'; + + beforeEach(() => { + TerminalComponent = Vue.extend(terminalComp); + + component = new TerminalComponent({ + propsData: { + terminalPath, + }, + }).$mount(); + }); + + it('should render a link to open a web terminal with the provided path', () => { + expect(component.$el.tagName).toEqual('A'); + expect(component.$el.getAttribute('title')).toEqual('Open web terminal'); + expect(component.$el.getAttribute('href')).toEqual(terminalPath); + }); +}); diff --git a/spec/javascripts/environments/environments_store_spec.js.es6 b/spec/javascripts/environments/environments_store_spec.js index 77e182b3830..115d84b50f5 100644 --- a/spec/javascripts/environments/environments_store_spec.js.es6 +++ b/spec/javascripts/environments/environments_store_spec.js @@ -1,5 +1,5 @@ -const Store = require('~/environments/stores/environments_store'); -const { environmentsList, serverData } = require('./mock_data'); +import Store from '~/environments/stores/environments_store'; +import { environmentsList, serverData } from './mock_data'; (() => { describe('Store', () => { diff --git a/spec/javascripts/environments/folder/environments_folder_view_spec.js.es6 b/spec/javascripts/environments/folder/environments_folder_view_spec.js index d1335b5b304..43a217a67f5 100644 --- a/spec/javascripts/environments/folder/environments_folder_view_spec.js.es6 +++ b/spec/javascripts/environments/folder/environments_folder_view_spec.js @@ -1,7 +1,7 @@ -const Vue = require('vue'); -require('~/flash'); -const EnvironmentsFolderViewComponent = require('~/environments/folder/environments_folder_view'); -const { environmentsList } = require('../mock_data'); +import Vue from 'vue'; +import '~/flash'; +import EnvironmentsFolderViewComponent from '~/environments/folder/environments_folder_view'; +import { environmentsList } from '../mock_data'; describe('Environments Folder View', () => { preloadFixtures('static/environments/environments_folder_view.html.raw'); diff --git a/spec/javascripts/environments/mock_data.js.es6 b/spec/javascripts/environments/mock_data.js index 5c395c6b2d8..30861481cc5 100644 --- a/spec/javascripts/environments/mock_data.js.es6 +++ b/spec/javascripts/environments/mock_data.js @@ -1,4 +1,4 @@ -const environmentsList = [ +export const environmentsList = [ { name: 'DEV', size: 1, @@ -30,7 +30,7 @@ const environmentsList = [ }, ]; -const serverData = [ +export const serverData = [ { name: 'DEV', size: 1, @@ -67,7 +67,7 @@ const serverData = [ }, ]; -const environment = { +export const environment = { name: 'DEV', size: 1, latest: { @@ -84,9 +84,3 @@ const environment = { updated_at: '2017-01-31T10:53:46.894Z', }, }; - -module.exports = { - environmentsList, - environment, - serverData, -}; diff --git a/spec/javascripts/extensions/array_spec.js b/spec/javascripts/extensions/array_spec.js new file mode 100644 index 00000000000..4b871fe967d --- /dev/null +++ b/spec/javascripts/extensions/array_spec.js @@ -0,0 +1,22 @@ +/* eslint-disable space-before-function-paren, no-var */ + +require('~/extensions/array'); + +(function() { + describe('Array extensions', function() { + describe('first', function() { + return it('returns the first item', function() { + var arr; + arr = [0, 1, 2, 3, 4, 5]; + return expect(arr.first()).toBe(0); + }); + }); + describe('last', function() { + return it('returns the last item', function() { + var arr; + arr = [0, 1, 2, 3, 4, 5]; + return expect(arr.last()).toBe(5); + }); + }); + }); +}).call(window); diff --git a/spec/javascripts/extensions/array_spec.js.es6 b/spec/javascripts/extensions/array_spec.js.es6 deleted file mode 100644 index 60f6b9b78e3..00000000000 --- a/spec/javascripts/extensions/array_spec.js.es6 +++ /dev/null @@ -1,45 +0,0 @@ -/* eslint-disable space-before-function-paren, no-var */ - -require('~/extensions/array'); - -(function() { - describe('Array extensions', function() { - describe('first', function() { - return it('returns the first item', function() { - var arr; - arr = [0, 1, 2, 3, 4, 5]; - return expect(arr.first()).toBe(0); - }); - }); - describe('last', function() { - return it('returns the last item', function() { - var arr; - arr = [0, 1, 2, 3, 4, 5]; - return expect(arr.last()).toBe(5); - }); - }); - - describe('find', function () { - beforeEach(() => { - this.arr = [0, 1, 2, 3, 4, 5]; - }); - - it('returns the item that first passes the predicate function', () => { - expect(this.arr.find(item => item === 2)).toBe(2); - }); - - it('returns undefined if no items pass the predicate function', () => { - expect(this.arr.find(item => item === 6)).not.toBeDefined(); - }); - - it('error when called on undefined or null', () => { - expect(Array.prototype.find.bind(undefined, item => item === 1)).toThrow(); - expect(Array.prototype.find.bind(null, item => item === 1)).toThrow(); - }); - - it('error when predicate is not a function', () => { - expect(Array.prototype.find.bind(this.arr, 1)).toThrow(); - }); - }); - }); -}).call(window); diff --git a/spec/javascripts/extensions/element_spec.js.es6 b/spec/javascripts/extensions/element_spec.js.es6 deleted file mode 100644 index 2d8a128ed33..00000000000 --- a/spec/javascripts/extensions/element_spec.js.es6 +++ /dev/null @@ -1,38 +0,0 @@ -require('~/extensions/element'); - -(() => { - describe('Element extensions', function () { - beforeEach(() => { - this.element = document.createElement('ul'); - }); - - describe('matches', () => { - it('returns true if element matches the selector', () => { - expect(this.element.matches('ul')).toBeTruthy(); - }); - - it("returns false if element doesn't match the selector", () => { - expect(this.element.matches('.not-an-element')).toBeFalsy(); - }); - }); - - describe('closest', () => { - beforeEach(() => { - this.childElement = document.createElement('li'); - this.element.appendChild(this.childElement); - }); - - it('returns the closest parent that matches the selector', () => { - expect(this.childElement.closest('ul').toString()).toBe(this.element.toString()); - }); - - it('returns itself if it matches the selector', () => { - expect(this.childElement.closest('li').toString()).toBe(this.childElement.toString()); - }); - - it('returns undefined if nothing matches the selector', () => { - expect(this.childElement.closest('.no-an-element')).toBeFalsy(); - }); - }); - }); -})(); diff --git a/spec/javascripts/extensions/object_spec.js.es6 b/spec/javascripts/extensions/object_spec.js.es6 deleted file mode 100644 index 2467ed78459..00000000000 --- a/spec/javascripts/extensions/object_spec.js.es6 +++ /dev/null @@ -1,25 +0,0 @@ -require('~/extensions/object'); - -describe('Object extensions', () => { - describe('assign', () => { - it('merges source object into target object', () => { - const targetObj = {}; - const sourceObj = { - foo: 'bar', - }; - Object.assign(targetObj, sourceObj); - expect(targetObj.foo).toBe('bar'); - }); - - it('merges object with the same properties', () => { - const targetObj = { - foo: 'bar', - }; - const sourceObj = { - foo: 'baz', - }; - Object.assign(targetObj, sourceObj); - expect(targetObj.foo).toBe('baz'); - }); - }); -}); diff --git a/spec/javascripts/filtered_search/dropdown_user_spec.js.es6 b/spec/javascripts/filtered_search/dropdown_user_spec.js index fa9d03c8a9a..c16f77c53a2 100644 --- a/spec/javascripts/filtered_search/dropdown_user_spec.js.es6 +++ b/spec/javascripts/filtered_search/dropdown_user_spec.js @@ -18,9 +18,7 @@ require('~/filtered_search/dropdown_user'); it('should not return the double quote found in value', () => { spyOn(gl.FilteredSearchTokenizer, 'processTokens').and.returnValue({ - lastToken: { - value: '"johnny appleseed', - }, + lastToken: '"johnny appleseed', }); expect(dropdownUser.getSearchInput()).toBe('johnny appleseed'); @@ -28,9 +26,7 @@ require('~/filtered_search/dropdown_user'); it('should not return the single quote found in value', () => { spyOn(gl.FilteredSearchTokenizer, 'processTokens').and.returnValue({ - lastToken: { - value: '\'larry boy', - }, + lastToken: '\'larry boy', }); expect(dropdownUser.getSearchInput()).toBe('larry boy'); diff --git a/spec/javascripts/filtered_search/dropdown_utils_spec.js.es6 b/spec/javascripts/filtered_search/dropdown_utils_spec.js index 1e2d7582d5b..e6538020896 100644 --- a/spec/javascripts/filtered_search/dropdown_utils_spec.js.es6 +++ b/spec/javascripts/filtered_search/dropdown_utils_spec.js @@ -45,7 +45,7 @@ require('~/filtered_search/filtered_search_dropdown_manager'); }); it('should filter without symbol', () => { - input.value = ':roo'; + input.value = 'roo'; const updatedItem = gl.DropdownUtils.filterWithSymbol('@', input, item); expect(updatedItem.droplab_hidden).toBe(false); @@ -58,69 +58,62 @@ require('~/filtered_search/filtered_search_dropdown_manager'); expect(updatedItem.droplab_hidden).toBe(false); }); - it('should filter with colon', () => { - input.value = 'roo'; - - const updatedItem = gl.DropdownUtils.filterWithSymbol('@', input, item); - expect(updatedItem.droplab_hidden).toBe(false); - }); - describe('filters multiple word title', () => { const multipleWordItem = { title: 'Community Contributions', }; it('should filter with double quote', () => { - input.value = 'label:"'; + input.value = '"'; const updatedItem = gl.DropdownUtils.filterWithSymbol('~', input, multipleWordItem); expect(updatedItem.droplab_hidden).toBe(false); }); it('should filter with double quote and symbol', () => { - input.value = 'label:~"'; + input.value = '~"'; const updatedItem = gl.DropdownUtils.filterWithSymbol('~', input, multipleWordItem); expect(updatedItem.droplab_hidden).toBe(false); }); it('should filter with double quote and multiple words', () => { - input.value = 'label:"community con'; + input.value = '"community con'; const updatedItem = gl.DropdownUtils.filterWithSymbol('~', input, multipleWordItem); expect(updatedItem.droplab_hidden).toBe(false); }); it('should filter with double quote, symbol and multiple words', () => { - input.value = 'label:~"community con'; + input.value = '~"community con'; const updatedItem = gl.DropdownUtils.filterWithSymbol('~', input, multipleWordItem); expect(updatedItem.droplab_hidden).toBe(false); }); it('should filter with single quote', () => { - input.value = 'label:\''; + input.value = '\''; const updatedItem = gl.DropdownUtils.filterWithSymbol('~', input, multipleWordItem); expect(updatedItem.droplab_hidden).toBe(false); }); it('should filter with single quote and symbol', () => { - input.value = 'label:~\''; + input.value = '~\''; const updatedItem = gl.DropdownUtils.filterWithSymbol('~', input, multipleWordItem); expect(updatedItem.droplab_hidden).toBe(false); }); it('should filter with single quote and multiple words', () => { - input.value = 'label:\'community con'; + input.value = '\'community con'; const updatedItem = gl.DropdownUtils.filterWithSymbol('~', input, multipleWordItem); expect(updatedItem.droplab_hidden).toBe(false); }); it('should filter with single quote, symbol and multiple words', () => { - input.value = 'label:~\'community con'; + input.value = '~\'community con'; const updatedItem = gl.DropdownUtils.filterWithSymbol('~', input, multipleWordItem); expect(updatedItem.droplab_hidden).toBe(false); @@ -133,7 +126,11 @@ require('~/filtered_search/filtered_search_dropdown_manager'); beforeEach(() => { setFixtures(` - <input type="text" id="test" /> + <ul class="tokens-container"> + <li class="input-token"> + <input class="filtered-search" type="text" id="test" /> + </li> + </ul> `); input = document.getElementById('test'); @@ -149,7 +146,7 @@ require('~/filtered_search/filtered_search_dropdown_manager'); input.value = 'o'; updatedItem = gl.DropdownUtils.filterHint(input, { hint: 'label', - }, 'o'); + }); expect(updatedItem.droplab_hidden).toBe(true); }); @@ -157,6 +154,29 @@ require('~/filtered_search/filtered_search_dropdown_manager'); const updatedItem = gl.DropdownUtils.filterHint(input, {}, ''); expect(updatedItem.droplab_hidden).toBe(false); }); + + it('should allow multiple if item.type is array', () => { + input.value = 'label:~first la'; + const updatedItem = gl.DropdownUtils.filterHint(input, { + hint: 'label', + type: 'array', + }); + expect(updatedItem.droplab_hidden).toBe(false); + }); + + it('should prevent multiple if item.type is not array', () => { + input.value = 'milestone:~first mile'; + let updatedItem = gl.DropdownUtils.filterHint(input, { + hint: 'milestone', + }); + expect(updatedItem.droplab_hidden).toBe(true); + + updatedItem = gl.DropdownUtils.filterHint(input, { + hint: 'milestone', + type: 'string', + }); + expect(updatedItem.droplab_hidden).toBe(true); + }); }); describe('setDataValueIfSelected', () => { diff --git a/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js b/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js new file mode 100644 index 00000000000..a1da3396d7b --- /dev/null +++ b/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js @@ -0,0 +1,101 @@ +require('~/extensions/array'); +require('~/filtered_search/filtered_search_visual_tokens'); +require('~/filtered_search/filtered_search_tokenizer'); +require('~/filtered_search/filtered_search_dropdown_manager'); + +(() => { + describe('Filtered Search Dropdown Manager', () => { + describe('addWordToInput', () => { + function getInputValue() { + return document.querySelector('.filtered-search').value; + } + + function setInputValue(value) { + document.querySelector('.filtered-search').value = value; + } + + beforeEach(() => { + setFixtures(` + <ul class="tokens-container"> + <li class="input-token"> + <input class="filtered-search"> + </li> + </ul> + `); + }); + + describe('input has no existing value', () => { + it('should add just tokenName', () => { + gl.FilteredSearchDropdownManager.addWordToInput('milestone'); + + const token = document.querySelector('.tokens-container .js-visual-token'); + + expect(token.classList.contains('filtered-search-token')).toEqual(true); + expect(token.querySelector('.name').innerText).toBe('milestone'); + expect(getInputValue()).toBe(''); + }); + + it('should add tokenName and tokenValue', () => { + gl.FilteredSearchDropdownManager.addWordToInput('label'); + + let token = document.querySelector('.tokens-container .js-visual-token'); + + expect(token.classList.contains('filtered-search-token')).toEqual(true); + expect(token.querySelector('.name').innerText).toBe('label'); + expect(getInputValue()).toBe(''); + + gl.FilteredSearchDropdownManager.addWordToInput('label', 'none'); + // We have to get that reference again + // Because gl.FilteredSearchDropdownManager deletes the previous token + token = document.querySelector('.tokens-container .js-visual-token'); + + expect(token.classList.contains('filtered-search-token')).toEqual(true); + expect(token.querySelector('.name').innerText).toBe('label'); + expect(token.querySelector('.value').innerText).toBe('none'); + expect(getInputValue()).toBe(''); + }); + }); + + describe('input has existing value', () => { + it('should be able to just add tokenName', () => { + setInputValue('a'); + gl.FilteredSearchDropdownManager.addWordToInput('author'); + + const token = document.querySelector('.tokens-container .js-visual-token'); + + expect(token.classList.contains('filtered-search-token')).toEqual(true); + expect(token.querySelector('.name').innerText).toBe('author'); + expect(getInputValue()).toBe(''); + }); + + it('should replace tokenValue', () => { + gl.FilteredSearchDropdownManager.addWordToInput('author'); + + setInputValue('roo'); + gl.FilteredSearchDropdownManager.addWordToInput(null, '@root'); + + const token = document.querySelector('.tokens-container .js-visual-token'); + + expect(token.classList.contains('filtered-search-token')).toEqual(true); + expect(token.querySelector('.name').innerText).toBe('author'); + expect(token.querySelector('.value').innerText).toBe('@root'); + expect(getInputValue()).toBe(''); + }); + + it('should add tokenValues containing spaces', () => { + gl.FilteredSearchDropdownManager.addWordToInput('label'); + + setInputValue('"test '); + gl.FilteredSearchDropdownManager.addWordToInput('label', '~\'"test me"\''); + + const token = document.querySelector('.tokens-container .js-visual-token'); + + expect(token.classList.contains('filtered-search-token')).toEqual(true); + expect(token.querySelector('.name').innerText).toBe('label'); + expect(token.querySelector('.value').innerText).toBe('~\'"test me"\''); + expect(getInputValue()).toBe(''); + }); + }); + }); + }); +})(); diff --git a/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js.es6 b/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js.es6 deleted file mode 100644 index ed0b0196ec4..00000000000 --- a/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js.es6 +++ /dev/null @@ -1,59 +0,0 @@ -require('~/extensions/array'); -require('~/filtered_search/filtered_search_tokenizer'); -require('~/filtered_search/filtered_search_dropdown_manager'); - -(() => { - describe('Filtered Search Dropdown Manager', () => { - describe('addWordToInput', () => { - function getInputValue() { - return document.querySelector('.filtered-search').value; - } - - function setInputValue(value) { - document.querySelector('.filtered-search').value = value; - } - - beforeEach(() => { - const input = document.createElement('input'); - input.classList.add('filtered-search'); - document.body.appendChild(input); - }); - - afterEach(() => { - document.querySelector('.filtered-search').outerHTML = ''; - }); - - describe('input has no existing value', () => { - it('should add just tokenName', () => { - gl.FilteredSearchDropdownManager.addWordToInput('milestone'); - expect(getInputValue()).toBe('milestone:'); - }); - - it('should add tokenName and tokenValue', () => { - gl.FilteredSearchDropdownManager.addWordToInput('label', 'none'); - expect(getInputValue()).toBe('label:none '); - }); - }); - - describe('input has existing value', () => { - it('should be able to just add tokenName', () => { - setInputValue('a'); - gl.FilteredSearchDropdownManager.addWordToInput('author'); - expect(getInputValue()).toBe('author:'); - }); - - it('should replace tokenValue', () => { - setInputValue('author:roo'); - gl.FilteredSearchDropdownManager.addWordToInput('author', '@root'); - expect(getInputValue()).toBe('author:@root '); - }); - - it('should add tokenValues containing spaces', () => { - setInputValue('label:~"test'); - gl.FilteredSearchDropdownManager.addWordToInput('label', '~\'"test me"\''); - expect(getInputValue()).toBe('label:~\'"test me"\' '); - }); - }); - }); - }); -})(); diff --git a/spec/javascripts/filtered_search/filtered_search_manager_spec.js b/spec/javascripts/filtered_search/filtered_search_manager_spec.js new file mode 100644 index 00000000000..113161c21c6 --- /dev/null +++ b/spec/javascripts/filtered_search/filtered_search_manager_spec.js @@ -0,0 +1,262 @@ +require('~/lib/utils/url_utility'); +require('~/lib/utils/common_utils'); +require('~/filtered_search/filtered_search_token_keys'); +require('~/filtered_search/filtered_search_tokenizer'); +require('~/filtered_search/filtered_search_dropdown_manager'); +require('~/filtered_search/filtered_search_manager'); +const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper'); + +(() => { + describe('Filtered Search Manager', () => { + let input; + let manager; + let tokensContainer; + const placeholder = 'Search or filter results...'; + + function dispatchBackspaceEvent(element, eventType) { + const backspaceKey = 8; + const event = new Event(eventType); + event.keyCode = backspaceKey; + element.dispatchEvent(event); + } + + function dispatchDeleteEvent(element, eventType) { + const deleteKey = 46; + const event = new Event(eventType); + event.keyCode = deleteKey; + element.dispatchEvent(event); + } + + beforeEach(() => { + setFixtures(` + <div class="filtered-search-input-container"> + <form> + <ul class="tokens-container list-unstyled"> + ${FilteredSearchSpecHelper.createInputHTML(placeholder)} + </ul> + <button class="clear-search" type="button"> + <i class="fa fa-times"></i> + </button> + </form> + </div> + `); + + spyOn(gl.FilteredSearchManager.prototype, 'loadSearchParamsFromURL').and.callFake(() => {}); + spyOn(gl.FilteredSearchManager.prototype, 'tokenChange').and.callFake(() => {}); + spyOn(gl.FilteredSearchDropdownManager.prototype, 'setDropdown').and.callFake(() => {}); + spyOn(gl.FilteredSearchDropdownManager.prototype, 'updateDropdownOffset').and.callFake(() => {}); + spyOn(gl.utils, 'getParameterByName').and.returnValue(null); + spyOn(gl.FilteredSearchVisualTokens, 'unselectTokens').and.callThrough(); + + input = document.querySelector('.filtered-search'); + tokensContainer = document.querySelector('.tokens-container'); + manager = new gl.FilteredSearchManager(); + }); + + afterEach(() => { + manager.cleanup(); + }); + + describe('search', () => { + const defaultParams = '?scope=all&utf8=✓&state=opened'; + + it('should search with a single word', (done) => { + input.value = 'searchTerm'; + + spyOn(gl.utils, 'visitUrl').and.callFake((url) => { + expect(url).toEqual(`${defaultParams}&search=searchTerm`); + done(); + }); + + manager.search(); + }); + + it('should search with multiple words', (done) => { + input.value = 'awesome search terms'; + + spyOn(gl.utils, 'visitUrl').and.callFake((url) => { + expect(url).toEqual(`${defaultParams}&search=awesome+search+terms`); + done(); + }); + + manager.search(); + }); + + it('should search with special characters', (done) => { + input.value = '~!@#$%^&*()_+{}:<>,.?/'; + + spyOn(gl.utils, 'visitUrl').and.callFake((url) => { + expect(url).toEqual(`${defaultParams}&search=~!%40%23%24%25%5E%26*()_%2B%7B%7D%3A%3C%3E%2C.%3F%2F`); + done(); + }); + + manager.search(); + }); + }); + + describe('handleInputPlaceholder', () => { + it('should render placeholder when there is no input', () => { + expect(input.placeholder).toEqual(placeholder); + }); + + it('should not render placeholder when there is input', () => { + input.value = 'test words'; + + const event = new Event('input'); + input.dispatchEvent(event); + + expect(input.placeholder).toEqual(''); + }); + + it('should not render placeholder when there are tokens and no input', () => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug'), + ); + + const event = new Event('input'); + input.dispatchEvent(event); + + expect(input.placeholder).toEqual(''); + }); + }); + + describe('checkForBackspace', () => { + describe('tokens and no input', () => { + beforeEach(() => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug'), + ); + }); + + it('removes last token', () => { + spyOn(gl.FilteredSearchVisualTokens, 'removeLastTokenPartial').and.callThrough(); + dispatchBackspaceEvent(input, 'keyup'); + + expect(gl.FilteredSearchVisualTokens.removeLastTokenPartial).toHaveBeenCalled(); + }); + + it('sets the input', () => { + spyOn(gl.FilteredSearchVisualTokens, 'getLastTokenPartial').and.callThrough(); + dispatchDeleteEvent(input, 'keyup'); + + expect(gl.FilteredSearchVisualTokens.getLastTokenPartial).toHaveBeenCalled(); + expect(input.value).toEqual('~bug'); + }); + }); + + it('does not remove token or change input when there is existing input', () => { + spyOn(gl.FilteredSearchVisualTokens, 'removeLastTokenPartial').and.callThrough(); + spyOn(gl.FilteredSearchVisualTokens, 'getLastTokenPartial').and.callThrough(); + + input.value = 'text'; + dispatchDeleteEvent(input, 'keyup'); + + expect(gl.FilteredSearchVisualTokens.removeLastTokenPartial).not.toHaveBeenCalled(); + expect(gl.FilteredSearchVisualTokens.getLastTokenPartial).not.toHaveBeenCalled(); + expect(input.value).toEqual('text'); + }); + }); + + describe('removeSelectedToken', () => { + function getVisualTokens() { + return tokensContainer.querySelectorAll('.js-visual-token'); + } + + beforeEach(() => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none', true), + ); + }); + + it('removes selected token when the backspace key is pressed', () => { + expect(getVisualTokens().length).toEqual(1); + + dispatchBackspaceEvent(document, 'keydown'); + + expect(getVisualTokens().length).toEqual(0); + }); + + it('removes selected token when the delete key is pressed', () => { + expect(getVisualTokens().length).toEqual(1); + + dispatchDeleteEvent(document, 'keydown'); + + expect(getVisualTokens().length).toEqual(0); + }); + + it('updates the input placeholder after removal', () => { + manager.handleInputPlaceholder(); + + expect(input.placeholder).toEqual(''); + expect(getVisualTokens().length).toEqual(1); + + dispatchBackspaceEvent(document, 'keydown'); + + expect(input.placeholder).not.toEqual(''); + expect(getVisualTokens().length).toEqual(0); + }); + + it('updates the clear button after removal', () => { + manager.toggleClearSearchButton(); + + const clearButton = document.querySelector('.clear-search'); + + expect(clearButton.classList.contains('hidden')).toEqual(false); + expect(getVisualTokens().length).toEqual(1); + + dispatchBackspaceEvent(document, 'keydown'); + + expect(clearButton.classList.contains('hidden')).toEqual(true); + expect(getVisualTokens().length).toEqual(0); + }); + }); + + describe('unselects token', () => { + beforeEach(() => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug', true)} + ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~awesome')} + `); + }); + + it('unselects token when input is clicked', () => { + const selectedToken = tokensContainer.querySelector('.js-visual-token .selected'); + + expect(selectedToken.classList.contains('selected')).toEqual(true); + expect(gl.FilteredSearchVisualTokens.unselectTokens).not.toHaveBeenCalled(); + + // Click directly on input attached to document + // so that the click event will propagate properly + document.querySelector('.filtered-search').click(); + + expect(gl.FilteredSearchVisualTokens.unselectTokens).toHaveBeenCalled(); + expect(selectedToken.classList.contains('selected')).toEqual(false); + }); + + it('unselects token when document.body is clicked', () => { + const selectedToken = tokensContainer.querySelector('.js-visual-token .selected'); + + expect(selectedToken.classList.contains('selected')).toEqual(true); + expect(gl.FilteredSearchVisualTokens.unselectTokens).not.toHaveBeenCalled(); + + document.body.click(); + + expect(selectedToken.classList.contains('selected')).toEqual(false); + expect(gl.FilteredSearchVisualTokens.unselectTokens).toHaveBeenCalled(); + }); + }); + + describe('toggleInputContainerFocus', () => { + it('toggles on focus', () => { + input.focus(); + expect(document.querySelector('.filtered-search-input-container').classList.contains('focus')).toEqual(true); + }); + + it('toggles on blur', () => { + input.blur(); + expect(document.querySelector('.filtered-search-input-container').classList.contains('focus')).toEqual(false); + }); + }); + }); +})(); diff --git a/spec/javascripts/filtered_search/filtered_search_manager_spec.js.es6 b/spec/javascripts/filtered_search/filtered_search_manager_spec.js.es6 deleted file mode 100644 index 98959dda242..00000000000 --- a/spec/javascripts/filtered_search/filtered_search_manager_spec.js.es6 +++ /dev/null @@ -1,67 +0,0 @@ -require('~/lib/utils/url_utility'); -require('~/lib/utils/common_utils'); -require('~/filtered_search/filtered_search_token_keys'); -require('~/filtered_search/filtered_search_tokenizer'); -require('~/filtered_search/filtered_search_dropdown_manager'); -require('~/filtered_search/filtered_search_manager'); - -(() => { - describe('Filtered Search Manager', () => { - describe('search', () => { - let manager; - const defaultParams = '?scope=all&utf8=✓&state=opened'; - - function getInput() { - return document.querySelector('.filtered-search'); - } - - beforeEach(() => { - setFixtures(` - <input type='text' class='filtered-search' /> - `); - - spyOn(gl.FilteredSearchManager.prototype, 'bindEvents').and.callFake(() => {}); - spyOn(gl.FilteredSearchManager.prototype, 'cleanup').and.callFake(() => {}); - spyOn(gl.FilteredSearchManager.prototype, 'loadSearchParamsFromURL').and.callFake(() => {}); - spyOn(gl.FilteredSearchDropdownManager.prototype, 'setDropdown').and.callFake(() => {}); - spyOn(gl.utils, 'getParameterByName').and.returnValue(null); - - manager = new gl.FilteredSearchManager(); - }); - - afterEach(() => { - getInput().outerHTML = ''; - }); - - it('should search with a single word', () => { - getInput().value = 'searchTerm'; - - spyOn(gl.utils, 'visitUrl').and.callFake((url) => { - expect(url).toEqual(`${defaultParams}&search=searchTerm`); - }); - - manager.search(); - }); - - it('should search with multiple words', () => { - getInput().value = 'awesome search terms'; - - spyOn(gl.utils, 'visitUrl').and.callFake((url) => { - expect(url).toEqual(`${defaultParams}&search=awesome+search+terms`); - }); - - manager.search(); - }); - - it('should search with special characters', () => { - getInput().value = '~!@#$%^&*()_+{}:<>,.?/'; - - spyOn(gl.utils, 'visitUrl').and.callFake((url) => { - expect(url).toEqual(`${defaultParams}&search=~!%40%23%24%25%5E%26*()_%2B%7B%7D%3A%3C%3E%2C.%3F%2F`); - }); - - manager.search(); - }); - }); - }); -})(); diff --git a/spec/javascripts/filtered_search/filtered_search_token_keys_spec.js.es6 b/spec/javascripts/filtered_search/filtered_search_token_keys_spec.js index cf409a7e509..cf409a7e509 100644 --- a/spec/javascripts/filtered_search/filtered_search_token_keys_spec.js.es6 +++ b/spec/javascripts/filtered_search/filtered_search_token_keys_spec.js diff --git a/spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js.es6 b/spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js index a91801cfc89..a91801cfc89 100644 --- a/spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js.es6 +++ b/spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js diff --git a/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js b/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js new file mode 100644 index 00000000000..bbda1476fed --- /dev/null +++ b/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js @@ -0,0 +1,600 @@ +require('~/filtered_search/filtered_search_visual_tokens'); +const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper'); + +describe('Filtered Search Visual Tokens', () => { + let tokensContainer; + + beforeEach(() => { + setFixtures(` + <ul class="tokens-container"> + ${FilteredSearchSpecHelper.createInputHTML()} + </ul> + `); + tokensContainer = document.querySelector('.tokens-container'); + }); + + describe('getLastVisualTokenBeforeInput', () => { + it('returns when there are no visual tokens', () => { + const { lastVisualToken, isLastVisualTokenValid } + = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); + + expect(lastVisualToken).toEqual(null); + expect(isLastVisualTokenValid).toEqual(true); + }); + + describe('input is the last item in tokensContainer', () => { + it('returns when there is one visual token', () => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug'), + ); + + const { lastVisualToken, isLastVisualTokenValid } + = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); + + expect(lastVisualToken).toEqual(document.querySelector('.filtered-search-token')); + expect(isLastVisualTokenValid).toEqual(true); + }); + + it('returns when there is an incomplete visual token', () => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('Author'), + ); + + const { lastVisualToken, isLastVisualTokenValid } + = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); + + expect(lastVisualToken).toEqual(document.querySelector('.filtered-search-token')); + expect(isLastVisualTokenValid).toEqual(false); + }); + + it('returns when there are multiple visual tokens', () => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')} + ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root')} + `); + + const { lastVisualToken, isLastVisualTokenValid } + = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); + const items = document.querySelectorAll('.tokens-container .js-visual-token'); + + expect(lastVisualToken.isEqualNode(items[items.length - 1])).toEqual(true); + expect(isLastVisualTokenValid).toEqual(true); + }); + + it('returns when there are multiple visual tokens and an incomplete visual token', () => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')} + ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')} + ${FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('assignee')} + `); + + const { lastVisualToken, isLastVisualTokenValid } + = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); + const items = document.querySelectorAll('.tokens-container .js-visual-token'); + + expect(lastVisualToken.isEqualNode(items[items.length - 1])).toEqual(true); + expect(isLastVisualTokenValid).toEqual(false); + }); + }); + + describe('input is a middle item in tokensContainer', () => { + it('returns last token before input', () => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')} + ${FilteredSearchSpecHelper.createInputHTML()} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root')} + `); + + const { lastVisualToken, isLastVisualTokenValid } + = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); + + expect(lastVisualToken).toEqual(document.querySelector('.filtered-search-token')); + expect(isLastVisualTokenValid).toEqual(true); + }); + + it('returns last partial token before input', () => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` + ${FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('label')} + ${FilteredSearchSpecHelper.createInputHTML()} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root')} + `); + + const { lastVisualToken, isLastVisualTokenValid } + = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); + + expect(lastVisualToken).toEqual(document.querySelector('.filtered-search-token')); + expect(isLastVisualTokenValid).toEqual(false); + }); + }); + }); + + describe('unselectTokens', () => { + it('does nothing when there are no tokens', () => { + const beforeHTML = tokensContainer.innerHTML; + gl.FilteredSearchVisualTokens.unselectTokens(); + + expect(tokensContainer.innerHTML).toEqual(beforeHTML); + }); + + it('removes the selected class from buttons', () => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@author')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '%123', true)} + `); + + const selected = tokensContainer.querySelector('.js-visual-token .selected'); + expect(selected.classList.contains('selected')).toEqual(true); + + gl.FilteredSearchVisualTokens.unselectTokens(); + + expect(selected.classList.contains('selected')).toEqual(false); + }); + }); + + describe('selectToken', () => { + beforeEach(() => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')} + ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~awesome')} + `); + }); + + it('removes the selected class if it has selected class', () => { + const firstTokenButton = tokensContainer.querySelector('.js-visual-token .selectable'); + firstTokenButton.classList.add('selected'); + + gl.FilteredSearchVisualTokens.selectToken(firstTokenButton); + + expect(firstTokenButton.classList.contains('selected')).toEqual(false); + }); + + describe('has no selected class', () => { + it('adds selected class', () => { + const firstTokenButton = tokensContainer.querySelector('.js-visual-token .selectable'); + + gl.FilteredSearchVisualTokens.selectToken(firstTokenButton); + + expect(firstTokenButton.classList.contains('selected')).toEqual(true); + }); + + it('removes selected class from other tokens', () => { + const tokenButtons = tokensContainer.querySelectorAll('.js-visual-token .selectable'); + tokenButtons[1].classList.add('selected'); + + gl.FilteredSearchVisualTokens.selectToken(tokenButtons[0]); + + expect(tokenButtons[0].classList.contains('selected')).toEqual(true); + expect(tokenButtons[1].classList.contains('selected')).toEqual(false); + }); + }); + }); + + describe('removeSelectedToken', () => { + it('does not remove when there are no selected tokens', () => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none'), + ); + + expect(tokensContainer.querySelector('.js-visual-token .selectable')).not.toEqual(null); + + gl.FilteredSearchVisualTokens.removeSelectedToken(); + + expect(tokensContainer.querySelector('.js-visual-token .selectable')).not.toEqual(null); + }); + + it('removes selected token', () => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none', true), + ); + + expect(tokensContainer.querySelector('.js-visual-token .selectable')).not.toEqual(null); + + gl.FilteredSearchVisualTokens.removeSelectedToken(); + + expect(tokensContainer.querySelector('.js-visual-token .selectable')).toEqual(null); + }); + }); + + describe('createVisualTokenElementHTML', () => { + let tokenElement; + + beforeEach(() => { + setFixtures(` + <div class="test-area"> + ${gl.FilteredSearchVisualTokens.createVisualTokenElementHTML()} + </div> + `); + + tokenElement = document.querySelector('.test-area').firstElementChild; + }); + + it('contains name div', () => { + expect(tokenElement.querySelector('.name')).toEqual(jasmine.anything()); + }); + + it('contains value div', () => { + expect(tokenElement.querySelector('.value')).toEqual(jasmine.anything()); + }); + + it('contains selectable class', () => { + expect(tokenElement.classList.contains('selectable')).toEqual(true); + }); + + it('contains button role', () => { + expect(tokenElement.getAttribute('role')).toEqual('button'); + }); + }); + + describe('addVisualTokenElement', () => { + it('renders search visual tokens', () => { + gl.FilteredSearchVisualTokens.addVisualTokenElement('search term', null, true); + const token = tokensContainer.querySelector('.js-visual-token'); + + expect(token.classList.contains('filtered-search-term')).toEqual(true); + expect(token.querySelector('.name').innerText).toEqual('search term'); + expect(token.querySelector('.value')).toEqual(null); + }); + + it('renders filter visual token name', () => { + gl.FilteredSearchVisualTokens.addVisualTokenElement('milestone'); + const token = tokensContainer.querySelector('.js-visual-token'); + + expect(token.classList.contains('filtered-search-token')).toEqual(true); + expect(token.querySelector('.name').innerText).toEqual('milestone'); + expect(token.querySelector('.value')).toEqual(null); + }); + + it('renders filter visual token name and value', () => { + gl.FilteredSearchVisualTokens.addVisualTokenElement('label', 'Frontend'); + const token = tokensContainer.querySelector('.js-visual-token'); + + expect(token.classList.contains('filtered-search-token')).toEqual(true); + expect(token.querySelector('.name').innerText).toEqual('label'); + expect(token.querySelector('.value').innerText).toEqual('Frontend'); + }); + + it('inserts visual token before input', () => { + tokensContainer.appendChild(FilteredSearchSpecHelper.createFilterVisualToken('assignee', '@root')); + + gl.FilteredSearchVisualTokens.addVisualTokenElement('label', 'Frontend'); + const tokens = tokensContainer.querySelectorAll('.js-visual-token'); + const labelToken = tokens[0]; + const assigneeToken = tokens[1]; + + expect(labelToken.classList.contains('filtered-search-token')).toEqual(true); + expect(labelToken.querySelector('.name').innerText).toEqual('label'); + expect(labelToken.querySelector('.value').innerText).toEqual('Frontend'); + + expect(assigneeToken.classList.contains('filtered-search-token')).toEqual(true); + expect(assigneeToken.querySelector('.name').innerText).toEqual('assignee'); + expect(assigneeToken.querySelector('.value').innerText).toEqual('@root'); + }); + }); + + describe('addValueToPreviousVisualTokenElement', () => { + it('does not add when previous visual token element has no value', () => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root'), + ); + + const original = tokensContainer.innerHTML; + gl.FilteredSearchVisualTokens.addValueToPreviousVisualTokenElement('value'); + + expect(original).toEqual(tokensContainer.innerHTML); + }); + + it('does not add when previous visual token element is a search', () => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root')} + ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')} + `); + + const original = tokensContainer.innerHTML; + gl.FilteredSearchVisualTokens.addValueToPreviousVisualTokenElement('value'); + + expect(original).toEqual(tokensContainer.innerHTML); + }); + + it('adds value to previous visual filter token', () => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('label'), + ); + + const original = tokensContainer.innerHTML; + gl.FilteredSearchVisualTokens.addValueToPreviousVisualTokenElement('value'); + const updatedToken = tokensContainer.querySelector('.js-visual-token'); + + expect(updatedToken.querySelector('.name').innerText).toEqual('label'); + expect(updatedToken.querySelector('.value').innerText).toEqual('value'); + expect(original).not.toEqual(tokensContainer.innerHTML); + }); + }); + + describe('addFilterVisualToken', () => { + it('creates visual token with just tokenName', () => { + gl.FilteredSearchVisualTokens.addFilterVisualToken('milestone'); + const token = tokensContainer.querySelector('.js-visual-token'); + + expect(token.classList.contains('filtered-search-token')).toEqual(true); + expect(token.querySelector('.name').innerText).toEqual('milestone'); + expect(token.querySelector('.value')).toEqual(null); + }); + + it('creates visual token with just tokenValue', () => { + gl.FilteredSearchVisualTokens.addFilterVisualToken('milestone'); + gl.FilteredSearchVisualTokens.addFilterVisualToken('%8.17'); + const token = tokensContainer.querySelector('.js-visual-token'); + + expect(token.classList.contains('filtered-search-token')).toEqual(true); + expect(token.querySelector('.name').innerText).toEqual('milestone'); + expect(token.querySelector('.value').innerText).toEqual('%8.17'); + }); + + it('creates full visual token', () => { + gl.FilteredSearchVisualTokens.addFilterVisualToken('assignee', '@john'); + const token = tokensContainer.querySelector('.js-visual-token'); + + expect(token.classList.contains('filtered-search-token')).toEqual(true); + expect(token.querySelector('.name').innerText).toEqual('assignee'); + expect(token.querySelector('.value').innerText).toEqual('@john'); + }); + }); + + describe('addSearchVisualToken', () => { + it('creates search visual token', () => { + gl.FilteredSearchVisualTokens.addSearchVisualToken('search term'); + const token = tokensContainer.querySelector('.js-visual-token'); + + expect(token.classList.contains('filtered-search-term')).toEqual(true); + expect(token.querySelector('.name').innerText).toEqual('search term'); + expect(token.querySelector('.value')).toEqual(null); + }); + + it('appends to previous search visual token if previous token was a search token', () => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root')} + ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')} + `); + + gl.FilteredSearchVisualTokens.addSearchVisualToken('append this'); + const token = tokensContainer.querySelector('.filtered-search-term'); + + expect(token.querySelector('.name').innerText).toEqual('search term append this'); + expect(token.querySelector('.value')).toEqual(null); + }); + }); + + describe('getLastTokenPartial', () => { + it('should get last token value', () => { + const value = '~bug'; + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', value), + ); + + expect(gl.FilteredSearchVisualTokens.getLastTokenPartial()).toEqual(value); + }); + + it('should get last token name if there is no value', () => { + const name = 'assignee'; + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createNameFilterVisualTokenHTML(name), + ); + + expect(gl.FilteredSearchVisualTokens.getLastTokenPartial()).toEqual(name); + }); + + it('should return empty when there are no tokens', () => { + expect(gl.FilteredSearchVisualTokens.getLastTokenPartial()).toEqual(''); + }); + }); + + describe('removeLastTokenPartial', () => { + it('should remove the last token value if it exists', () => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~"Community Contribution"'), + ); + + expect(tokensContainer.querySelector('.js-visual-token .value')).not.toEqual(null); + + gl.FilteredSearchVisualTokens.removeLastTokenPartial(); + + expect(tokensContainer.querySelector('.js-visual-token .value')).toEqual(null); + }); + + it('should remove the last token name if there is no value', () => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('milestone'), + ); + + expect(tokensContainer.querySelector('.js-visual-token .name')).not.toEqual(null); + + gl.FilteredSearchVisualTokens.removeLastTokenPartial(); + + expect(tokensContainer.querySelector('.js-visual-token .name')).toEqual(null); + }); + + it('should not remove anything when there are no tokens', () => { + const html = tokensContainer.innerHTML; + gl.FilteredSearchVisualTokens.removeLastTokenPartial(); + + expect(tokensContainer.innerHTML).toEqual(html); + }); + }); + + describe('tokenizeInput', () => { + it('does not do anything if there is no input', () => { + const original = tokensContainer.innerHTML; + gl.FilteredSearchVisualTokens.tokenizeInput(); + + expect(tokensContainer.innerHTML).toEqual(original); + }); + + it('adds search visual token if previous visual token is valid', () => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('assignee', 'none'), + ); + + const input = document.querySelector('.filtered-search'); + input.value = 'some value'; + gl.FilteredSearchVisualTokens.tokenizeInput(); + + const newToken = tokensContainer.querySelector('.filtered-search-term'); + + expect(input.value).toEqual(''); + expect(newToken.querySelector('.name').innerText).toEqual('some value'); + expect(newToken.querySelector('.value')).toEqual(null); + }); + + it('adds value to previous visual token element if previous visual token is invalid', () => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('assignee'), + ); + + const input = document.querySelector('.filtered-search'); + input.value = '@john'; + gl.FilteredSearchVisualTokens.tokenizeInput(); + + const updatedToken = tokensContainer.querySelector('.filtered-search-token'); + + expect(input.value).toEqual(''); + expect(updatedToken.querySelector('.name').innerText).toEqual('assignee'); + expect(updatedToken.querySelector('.value').innerText).toEqual('@john'); + }); + }); + + describe('editToken', () => { + let input; + let token; + + beforeEach(() => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none')} + ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'upcoming')} + `); + + input = document.querySelector('.filtered-search'); + token = document.querySelector('.js-visual-token'); + }); + + it('tokenize\'s existing input', () => { + input.value = 'some text'; + spyOn(gl.FilteredSearchVisualTokens, 'tokenizeInput').and.callThrough(); + + gl.FilteredSearchVisualTokens.editToken(token); + + expect(gl.FilteredSearchVisualTokens.tokenizeInput).toHaveBeenCalled(); + expect(input.value).not.toEqual('some text'); + }); + + it('moves input to the token position', () => { + expect(tokensContainer.children[3].querySelector('.filtered-search')).not.toEqual(null); + + gl.FilteredSearchVisualTokens.editToken(token); + + expect(tokensContainer.children[1].querySelector('.filtered-search')).not.toEqual(null); + expect(tokensContainer.children[3].querySelector('.filtered-search')).toEqual(null); + }); + + it('input contains the visual token value', () => { + gl.FilteredSearchVisualTokens.editToken(token); + + expect(input.value).toEqual('none'); + }); + + describe('selected token is a search term token', () => { + beforeEach(() => { + token = document.querySelector('.filtered-search-term'); + }); + + it('token is removed', () => { + expect(tokensContainer.querySelector('.filtered-search-term')).not.toEqual(null); + + gl.FilteredSearchVisualTokens.editToken(token); + + expect(tokensContainer.querySelector('.filtered-search-term')).toEqual(null); + }); + + it('input has the same value as removed token', () => { + expect(input.value).toEqual(''); + + gl.FilteredSearchVisualTokens.editToken(token); + + expect(input.value).toEqual('search'); + }); + }); + }); + + describe('moveInputTotheRight', () => { + it('does nothing if the input is already the right most element', () => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none'), + ); + + spyOn(gl.FilteredSearchVisualTokens, 'tokenizeInput').and.callFake(() => {}); + spyOn(gl.FilteredSearchVisualTokens, 'getLastVisualTokenBeforeInput').and.callThrough(); + + gl.FilteredSearchVisualTokens.moveInputToTheRight(); + + expect(gl.FilteredSearchVisualTokens.tokenizeInput).toHaveBeenCalled(); + expect(gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput).not.toHaveBeenCalled(); + }); + + it('tokenize\'s input', () => { + tokensContainer.innerHTML = ` + ${FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('label')} + ${FilteredSearchSpecHelper.createInputHTML()} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')} + `; + + document.querySelector('.filtered-search').value = 'none'; + + gl.FilteredSearchVisualTokens.moveInputToTheRight(); + const value = tokensContainer.querySelector('.js-visual-token .value'); + + expect(value.innerText).toEqual('none'); + }); + + it('converts input into search term token if last token is valid', () => { + tokensContainer.innerHTML = ` + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none')} + ${FilteredSearchSpecHelper.createInputHTML()} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')} + `; + + document.querySelector('.filtered-search').value = 'test'; + + gl.FilteredSearchVisualTokens.moveInputToTheRight(); + const searchValue = tokensContainer.querySelector('.filtered-search-term .name'); + + expect(searchValue.innerText).toEqual('test'); + }); + + it('moves the input to the right most element', () => { + tokensContainer.innerHTML = ` + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none')} + ${FilteredSearchSpecHelper.createInputHTML()} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')} + `; + + gl.FilteredSearchVisualTokens.moveInputToTheRight(); + + expect(tokensContainer.children[2].querySelector('.filtered-search')).not.toEqual(null); + }); + + it('tokenizes input even if input is the right most element', () => { + tokensContainer.innerHTML = ` + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none')} + ${FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('label')} + ${FilteredSearchSpecHelper.createInputHTML('', '~bug')} + `; + + gl.FilteredSearchVisualTokens.moveInputToTheRight(); + + const token = tokensContainer.children[1]; + expect(token.querySelector('.value').innerText).toEqual('~bug'); + }); + }); +}); diff --git a/spec/javascripts/fixtures/ajax_loading_spinner.html.haml b/spec/javascripts/fixtures/ajax_loading_spinner.html.haml new file mode 100644 index 00000000000..09d8c9df3b2 --- /dev/null +++ b/spec/javascripts/fixtures/ajax_loading_spinner.html.haml @@ -0,0 +1,2 @@ +%a.js-ajax-loading-spinner{href: "http://goesnowhere.nothing/whereami", data: {remote: true}} + %i.fa.fa-trash-o diff --git a/spec/javascripts/fixtures/branches.rb b/spec/javascripts/fixtures/branches.rb index 0e7c2351b66..a059818145b 100644 --- a/spec/javascripts/fixtures/branches.rb +++ b/spec/javascripts/fixtures/branches.rb @@ -20,7 +20,7 @@ describe Projects::BranchesController, '(JavaScript fixtures)', type: :controlle it 'branches/new_branch.html.raw' do |example| get :new, namespace_id: project.namespace.to_param, - project_id: project.to_param + project_id: project expect(response).to be_success store_frontend_fixture(response, example.description) diff --git a/spec/javascripts/fixtures/builds.rb b/spec/javascripts/fixtures/builds.rb index 978e25a1c32..320de791b08 100644 --- a/spec/javascripts/fixtures/builds.rb +++ b/spec/javascripts/fixtures/builds.rb @@ -24,7 +24,7 @@ describe Projects::BuildsController, '(JavaScript fixtures)', type: :controller it 'builds/build-with-artifacts.html.raw' do |example| get :show, namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, id: build_with_artifacts.to_param expect(response).to be_success diff --git a/spec/javascripts/fixtures/emoji_menu.js b/spec/javascripts/fixtures/emoji_menu.js deleted file mode 100644 index a50812d9517..00000000000 --- a/spec/javascripts/fixtures/emoji_menu.js +++ /dev/null @@ -1,4 +0,0 @@ -/* eslint-disable space-before-function-paren */ -(function() { - window.emojiMenu = "<div class='emoji-menu'>\n <input type=\"text\" name=\"emoji_search\" id=\"emoji_search\" value=\"\" class=\"emoji-search search-input form-control\" />\n <div class='emoji-menu-content'>\n <h5 class='emoji-menu-title'>\n Emoticons\n </h5>\n <ul class='clearfix emoji-menu-list'>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F47D\" title=\"alien\" data-aliases=\"\" data-emoji=\"alien\" data-unicode-name=\"1F47D\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F47C\" title=\"angel\" data-aliases=\"\" data-emoji=\"angel\" data-unicode-name=\"1F47C\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F4A2\" title=\"anger\" data-aliases=\"\" data-emoji=\"anger\" data-unicode-name=\"1F4A2\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F620\" title=\"angry\" data-aliases=\"\" data-emoji=\"angry\" data-unicode-name=\"1F620\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F627\" title=\"anguished\" data-aliases=\"\" data-emoji=\"anguished\" data-unicode-name=\"1F627\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F632\" title=\"astonished\" data-aliases=\"\" data-emoji=\"astonished\" data-unicode-name=\"1F632\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F45F\" title=\"athletic_shoe\" data-aliases=\"\" data-emoji=\"athletic_shoe\" data-unicode-name=\"1F45F\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F476\" title=\"baby\" data-aliases=\"\" data-emoji=\"baby\" data-unicode-name=\"1F476\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F459\" title=\"bikini\" data-aliases=\"\" data-emoji=\"bikini\" data-unicode-name=\"1F459\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F499\" title=\"blue_heart\" data-aliases=\"\" data-emoji=\"blue_heart\" data-unicode-name=\"1F499\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F60A\" title=\"blush\" data-aliases=\"\" data-emoji=\"blush\" data-unicode-name=\"1F60A\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F4A5\" title=\"boom\" data-aliases=\"\" data-emoji=\"boom\" data-unicode-name=\"1F4A5\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F462\" title=\"boot\" data-aliases=\"\" data-emoji=\"boot\" data-unicode-name=\"1F462\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F647\" title=\"bow\" data-aliases=\"\" data-emoji=\"bow\" data-unicode-name=\"1F647\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F466\" title=\"boy\" data-aliases=\"\" data-emoji=\"boy\" data-unicode-name=\"1F466\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F470\" title=\"bride_with_veil\" data-aliases=\"\" data-emoji=\"bride_with_veil\" data-unicode-name=\"1F470\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F4BC\" title=\"briefcase\" data-aliases=\"\" data-emoji=\"briefcase\" data-unicode-name=\"1F4BC\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F494\" title=\"broken_heart\" data-aliases=\"\" data-emoji=\"broken_heart\" data-unicode-name=\"1F494\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F464\" title=\"bust_in_silhouette\" data-aliases=\"\" data-emoji=\"bust_in_silhouette\" data-unicode-name=\"1F464\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F465\" title=\"busts_in_silhouette\" data-aliases=\"\" data-emoji=\"busts_in_silhouette\" data-unicode-name=\"1F465\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F44F\" title=\"clap\" data-aliases=\"\" data-emoji=\"clap\" data-unicode-name=\"1F44F\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F302\" title=\"closed_umbrella\" data-aliases=\"\" data-emoji=\"closed_umbrella\" data-unicode-name=\"1F302\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F630\" title=\"cold_sweat\" data-aliases=\"\" data-emoji=\"cold_sweat\" data-unicode-name=\"1F630\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F616\" title=\"confounded\" data-aliases=\"\" data-emoji=\"confounded\" data-unicode-name=\"1F616\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F615\" title=\"confused\" data-aliases=\"\" data-emoji=\"confused\" data-unicode-name=\"1F615\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F477\" title=\"construction_worker\" data-aliases=\"\" data-emoji=\"construction_worker\" data-unicode-name=\"1F477\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F46E\" title=\"cop\" data-aliases=\"\" data-emoji=\"cop\" data-unicode-name=\"1F46E\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F46B\" title=\"couple\" data-aliases=\"\" data-emoji=\"couple\" data-unicode-name=\"1F46B\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F491\" title=\"couple_with_heart\" data-aliases=\"\" data-emoji=\"couple_with_heart\" data-unicode-name=\"1F491\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F48F\" title=\"couplekiss\" data-aliases=\"\" data-emoji=\"couplekiss\" data-unicode-name=\"1F48F\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F451\" title=\"crown\" data-aliases=\"\" data-emoji=\"crown\" data-unicode-name=\"1F451\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F622\" title=\"cry\" data-aliases=\"\" data-emoji=\"cry\" data-unicode-name=\"1F622\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F63F\" title=\"crying_cat_face\" data-aliases=\"\" data-emoji=\"crying_cat_face\" data-unicode-name=\"1F63F\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F498\" title=\"cupid\" data-aliases=\"\" data-emoji=\"cupid\" data-unicode-name=\"1F498\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F483\" title=\"dancer\" data-aliases=\"\" data-emoji=\"dancer\" data-unicode-name=\"1F483\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F46F\" title=\"dancers\" data-aliases=\"\" data-emoji=\"dancers\" data-unicode-name=\"1F46F\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F4A8\" title=\"dash\" data-aliases=\"\" data-emoji=\"dash\" data-unicode-name=\"1F4A8\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F61E\" title=\"disappointed\" data-aliases=\"\" data-emoji=\"disappointed\" data-unicode-name=\"1F61E\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F625\" title=\"disappointed_relieved\" data-aliases=\"\" data-emoji=\"disappointed_relieved\" data-unicode-name=\"1F625\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F4AB\" title=\"dizzy\" data-aliases=\"\" data-emoji=\"dizzy\" data-unicode-name=\"1F4AB\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F635\" title=\"dizzy_face\" data-aliases=\"\" data-emoji=\"dizzy_face\" data-unicode-name=\"1F635\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F457\" title=\"dress\" data-aliases=\"\" data-emoji=\"dress\" data-unicode-name=\"1F457\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F4A7\" title=\"droplet\" data-aliases=\"\" data-emoji=\"droplet\" data-unicode-name=\"1F4A7\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F442\" title=\"ear\" data-aliases=\"\" data-emoji=\"ear\" data-unicode-name=\"1F442\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F611\" title=\"expressionless\" data-aliases=\"\" data-emoji=\"expressionless\" data-unicode-name=\"1F611\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F453\" title=\"eyeglasses\" data-aliases=\"\" data-emoji=\"eyeglasses\" data-unicode-name=\"1F453\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F440\" title=\"eyes\" data-aliases=\"\" data-emoji=\"eyes\" data-unicode-name=\"1F440\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F46A\" title=\"family\" data-aliases=\"\" data-emoji=\"family\" data-unicode-name=\"1F46A\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F628\" title=\"fearful\" data-aliases=\"\" data-emoji=\"fearful\" data-unicode-name=\"1F628\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F525\" title=\"fire\" data-aliases=\":flame:\" data-emoji=\"fire\" data-unicode-name=\"1F525\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-270A\" title=\"fist\" data-aliases=\"\" data-emoji=\"fist\" data-unicode-name=\"270A\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F633\" title=\"flushed\" data-aliases=\"\" data-emoji=\"flushed\" data-unicode-name=\"1F633\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F463\" title=\"footprints\" data-aliases=\"\" data-emoji=\"footprints\" data-unicode-name=\"1F463\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F626\" title=\"frowning\" data-aliases=\":anguished:\" data-emoji=\"frowning\" data-unicode-name=\"1F626\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F48E\" title=\"gem\" data-aliases=\"\" data-emoji=\"gem\" data-unicode-name=\"1F48E\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F467\" title=\"girl\" data-aliases=\"\" data-emoji=\"girl\" data-unicode-name=\"1F467\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F49A\" title=\"green_heart\" data-aliases=\"\" data-emoji=\"green_heart\" data-unicode-name=\"1F49A\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F62C\" title=\"grimacing\" data-aliases=\"\" data-emoji=\"grimacing\" data-unicode-name=\"1F62C\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F601\" title=\"grin\" data-aliases=\"\" data-emoji=\"grin\" data-unicode-name=\"1F601\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F600\" title=\"grinning\" data-aliases=\"\" data-emoji=\"grinning\" data-unicode-name=\"1F600\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F482\" title=\"guardsman\" data-aliases=\"\" data-emoji=\"guardsman\" data-unicode-name=\"1F482\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F487\" title=\"haircut\" data-aliases=\"\" data-emoji=\"haircut\" data-unicode-name=\"1F487\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F45C\" title=\"handbag\" data-aliases=\"\" data-emoji=\"handbag\" data-unicode-name=\"1F45C\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F649\" title=\"hear_no_evil\" data-aliases=\"\" data-emoji=\"hear_no_evil\" data-unicode-name=\"1F649\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-2764\" title=\"heart\" data-aliases=\"\" data-emoji=\"heart\" data-unicode-name=\"2764\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F60D\" title=\"heart_eyes\" data-aliases=\"\" data-emoji=\"heart_eyes\" data-unicode-name=\"1F60D\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F63B\" title=\"heart_eyes_cat\" data-aliases=\"\" data-emoji=\"heart_eyes_cat\" data-unicode-name=\"1F63B\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F493\" title=\"heartbeat\" data-aliases=\"\" data-emoji=\"heartbeat\" data-unicode-name=\"1F493\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F497\" title=\"heartpulse\" data-aliases=\"\" data-emoji=\"heartpulse\" data-unicode-name=\"1F497\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F460\" title=\"high_heel\" data-aliases=\"\" data-emoji=\"high_heel\" data-unicode-name=\"1F460\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F62F\" title=\"hushed\" data-aliases=\"\" data-emoji=\"hushed\" data-unicode-name=\"1F62F\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F47F\" title=\"imp\" data-aliases=\"\" data-emoji=\"imp\" data-unicode-name=\"1F47F\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F481\" title=\"information_desk_person\" data-aliases=\"\" data-emoji=\"information_desk_person\" data-unicode-name=\"1F481\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F607\" title=\"innocent\" data-aliases=\"\" data-emoji=\"innocent\" data-unicode-name=\"1F607\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F47A\" title=\"japanese_goblin\" data-aliases=\"\" data-emoji=\"japanese_goblin\" data-unicode-name=\"1F47A\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F479\" title=\"japanese_ogre\" data-aliases=\"\" data-emoji=\"japanese_ogre\" data-unicode-name=\"1F479\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F456\" title=\"jeans\" data-aliases=\"\" data-emoji=\"jeans\" data-unicode-name=\"1F456\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F602\" title=\"joy\" data-aliases=\"\" data-emoji=\"joy\" data-unicode-name=\"1F602\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F639\" title=\"joy_cat\" data-aliases=\"\" data-emoji=\"joy_cat\" data-unicode-name=\"1F639\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F458\" title=\"kimono\" data-aliases=\"\" data-emoji=\"kimono\" data-unicode-name=\"1F458\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F48B\" title=\"kiss\" data-aliases=\"\" data-emoji=\"kiss\" data-unicode-name=\"1F48B\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F617\" title=\"kissing\" data-aliases=\"\" data-emoji=\"kissing\" data-unicode-name=\"1F617\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F63D\" title=\"kissing_cat\" data-aliases=\"\" data-emoji=\"kissing_cat\" data-unicode-name=\"1F63D\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F61A\" title=\"kissing_closed_eyes\" data-aliases=\"\" data-emoji=\"kissing_closed_eyes\" data-unicode-name=\"1F61A\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F618\" title=\"kissing_heart\" data-aliases=\"\" data-emoji=\"kissing_heart\" data-unicode-name=\"1F618\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F619\" title=\"kissing_smiling_eyes\" data-aliases=\"\" data-emoji=\"kissing_smiling_eyes\" data-unicode-name=\"1F619\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F606\" title=\"laughing\" data-aliases=\":satisfied:\" data-emoji=\"laughing\" data-unicode-name=\"1F606\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F444\" title=\"lips\" data-aliases=\"\" data-emoji=\"lips\" data-unicode-name=\"1F444\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F484\" title=\"lipstick\" data-aliases=\"\" data-emoji=\"lipstick\" data-unicode-name=\"1F484\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F48C\" title=\"love_letter\" data-aliases=\"\" data-emoji=\"love_letter\" data-unicode-name=\"1F48C\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F468\" title=\"man\" data-aliases=\"\" data-emoji=\"man\" data-unicode-name=\"1F468\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F472\" title=\"man_with_gua_pi_mao\" data-aliases=\"\" data-emoji=\"man_with_gua_pi_mao\" data-unicode-name=\"1F472\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F473\" title=\"man_with_turban\" data-aliases=\"\" data-emoji=\"man_with_turban\" data-unicode-name=\"1F473\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F45E\" title=\"mans_shoe\" data-aliases=\"\" data-emoji=\"mans_shoe\" data-unicode-name=\"1F45E\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F637\" title=\"mask\" data-aliases=\"\" data-emoji=\"mask\" data-unicode-name=\"1F637\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F486\" title=\"massage\" data-aliases=\"\" data-emoji=\"massage\" data-unicode-name=\"1F486\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F4AA\" title=\"muscle\" data-aliases=\"\" data-emoji=\"muscle\" data-unicode-name=\"1F4AA\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F485\" title=\"nail_care\" data-aliases=\"\" data-emoji=\"nail_care\" data-unicode-name=\"1F485\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F454\" title=\"necktie\" data-aliases=\"\" data-emoji=\"necktie\" data-unicode-name=\"1F454\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F610\" title=\"neutral_face\" data-aliases=\"\" data-emoji=\"neutral_face\" data-unicode-name=\"1F610\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F645\" title=\"no_good\" data-aliases=\"\" data-emoji=\"no_good\" data-unicode-name=\"1F645\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F636\" title=\"no_mouth\" data-aliases=\"\" data-emoji=\"no_mouth\" data-unicode-name=\"1F636\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F443\" title=\"nose\" data-aliases=\"\" data-emoji=\"nose\" data-unicode-name=\"1F443\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F44C\" title=\"ok_hand\" data-aliases=\"\" data-emoji=\"ok_hand\" data-unicode-name=\"1F44C\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F646\" title=\"ok_woman\" data-aliases=\"\" data-emoji=\"ok_woman\" data-unicode-name=\"1F646\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F474\" title=\"older_man\" data-aliases=\"\" data-emoji=\"older_man\" data-unicode-name=\"1F474\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F475\" title=\"older_woman\" data-aliases=\":grandma:\" data-emoji=\"older_woman\" data-unicode-name=\"1F475\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F450\" title=\"open_hands\" data-aliases=\"\" data-emoji=\"open_hands\" data-unicode-name=\"1F450\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F62E\" title=\"open_mouth\" data-aliases=\"\" data-emoji=\"open_mouth\" data-unicode-name=\"1F62E\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F614\" title=\"pensive\" data-aliases=\"\" data-emoji=\"pensive\" data-unicode-name=\"1F614\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F623\" title=\"persevere\" data-aliases=\"\" data-emoji=\"persevere\" data-unicode-name=\"1F623\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F64D\" title=\"person_frowning\" data-aliases=\"\" data-emoji=\"person_frowning\" data-unicode-name=\"1F64D\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F471\" title=\"person_with_blond_hair\" data-aliases=\"\" data-emoji=\"person_with_blond_hair\" data-unicode-name=\"1F471\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F64E\" title=\"person_with_pouting_face\" data-aliases=\"\" data-emoji=\"person_with_pouting_face\" data-unicode-name=\"1F64E\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F447\" title=\"point_down\" data-aliases=\"\" data-emoji=\"point_down\" data-unicode-name=\"1F447\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F448\" title=\"point_left\" data-aliases=\"\" data-emoji=\"point_left\" data-unicode-name=\"1F448\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F449\" title=\"point_right\" data-aliases=\"\" data-emoji=\"point_right\" data-unicode-name=\"1F449\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-261D\" title=\"point_up\" data-aliases=\"\" data-emoji=\"point_up\" data-unicode-name=\"261D\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F446\" title=\"point_up_2\" data-aliases=\"\" data-emoji=\"point_up_2\" data-unicode-name=\"1F446\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F4A9\" title=\"poop\" data-aliases=\":shit: :hankey: :poo:\" data-emoji=\"poop\" data-unicode-name=\"1F4A9\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F45D\" title=\"pouch\" data-aliases=\"\" data-emoji=\"pouch\" data-unicode-name=\"1F45D\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F63E\" title=\"pouting_cat\" data-aliases=\"\" data-emoji=\"pouting_cat\" data-unicode-name=\"1F63E\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F64F\" title=\"pray\" data-aliases=\"\" data-emoji=\"pray\" data-unicode-name=\"1F64F\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F478\" title=\"princess\" data-aliases=\"\" data-emoji=\"princess\" data-unicode-name=\"1F478\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F44A\" title=\"punch\" data-aliases=\"\" data-emoji=\"punch\" data-unicode-name=\"1F44A\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F49C\" title=\"purple_heart\" data-aliases=\"\" data-emoji=\"purple_heart\" data-unicode-name=\"1F49C\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F45B\" title=\"purse\" data-aliases=\"\" data-emoji=\"purse\" data-unicode-name=\"1F45B\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F621\" title=\"rage\" data-aliases=\"\" data-emoji=\"rage\" data-unicode-name=\"1F621\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-270B\" title=\"raised_hand\" data-aliases=\"\" data-emoji=\"raised_hand\" data-unicode-name=\"270B\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F64C\" title=\"raised_hands\" data-aliases=\"\" data-emoji=\"raised_hands\" data-unicode-name=\"1F64C\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F64B\" title=\"raising_hand\" data-aliases=\"\" data-emoji=\"raising_hand\" data-unicode-name=\"1F64B\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-263A\" title=\"relaxed\" data-aliases=\"\" data-emoji=\"relaxed\" data-unicode-name=\"263A\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F60C\" title=\"relieved\" data-aliases=\"\" data-emoji=\"relieved\" data-unicode-name=\"1F60C\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F49E\" title=\"revolving_hearts\" data-aliases=\"\" data-emoji=\"revolving_hearts\" data-unicode-name=\"1F49E\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F380\" title=\"ribbon\" data-aliases=\"\" data-emoji=\"ribbon\" data-unicode-name=\"1F380\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F48D\" title=\"ring\" data-aliases=\"\" data-emoji=\"ring\" data-unicode-name=\"1F48D\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F3C3\" title=\"runner\" data-aliases=\"\" data-emoji=\"runner\" data-unicode-name=\"1F3C3\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F3BD\" title=\"running_shirt_with_sash\" data-aliases=\"\" data-emoji=\"running_shirt_with_sash\" data-unicode-name=\"1F3BD\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F461\" title=\"sandal\" data-aliases=\"\" data-emoji=\"sandal\" data-unicode-name=\"1F461\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F631\" title=\"scream\" data-aliases=\"\" data-emoji=\"scream\" data-unicode-name=\"1F631\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F640\" title=\"scream_cat\" data-aliases=\"\" data-emoji=\"scream_cat\" data-unicode-name=\"1F640\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F648\" title=\"see_no_evil\" data-aliases=\"\" data-emoji=\"see_no_evil\" data-unicode-name=\"1F648\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F455\" title=\"shirt\" data-aliases=\"\" data-emoji=\"shirt\" data-unicode-name=\"1F455\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F480\" title=\"skull\" data-aliases=\":skeleton:\" data-emoji=\"skull\" data-unicode-name=\"1F480\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F634\" title=\"sleeping\" data-aliases=\"\" data-emoji=\"sleeping\" data-unicode-name=\"1F634\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F62A\" title=\"sleepy\" data-aliases=\"\" data-emoji=\"sleepy\" data-unicode-name=\"1F62A\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F604\" title=\"smile\" data-aliases=\"\" data-emoji=\"smile\" data-unicode-name=\"1F604\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F638\" title=\"smile_cat\" data-aliases=\"\" data-emoji=\"smile_cat\" data-unicode-name=\"1F638\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F603\" title=\"smiley\" data-aliases=\"\" data-emoji=\"smiley\" data-unicode-name=\"1F603\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F63A\" title=\"smiley_cat\" data-aliases=\"\" data-emoji=\"smiley_cat\" data-unicode-name=\"1F63A\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F608\" title=\"smiling_imp\" data-aliases=\"\" data-emoji=\"smiling_imp\" data-unicode-name=\"1F608\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F60F\" title=\"smirk\" data-aliases=\"\" data-emoji=\"smirk\" data-unicode-name=\"1F60F\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F63C\" title=\"smirk_cat\" data-aliases=\"\" data-emoji=\"smirk_cat\" data-unicode-name=\"1F63C\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F62D\" title=\"sob\" data-aliases=\"\" data-emoji=\"sob\" data-unicode-name=\"1F62D\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-2728\" title=\"sparkles\" data-aliases=\"\" data-emoji=\"sparkles\" data-unicode-name=\"2728\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F496\" title=\"sparkling_heart\" data-aliases=\"\" data-emoji=\"sparkling_heart\" data-unicode-name=\"1F496\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F64A\" title=\"speak_no_evil\" data-aliases=\"\" data-emoji=\"speak_no_evil\" data-unicode-name=\"1F64A\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F4AC\" title=\"speech_balloon\" data-aliases=\"\" data-emoji=\"speech_balloon\" data-unicode-name=\"1F4AC\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F31F\" title=\"star2\" data-aliases=\"\" data-emoji=\"star2\" data-unicode-name=\"1F31F\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F61B\" title=\"stuck_out_tongue\" data-aliases=\"\" data-emoji=\"stuck_out_tongue\" data-unicode-name=\"1F61B\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F61D\" title=\"stuck_out_tongue_closed_eyes\" data-aliases=\"\" data-emoji=\"stuck_out_tongue_closed_eyes\" data-unicode-name=\"1F61D\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F61C\" title=\"stuck_out_tongue_winking_eye\" data-aliases=\"\" data-emoji=\"stuck_out_tongue_winking_eye\" data-unicode-name=\"1F61C\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F60E\" title=\"sunglasses\" data-aliases=\"\" data-emoji=\"sunglasses\" data-unicode-name=\"1F60E\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F613\" title=\"sweat\" data-aliases=\"\" data-emoji=\"sweat\" data-unicode-name=\"1F613\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F4A6\" title=\"sweat_drops\" data-aliases=\"\" data-emoji=\"sweat_drops\" data-unicode-name=\"1F4A6\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F605\" title=\"sweat_smile\" data-aliases=\"\" data-emoji=\"sweat_smile\" data-unicode-name=\"1F605\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F4AD\" title=\"thought_balloon\" data-aliases=\"\" data-emoji=\"thought_balloon\" data-unicode-name=\"1F4AD\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F44E\" title=\"thumbsdown\" data-aliases=\":-1:\" data-emoji=\"thumbsdown\" data-unicode-name=\"1F44E\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F44D\" title=\"thumbsup\" data-aliases=\":+1:\" data-emoji=\"thumbsup\" data-unicode-name=\"1F44D\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F62B\" title=\"tired_face\" data-aliases=\"\" data-emoji=\"tired_face\" data-unicode-name=\"1F62B\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F445\" title=\"tongue\" data-aliases=\"\" data-emoji=\"tongue\" data-unicode-name=\"1F445\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F3A9\" title=\"tophat\" data-aliases=\"\" data-emoji=\"tophat\" data-unicode-name=\"1F3A9\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F624\" title=\"triumph\" data-aliases=\"\" data-emoji=\"triumph\" data-unicode-name=\"1F624\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F495\" title=\"two_hearts\" data-aliases=\"\" data-emoji=\"two_hearts\" data-unicode-name=\"1F495\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F46C\" title=\"two_men_holding_hands\" data-aliases=\"\" data-emoji=\"two_men_holding_hands\" data-unicode-name=\"1F46C\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F46D\" title=\"two_women_holding_hands\" data-aliases=\"\" data-emoji=\"two_women_holding_hands\" data-unicode-name=\"1F46D\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F612\" title=\"unamused\" data-aliases=\"\" data-emoji=\"unamused\" data-unicode-name=\"1F612\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-270C\" title=\"v\" data-aliases=\"\" data-emoji=\"v\" data-unicode-name=\"270C\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F6B6\" title=\"walking\" data-aliases=\"\" data-emoji=\"walking\" data-unicode-name=\"1F6B6\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F44B\" title=\"wave\" data-aliases=\"\" data-emoji=\"wave\" data-unicode-name=\"1F44B\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F629\" title=\"weary\" data-aliases=\"\" data-emoji=\"weary\" data-unicode-name=\"1F629\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F609\" title=\"wink\" data-aliases=\"\" data-emoji=\"wink\" data-unicode-name=\"1F609\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F469\" title=\"woman\" data-aliases=\"\" data-emoji=\"woman\" data-unicode-name=\"1F469\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F45A\" title=\"womans_clothes\" data-aliases=\"\" data-emoji=\"womans_clothes\" data-unicode-name=\"1F45A\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F452\" title=\"womans_hat\" data-aliases=\"\" data-emoji=\"womans_hat\" data-unicode-name=\"1F452\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F61F\" title=\"worried\" data-aliases=\"\" data-emoji=\"worried\" data-unicode-name=\"1F61F\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F49B\" title=\"yellow_heart\" data-aliases=\"\" data-emoji=\"yellow_heart\" data-unicode-name=\"1F49B\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F60B\" title=\"yum\" data-aliases=\"\" data-emoji=\"yum\" data-unicode-name=\"1F60B\"></div>\n </button>\n </li>\n <li class='pull-left text-center emoji-menu-list-item'>\n <button class='emoji-menu-btn text-center js-emoji-btn' type='button'>\n <div class=\"icon emoji-icon emoji-1F4A4\" title=\"zzz\" data-aliases=\"\" data-emoji=\"zzz\" data-unicode-name=\"1F4A4\"></div>\n </button>\n </li>\n </ul>\n </div>\n</div>"; -}).call(window); diff --git a/spec/javascripts/fixtures/environments/metrics.html.haml b/spec/javascripts/fixtures/environments/metrics.html.haml new file mode 100644 index 00000000000..483063fb889 --- /dev/null +++ b/spec/javascripts/fixtures/environments/metrics.html.haml @@ -0,0 +1,12 @@ +%div + .top-area + .row + .col-sm-6 + %h3.page-title + Metrics for environment + .row + .col-sm-12 + %svg.prometheus-graph{ 'graph-type' => 'cpu_values' } + .row + .col-sm-12 + %svg.prometheus-graph{ 'graph-type' => 'memory_values' }
\ No newline at end of file diff --git a/spec/javascripts/fixtures/issues.rb b/spec/javascripts/fixtures/issues.rb index 06f708f9e15..88e3f860809 100644 --- a/spec/javascripts/fixtures/issues.rb +++ b/spec/javascripts/fixtures/issues.rb @@ -41,7 +41,7 @@ describe Projects::IssuesController, '(JavaScript fixtures)', type: :controller def render_issue(fixture_file_name, issue) get :show, namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, id: issue.to_param expect(response).to be_success diff --git a/spec/javascripts/fixtures/merge_requests.rb b/spec/javascripts/fixtures/merge_requests.rb index 62984097099..ee893b76c84 100644 --- a/spec/javascripts/fixtures/merge_requests.rb +++ b/spec/javascripts/fixtures/merge_requests.rb @@ -27,7 +27,7 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont def render_merge_request(fixture_file_name, merge_request) get :show, namespace_id: project.namespace.to_param, - project_id: project.to_param, + project_id: project, id: merge_request.to_param expect(response).to be_success diff --git a/spec/javascripts/fixtures/project_branches.json b/spec/javascripts/fixtures/project_branches.json new file mode 100644 index 00000000000..a96a4c0c095 --- /dev/null +++ b/spec/javascripts/fixtures/project_branches.json @@ -0,0 +1,5 @@ +[ + "master", + "development", + "staging" +] diff --git a/spec/javascripts/fixtures/projects.json b/spec/javascripts/fixtures/projects.json index 4ce7f5c601a..1339ee00870 100644 --- a/spec/javascripts/fixtures/projects.json +++ b/spec/javascripts/fixtures/projects.json @@ -43,7 +43,7 @@ "avatar_url": null, "star_count": 0, "forks_count": 0, - "only_allow_merge_if_build_succeeds": false, + "only_allow_merge_if_pipeline_succeeds": false, "open_issues_count": 0, "permissions": { "project_access": null, @@ -88,7 +88,7 @@ "avatar_url": null, "star_count": 0, "forks_count": 0, - "only_allow_merge_if_build_succeeds": false, + "only_allow_merge_if_pipeline_succeeds": false, "open_issues_count": 5, "permissions": { "project_access": { @@ -139,7 +139,7 @@ "avatar_url": null, "star_count": 0, "forks_count": 0, - "only_allow_merge_if_build_succeeds": true, + "only_allow_merge_if_pipeline_succeeds": true, "open_issues_count": 4, "permissions": { "project_access": null, @@ -187,7 +187,7 @@ "avatar_url": null, "star_count": 0, "forks_count": 0, - "only_allow_merge_if_build_succeeds": true, + "only_allow_merge_if_pipeline_succeeds": true, "open_issues_count": 4, "permissions": { "project_access": null, @@ -235,7 +235,7 @@ "avatar_url": null, "star_count": 0, "forks_count": 0, - "only_allow_merge_if_build_succeeds": false, + "only_allow_merge_if_pipeline_succeeds": false, "open_issues_count": 5, "permissions": { "project_access": null, @@ -283,7 +283,7 @@ "avatar_url": null, "star_count": 0, "forks_count": 0, - "only_allow_merge_if_build_succeeds": false, + "only_allow_merge_if_pipeline_succeeds": false, "open_issues_count": 5, "permissions": { "project_access": { @@ -334,7 +334,7 @@ "avatar_url": null, "star_count": 0, "forks_count": 0, - "only_allow_merge_if_build_succeeds": false, + "only_allow_merge_if_pipeline_succeeds": false, "open_issues_count": 3, "permissions": { "project_access": null, @@ -382,7 +382,7 @@ "avatar_url": null, "star_count": 0, "forks_count": 0, - "only_allow_merge_if_build_succeeds": false, + "only_allow_merge_if_pipeline_succeeds": false, "open_issues_count": 5, "permissions": { "project_access": { @@ -433,7 +433,7 @@ "avatar_url": null, "star_count": 0, "forks_count": 0, - "only_allow_merge_if_build_succeeds": false, + "only_allow_merge_if_pipeline_succeeds": false, "open_issues_count": 5, "permissions": { "project_access": null, diff --git a/spec/javascripts/fixtures/projects.rb b/spec/javascripts/fixtures/projects.rb index 56513219e1e..6c33b240e5c 100644 --- a/spec/javascripts/fixtures/projects.rb +++ b/spec/javascripts/fixtures/projects.rb @@ -20,7 +20,7 @@ describe ProjectsController, '(JavaScript fixtures)', type: :controller do it 'projects/dashboard.html.raw' do |example| get :show, namespace_id: project.namespace.to_param, - id: project.to_param + id: project expect(response).to be_success store_frontend_fixture(response, example.description) diff --git a/spec/javascripts/fixtures/target_branch_dropdown.html.haml b/spec/javascripts/fixtures/target_branch_dropdown.html.haml new file mode 100644 index 00000000000..821fb7940a0 --- /dev/null +++ b/spec/javascripts/fixtures/target_branch_dropdown.html.haml @@ -0,0 +1,28 @@ +%form.js-edit-blob-form + %input{type: 'hidden', name: 'target_branch', value: 'master'} + %div + .dropdown + %button.dropdown-menu-toggle.js-project-branches-dropdown.js-target-branch{type: 'button', data: {toggle: 'dropdown', selected: 'master', field_name: 'target_branch', form_id: '.js-edit-blob-form'}} + .dropdown-menu.dropdown-menu-selectable.dropdown-menu-paging + .dropdown-page-one + .dropdown-title 'Select branch' + .dropdown-input + %input.dropdown-input-field{type: 'search', value: ''} + %i.fa.fa-search.dropdown-input-search + %i.fa.fa-times-dropdown-input-clear.js-dropdown-input-clear{role: 'button'} + .dropdown-content + .dropdown-footer + %ul.dropdown-footer-list + %li + %a.create-new-branch.dropdown-toggle-page{href: "#"} + Create new branch + .dropdown-page-two.dropdown-new-branch + %button.dropdown-title-button.dropdown-menu-back{type: 'button'} + .dropdown_title 'Create new branch' + .dropdown_content + %input#new_branch_name.default-dropdown-input{ type: "text", placeholder: "Name new branch" } + %button.btn.btn-primary.pull-left.js-new-branch-btn{ type: "button" } + Create + %button.btn.btn-default.pull-right.js-cancel-branch-btn{ type: "button" } + Cancel + %button{type: 'submit'} diff --git a/spec/javascripts/fixtures/todos.rb b/spec/javascripts/fixtures/todos.rb index 2c08b06ea9e..a81ef8c5492 100644 --- a/spec/javascripts/fixtures/todos.rb +++ b/spec/javascripts/fixtures/todos.rb @@ -39,8 +39,8 @@ describe 'Todos (JavaScript fixtures)' do it 'todos/todos.json' do |example| post :create, - namespace_id: namespace.path, - project_id: project.path, + namespace_id: namespace, + project_id: project, issuable_type: 'issue', issuable_id: issue_2.id, format: 'json' diff --git a/spec/javascripts/fixtures/user_callout.html.haml b/spec/javascripts/fixtures/user_callout.html.haml new file mode 100644 index 00000000000..275359bde0a --- /dev/null +++ b/spec/javascripts/fixtures/user_callout.html.haml @@ -0,0 +1,2 @@ +.user-callout{ 'callout-svg' => custom_icon('icon_customization') } + diff --git a/spec/javascripts/gfm_auto_complete_spec.js.es6 b/spec/javascripts/gfm_auto_complete_spec.js index 5dfa4008fbd..5dfa4008fbd 100644 --- a/spec/javascripts/gfm_auto_complete_spec.js.es6 +++ b/spec/javascripts/gfm_auto_complete_spec.js diff --git a/spec/javascripts/gl_dropdown_spec.js.es6 b/spec/javascripts/gl_dropdown_spec.js index c207fb00a47..c207fb00a47 100644 --- a/spec/javascripts/gl_dropdown_spec.js.es6 +++ b/spec/javascripts/gl_dropdown_spec.js diff --git a/spec/javascripts/gl_emoji_spec.js b/spec/javascripts/gl_emoji_spec.js new file mode 100644 index 00000000000..b2b46640e5b --- /dev/null +++ b/spec/javascripts/gl_emoji_spec.js @@ -0,0 +1,399 @@ +import { glEmojiTag } from '~/behaviors/gl_emoji'; +import { + isEmojiUnicodeSupported, + isFlagEmoji, + isKeycapEmoji, + isSkinToneComboEmoji, + isHorceRacingSkinToneComboEmoji, + isPersonZwjEmoji, +} from '~/behaviors/gl_emoji/is_emoji_unicode_supported'; + +const emptySupportMap = { + personZwj: false, + horseRacing: false, + flag: false, + skinToneModifier: false, + '9.0': false, + '8.0': false, + '7.0': false, + 6.1: false, + '6.0': false, + 5.2: false, + 5.1: false, + 4.1: false, + '4.0': false, + 3.2: false, + '3.0': false, + 1.1: false, +}; + +const emojiFixtureMap = { + bomb: { + name: 'bomb', + moji: '💣', + unicodeVersion: '6.0', + }, + construction_worker_tone5: { + name: 'construction_worker_tone5', + moji: '👷🏿', + unicodeVersion: '8.0', + }, + five: { + name: 'five', + moji: '5️⃣', + unicodeVersion: '3.0', + }, + grey_question: { + name: 'grey_question', + moji: '❔', + unicodeVersion: '6.0', + }, +}; + +function markupToDomElement(markup) { + const div = document.createElement('div'); + div.innerHTML = markup; + return div.firstElementChild; +} + +function testGlEmojiImageFallback(element, name, src) { + expect(element.tagName.toLowerCase()).toBe('img'); + expect(element.getAttribute('src')).toBe(src); + expect(element.getAttribute('title')).toBe(`:${name}:`); + expect(element.getAttribute('alt')).toBe(`:${name}:`); +} + +const defaults = { + forceFallback: false, + sprite: false, +}; + +function testGlEmojiElement(element, name, unicodeVersion, unicodeMoji, options = {}) { + const opts = Object.assign({}, defaults, options); + expect(element.tagName.toLowerCase()).toBe('gl-emoji'); + expect(element.dataset.name).toBe(name); + expect(element.dataset.fallbackSrc.length).toBeGreaterThan(0); + expect(element.dataset.unicodeVersion).toBe(unicodeVersion); + + const fallbackSpriteClass = `emoji-${name}`; + if (opts.sprite) { + expect(element.dataset.fallbackSpriteClass).toBe(fallbackSpriteClass); + } + + if (opts.forceFallback && opts.sprite) { + expect(element.getAttribute('class')).toBe(`emoji-icon ${fallbackSpriteClass}`); + } + + if (opts.forceFallback && !opts.sprite) { + // Check for image fallback + testGlEmojiImageFallback(element.firstElementChild, name, element.dataset.fallbackSrc); + } else { + // Otherwise make sure things are still unicode text + expect(element.textContent.trim()).toBe(unicodeMoji); + } +} + +describe('gl_emoji', () => { + describe('glEmojiTag', () => { + it('bomb emoji', () => { + const emojiKey = 'bomb'; + const markup = glEmojiTag(emojiFixtureMap[emojiKey].name); + const glEmojiElement = markupToDomElement(markup); + testGlEmojiElement( + glEmojiElement, + emojiFixtureMap[emojiKey].name, + emojiFixtureMap[emojiKey].unicodeVersion, + emojiFixtureMap[emojiKey].moji, + ); + }); + + it('bomb emoji with image fallback', () => { + const emojiKey = 'bomb'; + const markup = glEmojiTag(emojiFixtureMap[emojiKey].name, { + forceFallback: true, + }); + const glEmojiElement = markupToDomElement(markup); + testGlEmojiElement( + glEmojiElement, + emojiFixtureMap[emojiKey].name, + emojiFixtureMap[emojiKey].unicodeVersion, + emojiFixtureMap[emojiKey].moji, + { + forceFallback: true, + }, + ); + }); + + it('bomb emoji with sprite fallback readiness', () => { + const emojiKey = 'bomb'; + const markup = glEmojiTag(emojiFixtureMap[emojiKey].name, { + sprite: true, + }); + const glEmojiElement = markupToDomElement(markup); + testGlEmojiElement( + glEmojiElement, + emojiFixtureMap[emojiKey].name, + emojiFixtureMap[emojiKey].unicodeVersion, + emojiFixtureMap[emojiKey].moji, + { + sprite: true, + }, + ); + }); + it('bomb emoji with sprite fallback', () => { + const emojiKey = 'bomb'; + const markup = glEmojiTag(emojiFixtureMap[emojiKey].name, { + forceFallback: true, + sprite: true, + }); + const glEmojiElement = markupToDomElement(markup); + testGlEmojiElement( + glEmojiElement, + emojiFixtureMap[emojiKey].name, + emojiFixtureMap[emojiKey].unicodeVersion, + emojiFixtureMap[emojiKey].moji, + { + forceFallback: true, + sprite: true, + }, + ); + }); + + it('question mark when invalid emoji name given', () => { + const name = 'invalid_emoji'; + const emojiKey = 'grey_question'; + const markup = glEmojiTag(name); + const glEmojiElement = markupToDomElement(markup); + testGlEmojiElement( + glEmojiElement, + emojiFixtureMap[emojiKey].name, + emojiFixtureMap[emojiKey].unicodeVersion, + emojiFixtureMap[emojiKey].moji, + ); + }); + + it('question mark with image fallback when invalid emoji name given', () => { + const name = 'invalid_emoji'; + const emojiKey = 'grey_question'; + const markup = glEmojiTag(name, { + forceFallback: true, + }); + const glEmojiElement = markupToDomElement(markup); + testGlEmojiElement( + glEmojiElement, + emojiFixtureMap[emojiKey].name, + emojiFixtureMap[emojiKey].unicodeVersion, + emojiFixtureMap[emojiKey].moji, + { + forceFallback: true, + }, + ); + }); + }); + + describe('isFlagEmoji', () => { + it('should detect flag_ac', () => { + expect(isFlagEmoji('🇦🇨')).toBeTruthy(); + }); + it('should detect flag_us', () => { + expect(isFlagEmoji('🇺🇸')).toBeTruthy(); + }); + it('should detect flag_zw', () => { + expect(isFlagEmoji('🇿🇼')).toBeTruthy(); + }); + it('should not detect flags', () => { + expect(isFlagEmoji('🎏')).toBeFalsy(); + }); + it('should not detect triangular_flag_on_post', () => { + expect(isFlagEmoji('🚩')).toBeFalsy(); + }); + it('should not detect single letter', () => { + expect(isFlagEmoji('🇦')).toBeFalsy(); + }); + it('should not detect >2 letters', () => { + expect(isFlagEmoji('🇦🇧🇨')).toBeFalsy(); + }); + }); + + describe('isKeycapEmoji', () => { + it('should detect one(keycap)', () => { + expect(isKeycapEmoji('1️⃣')).toBeTruthy(); + }); + it('should detect nine(keycap)', () => { + expect(isKeycapEmoji('9️⃣')).toBeTruthy(); + }); + it('should not detect ten(keycap)', () => { + expect(isKeycapEmoji('🔟')).toBeFalsy(); + }); + it('should not detect hash(keycap)', () => { + expect(isKeycapEmoji('#⃣')).toBeFalsy(); + }); + }); + + describe('isSkinToneComboEmoji', () => { + it('should detect hand_splayed_tone5', () => { + expect(isSkinToneComboEmoji('🖐🏿')).toBeTruthy(); + }); + it('should not detect hand_splayed', () => { + expect(isSkinToneComboEmoji('🖐')).toBeFalsy(); + }); + it('should detect lifter_tone1', () => { + expect(isSkinToneComboEmoji('🏋🏻')).toBeTruthy(); + }); + it('should not detect lifter', () => { + expect(isSkinToneComboEmoji('🏋')).toBeFalsy(); + }); + it('should detect rowboat_tone4', () => { + expect(isSkinToneComboEmoji('🚣🏾')).toBeTruthy(); + }); + it('should not detect rowboat', () => { + expect(isSkinToneComboEmoji('🚣')).toBeFalsy(); + }); + it('should not detect individual tone emoji', () => { + expect(isSkinToneComboEmoji('🏻')).toBeFalsy(); + }); + }); + + describe('isHorceRacingSkinToneComboEmoji', () => { + it('should detect horse_racing_tone2', () => { + expect(isHorceRacingSkinToneComboEmoji('🏇🏼')).toBeTruthy(); + }); + it('should not detect horse_racing', () => { + expect(isHorceRacingSkinToneComboEmoji('🏇')).toBeFalsy(); + }); + }); + + describe('isPersonZwjEmoji', () => { + it('should detect couple_mm', () => { + expect(isPersonZwjEmoji('👨❤️👨')).toBeTruthy(); + }); + it('should not detect couple_with_heart', () => { + expect(isPersonZwjEmoji('💑')).toBeFalsy(); + }); + it('should not detect couplekiss', () => { + expect(isPersonZwjEmoji('💏')).toBeFalsy(); + }); + it('should detect family_mmb', () => { + expect(isPersonZwjEmoji('👨👨👦')).toBeTruthy(); + }); + it('should detect family_mwgb', () => { + expect(isPersonZwjEmoji('👨👩👧👦')).toBeTruthy(); + }); + it('should not detect family', () => { + expect(isPersonZwjEmoji('👪')).toBeFalsy(); + }); + it('should detect kiss_ww', () => { + expect(isPersonZwjEmoji('👩❤️💋👩')).toBeTruthy(); + }); + it('should not detect girl', () => { + expect(isPersonZwjEmoji('👧')).toBeFalsy(); + }); + it('should not detect girl_tone5', () => { + expect(isPersonZwjEmoji('👧🏿')).toBeFalsy(); + }); + it('should not detect man', () => { + expect(isPersonZwjEmoji('👨')).toBeFalsy(); + }); + it('should not detect woman', () => { + expect(isPersonZwjEmoji('👩')).toBeFalsy(); + }); + }); + + describe('isEmojiUnicodeSupported', () => { + it('bomb(6.0) with 6.0 support', () => { + const emojiKey = 'bomb'; + const unicodeSupportMap = Object.assign({}, emptySupportMap, { + '6.0': true, + }); + const isSupported = isEmojiUnicodeSupported( + unicodeSupportMap, + emojiFixtureMap[emojiKey].moji, + emojiFixtureMap[emojiKey].unicodeVersion, + ); + expect(isSupported).toBeTruthy(); + }); + + it('bomb(6.0) without 6.0 support', () => { + const emojiKey = 'bomb'; + const unicodeSupportMap = emptySupportMap; + const isSupported = isEmojiUnicodeSupported( + unicodeSupportMap, + emojiFixtureMap[emojiKey].moji, + emojiFixtureMap[emojiKey].unicodeVersion, + ); + expect(isSupported).toBeFalsy(); + }); + + it('bomb(6.0) without 6.0 but with 9.0 support', () => { + const emojiKey = 'bomb'; + const unicodeSupportMap = Object.assign({}, emptySupportMap, { + '9.0': true, + }); + const isSupported = isEmojiUnicodeSupported( + unicodeSupportMap, + emojiFixtureMap[emojiKey].moji, + emojiFixtureMap[emojiKey].unicodeVersion, + ); + expect(isSupported).toBeFalsy(); + }); + + it('construction_worker_tone5(8.0) without skin tone modifier support', () => { + const emojiKey = 'construction_worker_tone5'; + const unicodeSupportMap = Object.assign({}, emptySupportMap, { + skinToneModifier: false, + '9.0': true, + '8.0': true, + '7.0': true, + 6.1: true, + '6.0': true, + 5.2: true, + 5.1: true, + 4.1: true, + '4.0': true, + 3.2: true, + '3.0': true, + 1.1: true, + }); + const isSupported = isEmojiUnicodeSupported( + unicodeSupportMap, + emojiFixtureMap[emojiKey].moji, + emojiFixtureMap[emojiKey].unicodeVersion, + ); + expect(isSupported).toBeFalsy(); + }); + + it('use native keycap on >=57 chrome', () => { + const emojiKey = 'five'; + const unicodeSupportMap = Object.assign({}, emptySupportMap, { + '3.0': true, + meta: { + isChrome: true, + chromeVersion: 57, + }, + }); + const isSupported = isEmojiUnicodeSupported( + unicodeSupportMap, + emojiFixtureMap[emojiKey].moji, + emojiFixtureMap[emojiKey].unicodeVersion, + ); + expect(isSupported).toBeTruthy(); + }); + + it('fallback keycap on <57 chrome', () => { + const emojiKey = 'five'; + const unicodeSupportMap = Object.assign({}, emptySupportMap, { + '3.0': true, + meta: { + isChrome: true, + chromeVersion: 50, + }, + }); + const isSupported = isEmojiUnicodeSupported( + unicodeSupportMap, + emojiFixtureMap[emojiKey].moji, + emojiFixtureMap[emojiKey].unicodeVersion, + ); + expect(isSupported).toBeFalsy(); + }); + }); +}); diff --git a/spec/javascripts/gl_field_errors_spec.js.es6 b/spec/javascripts/gl_field_errors_spec.js index 733023481f5..733023481f5 100644 --- a/spec/javascripts/gl_field_errors_spec.js.es6 +++ b/spec/javascripts/gl_field_errors_spec.js diff --git a/spec/javascripts/gl_form_spec.js.es6 b/spec/javascripts/gl_form_spec.js index 71d6e2a7e22..71d6e2a7e22 100644 --- a/spec/javascripts/gl_form_spec.js.es6 +++ b/spec/javascripts/gl_form_spec.js diff --git a/spec/javascripts/graphs/stat_graph_contributors_graph_spec.js b/spec/javascripts/graphs/stat_graph_contributors_graph_spec.js index a954bb60560..861f26e162f 100644 --- a/spec/javascripts/graphs/stat_graph_contributors_graph_spec.js +++ b/spec/javascripts/graphs/stat_graph_contributors_graph_spec.js @@ -1,9 +1,7 @@ -/* eslint-disable quotes, jasmine/no-suite-dupes, vars-on-top, no-var, max-len */ -/* global d3 */ -/* global ContributorsGraph */ -/* global ContributorsMasterGraph */ +/* eslint-disable quotes, jasmine/no-suite-dupes, vars-on-top, no-var */ -require('~/graphs/stat_graph_contributors_graph'); +import d3 from 'd3'; +import { ContributorsGraph, ContributorsMasterGraph } from '~/graphs/stat_graph_contributors_graph'; describe("ContributorsGraph", function () { describe("#set_x_domain", function () { diff --git a/spec/javascripts/graphs/stat_graph_contributors_util_spec.js b/spec/javascripts/graphs/stat_graph_contributors_util_spec.js index b15764abe8c..9b47ab62181 100644 --- a/spec/javascripts/graphs/stat_graph_contributors_util_spec.js +++ b/spec/javascripts/graphs/stat_graph_contributors_util_spec.js @@ -1,7 +1,6 @@ /* eslint-disable quotes, no-var, camelcase, object-property-newline, comma-dangle, max-len, vars-on-top, quote-props */ -/* global ContributorsStatGraphUtil */ -require('~/graphs/stat_graph_contributors_util'); +import ContributorsStatGraphUtil from '~/graphs/stat_graph_contributors_util'; describe("ContributorsStatGraphUtil", function () { describe("#parse_log", function () { diff --git a/spec/javascripts/graphs/stat_graph_spec.js b/spec/javascripts/graphs/stat_graph_spec.js deleted file mode 100644 index 876c23361bc..00000000000 --- a/spec/javascripts/graphs/stat_graph_spec.js +++ /dev/null @@ -1,20 +0,0 @@ -/* eslint-disable quotes */ -/* global StatGraph */ - -require('~/graphs/stat_graph'); - -describe("StatGraph", function () { - describe("#get_log", function () { - it("returns log", function () { - StatGraph.log = "test"; - expect(StatGraph.get_log()).toBe("test"); - }); - }); - - describe("#set_log", function () { - it("sets the log", function () { - StatGraph.set_log("test"); - expect(StatGraph.log).toBe("test"); - }); - }); -}); diff --git a/spec/javascripts/helpers/class_spec_helper.js.es6 b/spec/javascripts/helpers/class_spec_helper.js index d3c37d39431..61db27a8fcc 100644 --- a/spec/javascripts/helpers/class_spec_helper.js.es6 +++ b/spec/javascripts/helpers/class_spec_helper.js @@ -7,3 +7,5 @@ class ClassSpecHelper { } window.ClassSpecHelper = ClassSpecHelper; + +module.exports = ClassSpecHelper; diff --git a/spec/javascripts/helpers/class_spec_helper_spec.js.es6 b/spec/javascripts/helpers/class_spec_helper_spec.js index 0a61e561640..0a61e561640 100644 --- a/spec/javascripts/helpers/class_spec_helper_spec.js.es6 +++ b/spec/javascripts/helpers/class_spec_helper_spec.js diff --git a/spec/javascripts/helpers/filtered_search_spec_helper.js b/spec/javascripts/helpers/filtered_search_spec_helper.js new file mode 100644 index 00000000000..ce83a256ddd --- /dev/null +++ b/spec/javascripts/helpers/filtered_search_spec_helper.js @@ -0,0 +1,52 @@ +class FilteredSearchSpecHelper { + static createFilterVisualTokenHTML(name, value, isSelected) { + return FilteredSearchSpecHelper.createFilterVisualToken(name, value, isSelected).outerHTML; + } + + static createFilterVisualToken(name, value, isSelected = false) { + const li = document.createElement('li'); + li.classList.add('js-visual-token', 'filtered-search-token'); + + li.innerHTML = ` + <div class="selectable ${isSelected ? 'selected' : ''}" role="button"> + <div class="name">${name}</div> + <div class="value">${value}</div> + </div> + `; + + return li; + } + + static createNameFilterVisualTokenHTML(name) { + return ` + <li class="js-visual-token filtered-search-token"> + <div class="name">${name}</div> + </li> + `; + } + + static createSearchVisualTokenHTML(name) { + return ` + <li class="js-visual-token filtered-search-term"> + <div class="name">${name}</div> + </li> + `; + } + + static createInputHTML(placeholder = '', value = '') { + return ` + <li class="input-token"> + <input type='text' class='filtered-search' placeholder='${placeholder}' value='${value}'/> + </li> + `; + } + + static createTokensContainerHTML(html, inputPlaceholder) { + return ` + ${html} + ${FilteredSearchSpecHelper.createInputHTML(inputPlaceholder)} + `; + } +} + +module.exports = FilteredSearchSpecHelper; diff --git a/spec/javascripts/issuable_spec.js.es6 b/spec/javascripts/issuable_spec.js index 26d87cc5931..26d87cc5931 100644 --- a/spec/javascripts/issuable_spec.js.es6 +++ b/spec/javascripts/issuable_spec.js diff --git a/spec/javascripts/issuable_time_tracker_spec.js.es6 b/spec/javascripts/issuable_time_tracker_spec.js index cb068a4f879..cb068a4f879 100644 --- a/spec/javascripts/issuable_time_tracker_spec.js.es6 +++ b/spec/javascripts/issuable_time_tracker_spec.js diff --git a/spec/javascripts/issue_spec.js b/spec/javascripts/issue_spec.js index e7530f61385..aabc8bea12f 100644 --- a/spec/javascripts/issue_spec.js +++ b/spec/javascripts/issue_spec.js @@ -1,10 +1,9 @@ /* eslint-disable space-before-function-paren, no-var, one-var, one-var-declaration-per-line, no-use-before-define, comma-dangle, max-len */ -/* global Issue */ +import Issue from '~/issue'; require('~/lib/utils/text_utility'); -require('~/issue'); -(function() { +describe('Issue', function() { var INVALID_URL = 'http://goesnowhere.nothing/whereami'; var $boxClosed, $boxOpen, $btnClose, $btnReopen; @@ -59,28 +58,26 @@ require('~/issue'); expect($btnReopen).toHaveText('Reopen issue'); } - describe('Issue', function() { - describe('task lists', function() { - beforeEach(function() { - loadFixtures('issues/issue-with-task-list.html.raw'); - this.issue = new Issue(); - }); - - it('modifies the Markdown field', function() { - spyOn(jQuery, 'ajax').and.stub(); - $('input[type=checkbox]').attr('checked', true).trigger('change'); - expect($('.js-task-list-field').val()).toBe('- [x] Task List Item'); - }); + describe('task lists', function() { + beforeEach(function() { + loadFixtures('issues/issue-with-task-list.html.raw'); + this.issue = new Issue(); + }); - it('submits an ajax request on tasklist:changed', function() { - spyOn(jQuery, 'ajax').and.callFake(function(req) { - expect(req.type).toBe('PATCH'); - expect(req.url).toBe(gl.TEST_HOST + '/frontend-fixtures/issues-project/issues/1.json'); // eslint-disable-line prefer-template - expect(req.data.issue.description).not.toBe(null); - }); + it('modifies the Markdown field', function() { + spyOn(jQuery, 'ajax').and.stub(); + $('input[type=checkbox]').attr('checked', true).trigger('change'); + expect($('.js-task-list-field').val()).toBe('- [x] Task List Item'); + }); - $('.js-task-list-field').trigger('tasklist:changed'); + it('submits an ajax request on tasklist:changed', function() { + spyOn(jQuery, 'ajax').and.callFake(function(req) { + expect(req.type).toBe('PATCH'); + expect(req.url).toBe(gl.TEST_HOST + '/frontend-fixtures/issues-project/issues/1.json'); // eslint-disable-line prefer-template + expect(req.data.issue.description).not.toBe(null); }); + + $('.js-task-list-field').trigger('tasklist:changed'); }); }); @@ -139,6 +136,21 @@ require('~/issue'); expectErrorMessage(); expect($('.issue_counter')).toHaveText(1); }); + + it('updates counter', () => { + spyOn(jQuery, 'ajax').and.callFake(function(req) { + expectPendingRequest(req, $btnClose); + req.success({ + id: 34 + }); + }); + + expect($('.issue_counter')).toHaveText(1); + $('.issue_counter').text('1,001'); + expect($('.issue_counter').text()).toEqual('1,001'); + $btnClose.trigger('click'); + expect($('.issue_counter').text()).toEqual('1,000'); + }); }); describe('reopen issue', function() { @@ -165,4 +177,4 @@ require('~/issue'); expect($('.issue_counter')).toHaveText(1); }); }); -}).call(window); +}); diff --git a/spec/javascripts/labels_issue_sidebar_spec.js.es6 b/spec/javascripts/labels_issue_sidebar_spec.js index 37e038c16da..37e038c16da 100644 --- a/spec/javascripts/labels_issue_sidebar_spec.js.es6 +++ b/spec/javascripts/labels_issue_sidebar_spec.js diff --git a/spec/javascripts/lib/utils/common_utils_spec.js.es6 b/spec/javascripts/lib/utils/common_utils_spec.js index f4d3e77e515..f4d3e77e515 100644 --- a/spec/javascripts/lib/utils/common_utils_spec.js.es6 +++ b/spec/javascripts/lib/utils/common_utils_spec.js diff --git a/spec/javascripts/lib/utils/text_utility_spec.js b/spec/javascripts/lib/utils/text_utility_spec.js new file mode 100644 index 00000000000..4200e943121 --- /dev/null +++ b/spec/javascripts/lib/utils/text_utility_spec.js @@ -0,0 +1,110 @@ +require('~/lib/utils/text_utility'); + +(() => { + describe('text_utility', () => { + describe('gl.text.getTextWidth', () => { + it('returns zero width when no text is passed', () => { + expect(gl.text.getTextWidth('')).toBe(0); + }); + + it('returns zero width when no text is passed and font is passed', () => { + expect(gl.text.getTextWidth('', '100px sans-serif')).toBe(0); + }); + + it('returns width when text is passed', () => { + expect(gl.text.getTextWidth('foo') > 0).toBe(true); + }); + + it('returns bigger width when font is larger', () => { + const largeFont = gl.text.getTextWidth('foo', '100px sans-serif'); + const regular = gl.text.getTextWidth('foo', '10px sans-serif'); + expect(largeFont > regular).toBe(true); + }); + }); + + describe('gl.text.pluralize', () => { + it('returns pluralized', () => { + expect(gl.text.pluralize('test', 2)).toBe('tests'); + }); + + it('returns pluralized when count is 0', () => { + expect(gl.text.pluralize('test', 0)).toBe('tests'); + }); + + it('does not return pluralized', () => { + expect(gl.text.pluralize('test', 1)).toBe('test'); + }); + }); + + describe('gl.text.highCountTrim', () => { + it('returns 99+ for count >= 100', () => { + expect(gl.text.highCountTrim(105)).toBe('99+'); + expect(gl.text.highCountTrim(100)).toBe('99+'); + }); + + it('returns exact number for count < 100', () => { + expect(gl.text.highCountTrim(45)).toBe(45); + }); + }); + + describe('gl.text.insertText', () => { + let textArea; + + beforeAll(() => { + textArea = document.createElement('textarea'); + document.querySelector('body').appendChild(textArea); + }); + + afterAll(() => { + textArea.parentNode.removeChild(textArea); + }); + + describe('without selection', () => { + it('inserts the tag on an empty line', () => { + const initialValue = ''; + + textArea.value = initialValue; + textArea.selectionStart = 0; + textArea.selectionEnd = 0; + + gl.text.insertText(textArea, textArea.value, '*', null, '', false); + + expect(textArea.value).toEqual(`${initialValue}* `); + }); + + it('inserts the tag on a new line if the current one is not empty', () => { + const initialValue = 'some text'; + + textArea.value = initialValue; + textArea.setSelectionRange(initialValue.length, initialValue.length); + + gl.text.insertText(textArea, textArea.value, '*', null, '', false); + + expect(textArea.value).toEqual(`${initialValue}\n* `); + }); + + it('inserts the tag on the same line if the current line only contains spaces', () => { + const initialValue = ' '; + + textArea.value = initialValue; + textArea.setSelectionRange(initialValue.length, initialValue.length); + + gl.text.insertText(textArea, textArea.value, '*', null, '', false); + + expect(textArea.value).toEqual(`${initialValue}* `); + }); + + it('inserts the tag on the same line if the current line only contains tabs', () => { + const initialValue = '\t\t\t'; + + textArea.value = initialValue; + textArea.setSelectionRange(initialValue.length, initialValue.length); + + gl.text.insertText(textArea, textArea.value, '*', null, '', false); + + expect(textArea.value).toEqual(`${initialValue}* `); + }); + }); + }); + }); +})(); diff --git a/spec/javascripts/lib/utils/text_utility_spec.js.es6 b/spec/javascripts/lib/utils/text_utility_spec.js.es6 deleted file mode 100644 index 06b69b8ac17..00000000000 --- a/spec/javascripts/lib/utils/text_utility_spec.js.es6 +++ /dev/null @@ -1,50 +0,0 @@ -require('~/lib/utils/text_utility'); - -(() => { - describe('text_utility', () => { - describe('gl.text.getTextWidth', () => { - it('returns zero width when no text is passed', () => { - expect(gl.text.getTextWidth('')).toBe(0); - }); - - it('returns zero width when no text is passed and font is passed', () => { - expect(gl.text.getTextWidth('', '100px sans-serif')).toBe(0); - }); - - it('returns width when text is passed', () => { - expect(gl.text.getTextWidth('foo') > 0).toBe(true); - }); - - it('returns bigger width when font is larger', () => { - const largeFont = gl.text.getTextWidth('foo', '100px sans-serif'); - const regular = gl.text.getTextWidth('foo', '10px sans-serif'); - expect(largeFont > regular).toBe(true); - }); - }); - - describe('gl.text.pluralize', () => { - it('returns pluralized', () => { - expect(gl.text.pluralize('test', 2)).toBe('tests'); - }); - - it('returns pluralized when count is 0', () => { - expect(gl.text.pluralize('test', 0)).toBe('tests'); - }); - - it('does not return pluralized', () => { - expect(gl.text.pluralize('test', 1)).toBe('test'); - }); - }); - - describe('gl.text.highCountTrim', () => { - it('returns 99+ for count >= 100', () => { - expect(gl.text.highCountTrim(105)).toBe('99+'); - expect(gl.text.highCountTrim(100)).toBe('99+'); - }); - - it('returns exact number for count < 100', () => { - expect(gl.text.highCountTrim(45)).toBe(45); - }); - }); - }); -})(); diff --git a/spec/javascripts/line_highlighter_spec.js b/spec/javascripts/line_highlighter_spec.js index a0b2ebc221b..a1fd2d38968 100644 --- a/spec/javascripts/line_highlighter_spec.js +++ b/spec/javascripts/line_highlighter_spec.js @@ -7,16 +7,12 @@ require('~/line_highlighter'); describe('LineHighlighter', function() { var clickLine; preloadFixtures('static/line_highlighter.html.raw'); - clickLine = function(number, eventData) { - var e; - if (eventData == null) { - eventData = {}; - } + clickLine = function(number, eventData = {}) { if ($.isEmptyObject(eventData)) { - return $("#L" + number).mousedown().click(); + return $("#L" + number).click(); } else { - e = $.Event('mousedown', eventData); - return $("#L" + number).trigger(e).click(); + const e = $.Event('click', eventData); + return $("#L" + number).trigger(e); } }; beforeEach(function() { @@ -63,12 +59,6 @@ require('~/line_highlighter'); }); }); describe('#clickHandler', function() { - it('discards the mousedown event', function() { - var spy; - spy = spyOnEvent('a[data-line-number]', 'mousedown'); - clickLine(13); - return expect(spy).toHaveBeenPrevented(); - }); it('handles clicking on a child icon element', function() { var spy; spy = spyOn(this["class"], 'setHash').and.callThrough(); diff --git a/spec/javascripts/mini_pipeline_graph_dropdown_spec.js b/spec/javascripts/mini_pipeline_graph_dropdown_spec.js new file mode 100644 index 00000000000..e504d41d4d4 --- /dev/null +++ b/spec/javascripts/mini_pipeline_graph_dropdown_spec.js @@ -0,0 +1,72 @@ +/* eslint-disable no-new */ + +import MiniPipelineGraph from '~/mini_pipeline_graph_dropdown'; +import '~/flash'; + +(() => { + describe('Mini Pipeline Graph Dropdown', () => { + preloadFixtures('static/mini_dropdown_graph.html.raw'); + + beforeEach(() => { + loadFixtures('static/mini_dropdown_graph.html.raw'); + }); + + describe('When is initialized', () => { + it('should initialize without errors when no options are given', () => { + const miniPipelineGraph = new MiniPipelineGraph(); + + expect(miniPipelineGraph.dropdownListSelector).toEqual('.js-builds-dropdown-container'); + }); + + it('should set the container as the given prop', () => { + const container = '.foo'; + + const miniPipelineGraph = new MiniPipelineGraph({ container }); + + expect(miniPipelineGraph.container).toEqual(container); + }); + }); + + describe('When dropdown is clicked', () => { + it('should call getBuildsList', () => { + const getBuildsListSpy = spyOn(MiniPipelineGraph.prototype, 'getBuildsList').and.callFake(function () {}); + + new MiniPipelineGraph({ container: '.js-builds-dropdown-tests' }).bindEvents(); + + document.querySelector('.js-builds-dropdown-button').click(); + + expect(getBuildsListSpy).toHaveBeenCalled(); + }); + + it('should make a request to the endpoint provided in the html', () => { + const ajaxSpy = spyOn($, 'ajax').and.callFake(function () {}); + + new MiniPipelineGraph({ container: '.js-builds-dropdown-tests' }).bindEvents(); + + document.querySelector('.js-builds-dropdown-button').click(); + expect(ajaxSpy.calls.allArgs()[0][0].url).toEqual('foobar'); + }); + + it('should not close when user uses cmd/ctrl + click', () => { + spyOn($, 'ajax').and.callFake(function (params) { + params.success({ + html: `<li> + <a class="mini-pipeline-graph-dropdown-item" href="#"> + <span class="ci-status-icon ci-status-icon-failed"></span> + <span class="ci-build-text">build</span> + </a> + <a class="ci-action-icon-wrapper js-ci-action-icon" href="#"></a> + </li>`, + }); + }); + new MiniPipelineGraph({ container: '.js-builds-dropdown-tests' }).bindEvents(); + + document.querySelector('.js-builds-dropdown-button').click(); + + document.querySelector('a.mini-pipeline-graph-dropdown-item').click(); + + expect($('.js-builds-dropdown-list').is(':visible')).toEqual(true); + }); + }); + }); +})(); diff --git a/spec/javascripts/mini_pipeline_graph_dropdown_spec.js.es6 b/spec/javascripts/mini_pipeline_graph_dropdown_spec.js.es6 deleted file mode 100644 index 7cdade01e00..00000000000 --- a/spec/javascripts/mini_pipeline_graph_dropdown_spec.js.es6 +++ /dev/null @@ -1,51 +0,0 @@ -/* eslint-disable no-new */ - -require('~/flash'); -require('~/mini_pipeline_graph_dropdown'); - -(() => { - describe('Mini Pipeline Graph Dropdown', () => { - preloadFixtures('static/mini_dropdown_graph.html.raw'); - - beforeEach(() => { - loadFixtures('static/mini_dropdown_graph.html.raw'); - }); - - describe('When is initialized', () => { - it('should initialize without errors when no options are given', () => { - const miniPipelineGraph = new window.gl.MiniPipelineGraph(); - - expect(miniPipelineGraph.dropdownListSelector).toEqual('.js-builds-dropdown-container'); - }); - - it('should set the container as the given prop', () => { - const container = '.foo'; - - const miniPipelineGraph = new window.gl.MiniPipelineGraph({ container }); - - expect(miniPipelineGraph.container).toEqual(container); - }); - }); - - describe('When dropdown is clicked', () => { - it('should call getBuildsList', () => { - const getBuildsListSpy = spyOn(gl.MiniPipelineGraph.prototype, 'getBuildsList').and.callFake(function () {}); - - new gl.MiniPipelineGraph({ container: '.js-builds-dropdown-tests' }).bindEvents(); - - document.querySelector('.js-builds-dropdown-button').click(); - - expect(getBuildsListSpy).toHaveBeenCalled(); - }); - - it('should make a request to the endpoint provided in the html', () => { - const ajaxSpy = spyOn($, 'ajax').and.callFake(function () {}); - - new gl.MiniPipelineGraph({ container: '.js-builds-dropdown-tests' }).bindEvents(); - - document.querySelector('.js-builds-dropdown-button').click(); - expect(ajaxSpy.calls.allArgs()[0][0].url).toEqual('foobar'); - }); - }); - }); -})(); diff --git a/spec/javascripts/monitoring/prometheus_graph_spec.js b/spec/javascripts/monitoring/prometheus_graph_spec.js new file mode 100644 index 00000000000..a3c1c5e1b7c --- /dev/null +++ b/spec/javascripts/monitoring/prometheus_graph_spec.js @@ -0,0 +1,75 @@ +import 'jquery'; +import '~/lib/utils/common_utils'; +import PrometheusGraph from '~/monitoring/prometheus_graph'; +import { prometheusMockData } from './prometheus_mock_data'; + +describe('PrometheusGraph', () => { + const fixtureName = 'static/environments/metrics.html.raw'; + const prometheusGraphContainer = '.prometheus-graph'; + const prometheusGraphContents = `${prometheusGraphContainer}[graph-type=cpu_values]`; + + preloadFixtures(fixtureName); + + beforeEach(() => { + loadFixtures(fixtureName); + this.prometheusGraph = new PrometheusGraph(); + const self = this; + const fakeInit = (metricsResponse) => { + self.prometheusGraph.transformData(metricsResponse); + self.prometheusGraph.createGraph(); + }; + spyOn(this.prometheusGraph, 'init').and.callFake(fakeInit); + }); + + it('initializes graph properties', () => { + // Test for the measurements + expect(this.prometheusGraph.margin).toBeDefined(); + expect(this.prometheusGraph.marginLabelContainer).toBeDefined(); + expect(this.prometheusGraph.originalWidth).toBeDefined(); + expect(this.prometheusGraph.originalHeight).toBeDefined(); + expect(this.prometheusGraph.height).toBeDefined(); + expect(this.prometheusGraph.width).toBeDefined(); + expect(this.prometheusGraph.backOffRequestCounter).toBeDefined(); + // Test for the graph properties (colors, radius, etc.) + expect(this.prometheusGraph.graphSpecificProperties).toBeDefined(); + expect(this.prometheusGraph.commonGraphProperties).toBeDefined(); + }); + + it('transforms the data', () => { + this.prometheusGraph.init(prometheusMockData.metrics); + expect(this.prometheusGraph.data).toBeDefined(); + expect(this.prometheusGraph.data.cpu_values.length).toBe(121); + expect(this.prometheusGraph.data.memory_values.length).toBe(121); + }); + + it('creates two graphs', () => { + this.prometheusGraph.init(prometheusMockData.metrics); + expect($(prometheusGraphContainer).length).toBe(2); + }); + + describe('Graph contents', () => { + beforeEach(() => { + this.prometheusGraph.init(prometheusMockData.metrics); + }); + + it('has axis, an area, a line and a overlay', () => { + const $graphContainer = $(prometheusGraphContents).find('.x-axis').parent(); + expect($graphContainer.find('.x-axis')).toBeDefined(); + expect($graphContainer.find('.y-axis')).toBeDefined(); + expect($graphContainer.find('.prometheus-graph-overlay')).toBeDefined(); + expect($graphContainer.find('.metric-line')).toBeDefined(); + expect($graphContainer.find('.metric-area')).toBeDefined(); + }); + + it('has legends, labels and an extra axis that labels the metrics', () => { + const $prometheusGraphContents = $(prometheusGraphContents); + const $axisLabelContainer = $(prometheusGraphContents).find('.label-x-axis-line').parent(); + expect($prometheusGraphContents.find('.label-x-axis-line')).toBeDefined(); + expect($prometheusGraphContents.find('.label-y-axis-line')).toBeDefined(); + expect($prometheusGraphContents.find('.label-axis-text')).toBeDefined(); + expect($prometheusGraphContents.find('.rect-axis-text')).toBeDefined(); + expect($axisLabelContainer.find('rect').length).toBe(2); + expect($axisLabelContainer.find('text').length).toBe(4); + }); + }); +}); diff --git a/spec/javascripts/monitoring/prometheus_mock_data.js b/spec/javascripts/monitoring/prometheus_mock_data.js new file mode 100644 index 00000000000..1cdc14faaa8 --- /dev/null +++ b/spec/javascripts/monitoring/prometheus_mock_data.js @@ -0,0 +1,1014 @@ +/* eslint-disable import/prefer-default-export*/ +export const prometheusMockData = { + status: 200, + metrics: { + success: true, + metrics: { + memory_values: [ + { + metric: { + }, + values: [ + [ + 1488462917.256, + '10.12890625', + ], + [ + 1488462977.256, + '10.140625', + ], + [ + 1488463037.256, + '10.140625', + ], + [ + 1488463097.256, + '10.14453125', + ], + [ + 1488463157.256, + '10.1484375', + ], + [ + 1488463217.256, + '10.15625', + ], + [ + 1488463277.256, + '10.15625', + ], + [ + 1488463337.256, + '10.15625', + ], + [ + 1488463397.256, + '10.1640625', + ], + [ + 1488463457.256, + '10.171875', + ], + [ + 1488463517.256, + '10.171875', + ], + [ + 1488463577.256, + '10.171875', + ], + [ + 1488463637.256, + '10.18359375', + ], + [ + 1488463697.256, + '10.1953125', + ], + [ + 1488463757.256, + '10.203125', + ], + [ + 1488463817.256, + '10.20703125', + ], + [ + 1488463877.256, + '10.20703125', + ], + [ + 1488463937.256, + '10.20703125', + ], + [ + 1488463997.256, + '10.20703125', + ], + [ + 1488464057.256, + '10.2109375', + ], + [ + 1488464117.256, + '10.2109375', + ], + [ + 1488464177.256, + '10.2109375', + ], + [ + 1488464237.256, + '10.2109375', + ], + [ + 1488464297.256, + '10.21484375', + ], + [ + 1488464357.256, + '10.22265625', + ], + [ + 1488464417.256, + '10.22265625', + ], + [ + 1488464477.256, + '10.2265625', + ], + [ + 1488464537.256, + '10.23046875', + ], + [ + 1488464597.256, + '10.23046875', + ], + [ + 1488464657.256, + '10.234375', + ], + [ + 1488464717.256, + '10.234375', + ], + [ + 1488464777.256, + '10.234375', + ], + [ + 1488464837.256, + '10.234375', + ], + [ + 1488464897.256, + '10.234375', + ], + [ + 1488464957.256, + '10.234375', + ], + [ + 1488465017.256, + '10.23828125', + ], + [ + 1488465077.256, + '10.23828125', + ], + [ + 1488465137.256, + '10.2421875', + ], + [ + 1488465197.256, + '10.2421875', + ], + [ + 1488465257.256, + '10.2421875', + ], + [ + 1488465317.256, + '10.2421875', + ], + [ + 1488465377.256, + '10.2421875', + ], + [ + 1488465437.256, + '10.2421875', + ], + [ + 1488465497.256, + '10.2421875', + ], + [ + 1488465557.256, + '10.2421875', + ], + [ + 1488465617.256, + '10.2421875', + ], + [ + 1488465677.256, + '10.2421875', + ], + [ + 1488465737.256, + '10.2421875', + ], + [ + 1488465797.256, + '10.24609375', + ], + [ + 1488465857.256, + '10.25', + ], + [ + 1488465917.256, + '10.25390625', + ], + [ + 1488465977.256, + '9.98828125', + ], + [ + 1488466037.256, + '9.9921875', + ], + [ + 1488466097.256, + '9.9921875', + ], + [ + 1488466157.256, + '9.99609375', + ], + [ + 1488466217.256, + '10', + ], + [ + 1488466277.256, + '10.00390625', + ], + [ + 1488466337.256, + '10.0078125', + ], + [ + 1488466397.256, + '10.01171875', + ], + [ + 1488466457.256, + '10.0234375', + ], + [ + 1488466517.256, + '10.02734375', + ], + [ + 1488466577.256, + '10.02734375', + ], + [ + 1488466637.256, + '10.03125', + ], + [ + 1488466697.256, + '10.03125', + ], + [ + 1488466757.256, + '10.03125', + ], + [ + 1488466817.256, + '10.03125', + ], + [ + 1488466877.256, + '10.03125', + ], + [ + 1488466937.256, + '10.03125', + ], + [ + 1488466997.256, + '10.03125', + ], + [ + 1488467057.256, + '10.0390625', + ], + [ + 1488467117.256, + '10.0390625', + ], + [ + 1488467177.256, + '10.04296875', + ], + [ + 1488467237.256, + '10.05078125', + ], + [ + 1488467297.256, + '10.05859375', + ], + [ + 1488467357.256, + '10.06640625', + ], + [ + 1488467417.256, + '10.06640625', + ], + [ + 1488467477.256, + '10.0703125', + ], + [ + 1488467537.256, + '10.07421875', + ], + [ + 1488467597.256, + '10.0859375', + ], + [ + 1488467657.256, + '10.0859375', + ], + [ + 1488467717.256, + '10.09765625', + ], + [ + 1488467777.256, + '10.1015625', + ], + [ + 1488467837.256, + '10.10546875', + ], + [ + 1488467897.256, + '10.10546875', + ], + [ + 1488467957.256, + '10.125', + ], + [ + 1488468017.256, + '10.13671875', + ], + [ + 1488468077.256, + '10.1484375', + ], + [ + 1488468137.256, + '10.15625', + ], + [ + 1488468197.256, + '10.16796875', + ], + [ + 1488468257.256, + '10.171875', + ], + [ + 1488468317.256, + '10.171875', + ], + [ + 1488468377.256, + '10.171875', + ], + [ + 1488468437.256, + '10.171875', + ], + [ + 1488468497.256, + '10.171875', + ], + [ + 1488468557.256, + '10.171875', + ], + [ + 1488468617.256, + '10.171875', + ], + [ + 1488468677.256, + '10.17578125', + ], + [ + 1488468737.256, + '10.17578125', + ], + [ + 1488468797.256, + '10.265625', + ], + [ + 1488468857.256, + '10.19921875', + ], + [ + 1488468917.256, + '10.19921875', + ], + [ + 1488468977.256, + '10.19921875', + ], + [ + 1488469037.256, + '10.19921875', + ], + [ + 1488469097.256, + '10.19921875', + ], + [ + 1488469157.256, + '10.203125', + ], + [ + 1488469217.256, + '10.43359375', + ], + [ + 1488469277.256, + '10.20703125', + ], + [ + 1488469337.256, + '10.2109375', + ], + [ + 1488469397.256, + '10.22265625', + ], + [ + 1488469457.256, + '10.21484375', + ], + [ + 1488469517.256, + '10.21484375', + ], + [ + 1488469577.256, + '10.21484375', + ], + [ + 1488469637.256, + '10.22265625', + ], + [ + 1488469697.256, + '10.234375', + ], + [ + 1488469757.256, + '10.234375', + ], + [ + 1488469817.256, + '10.234375', + ], + [ + 1488469877.256, + '10.2421875', + ], + [ + 1488469937.256, + '10.25', + ], + [ + 1488469997.256, + '10.25390625', + ], + [ + 1488470057.256, + '10.26171875', + ], + [ + 1488470117.256, + '10.2734375', + ], + ], + }, + ], + memory_current: [ + { + metric: { + }, + value: [ + 1488470117.737, + '10.2734375', + ], + }, + ], + cpu_values: [ + { + metric: { + }, + values: [ + [ + 1488462918.15, + '0.0002996458625058103', + ], + [ + 1488462978.15, + '0.0002652382333333314', + ], + [ + 1488463038.15, + '0.0003485461333333421', + ], + [ + 1488463098.15, + '0.0003420421999999886', + ], + [ + 1488463158.15, + '0.00023107150000001297', + ], + [ + 1488463218.15, + '0.00030463981666664826', + ], + [ + 1488463278.15, + '0.0002477177833333677', + ], + [ + 1488463338.15, + '0.00026936656666665115', + ], + [ + 1488463398.15, + '0.000406264750000022', + ], + [ + 1488463458.15, + '0.00029592802026561453', + ], + [ + 1488463518.15, + '0.00023426999683316343', + ], + [ + 1488463578.15, + '0.0003057080666666915', + ], + [ + 1488463638.15, + '0.0003408470500000149', + ], + [ + 1488463698.15, + '0.00025497336666665166', + ], + [ + 1488463758.15, + '0.0003009282833333534', + ], + [ + 1488463818.15, + '0.0003119383499999924', + ], + [ + 1488463878.15, + '0.00028719019999998705', + ], + [ + 1488463938.15, + '0.000327864749999988', + ], + [ + 1488463998.15, + '0.0002514917333333422', + ], + [ + 1488464058.15, + '0.0003614651166666742', + ], + [ + 1488464118.15, + '0.0003221668000000122', + ], + [ + 1488464178.15, + '0.00023323083333330884', + ], + [ + 1488464238.15, + '0.00028531499475009274', + ], + [ + 1488464298.15, + '0.0002627695294921391', + ], + [ + 1488464358.15, + '0.00027145463333333453', + ], + [ + 1488464418.15, + '0.00025669488333335266', + ], + [ + 1488464478.15, + '0.00022307761666665965', + ], + [ + 1488464538.15, + '0.0003307265833333517', + ], + [ + 1488464598.15, + '0.0002817050666666709', + ], + [ + 1488464658.15, + '0.00022357458333332285', + ], + [ + 1488464718.15, + '0.00032648590000000275', + ], + [ + 1488464778.15, + '0.00028410750000000816', + ], + [ + 1488464838.15, + '0.0003038076999999954', + ], + [ + 1488464898.15, + '0.00037568226666667335', + ], + [ + 1488464958.15, + '0.00020160354999999202', + ], + [ + 1488465018.15, + '0.0003229403333333399', + ], + [ + 1488465078.15, + '0.00033516069999999236', + ], + [ + 1488465138.15, + '0.0003365978333333371', + ], + [ + 1488465198.15, + '0.00020262178333331585', + ], + [ + 1488465258.15, + '0.00040567498333331876', + ], + [ + 1488465318.15, + '0.00029114155000001436', + ], + [ + 1488465378.15, + '0.0002498841000000122', + ], + [ + 1488465438.15, + '0.00027296763333331715', + ], + [ + 1488465498.15, + '0.0002958794000000135', + ], + [ + 1488465558.15, + '0.0002922354666666867', + ], + [ + 1488465618.15, + '0.00034186624999999653', + ], + [ + 1488465678.15, + '0.0003397984166666627', + ], + [ + 1488465738.15, + '0.0002658284166666469', + ], + [ + 1488465798.15, + '0.00026221139999999346', + ], + [ + 1488465858.15, + '0.00029467960000001034', + ], + [ + 1488465918.15, + '0.0002634141333333358', + ], + [ + 1488465978.15, + '0.0003202958333333209', + ], + [ + 1488466038.15, + '0.00037890760000000394', + ], + [ + 1488466098.15, + '0.00023453356666666518', + ], + [ + 1488466158.15, + '0.0002866827333333433', + ], + [ + 1488466218.15, + '0.0003335935499999998', + ], + [ + 1488466278.15, + '0.00022787131666666125', + ], + [ + 1488466338.15, + '0.00033821938333333064', + ], + [ + 1488466398.15, + '0.00029233375000001043', + ], + [ + 1488466458.15, + '0.00026562758333333514', + ], + [ + 1488466518.15, + '0.0003142600999999819', + ], + [ + 1488466578.15, + '0.00027392178333333444', + ], + [ + 1488466638.15, + '0.00028178598333334173', + ], + [ + 1488466698.15, + '0.0002463400666666911', + ], + [ + 1488466758.15, + '0.00040234373333332125', + ], + [ + 1488466818.15, + '0.00023677453333332822', + ], + [ + 1488466878.15, + '0.00030852703333333523', + ], + [ + 1488466938.15, + '0.0003582272833333455', + ], + [ + 1488466998.15, + '0.0002176380833332973', + ], + [ + 1488467058.15, + '0.00026180203333335447', + ], + [ + 1488467118.15, + '0.00027862966666667436', + ], + [ + 1488467178.15, + '0.0002769731166666567', + ], + [ + 1488467238.15, + '0.0002832899166666477', + ], + [ + 1488467298.15, + '0.0003446533500000311', + ], + [ + 1488467358.15, + '0.0002691345999999761', + ], + [ + 1488467418.15, + '0.000284919933333357', + ], + [ + 1488467478.15, + '0.0002396026166666528', + ], + [ + 1488467538.15, + '0.00035625295000002075', + ], + [ + 1488467598.15, + '0.00036759816666664946', + ], + [ + 1488467658.15, + '0.00030326608333333855', + ], + [ + 1488467718.15, + '0.00023584972418043393', + ], + [ + 1488467778.15, + '0.00025744508892115107', + ], + [ + 1488467838.15, + '0.00036737541666663395', + ], + [ + 1488467898.15, + '0.00034325741666666094', + ], + [ + 1488467958.15, + '0.00026390046666667407', + ], + [ + 1488468018.15, + '0.0003302534500000102', + ], + [ + 1488468078.15, + '0.00035243794999999527', + ], + [ + 1488468138.15, + '0.00020149738333333407', + ], + [ + 1488468198.15, + '0.0003183469666666679', + ], + [ + 1488468258.15, + '0.0003835329166666845', + ], + [ + 1488468318.15, + '0.0002485075333333124', + ], + [ + 1488468378.15, + '0.0003011457166666768', + ], + [ + 1488468438.15, + '0.00032242785497684965', + ], + [ + 1488468498.15, + '0.0002659713747457531', + ], + [ + 1488468558.15, + '0.0003476860333333202', + ], + [ + 1488468618.15, + '0.00028336403333334794', + ], + [ + 1488468678.15, + '0.00017132354999998728', + ], + [ + 1488468738.15, + '0.0003001915833333276', + ], + [ + 1488468798.15, + '0.0003025715666666725', + ], + [ + 1488468858.15, + '0.0003012370166666815', + ], + [ + 1488468918.15, + '0.00030203619999997025', + ], + [ + 1488468978.15, + '0.0002804355000000314', + ], + [ + 1488469038.15, + '0.00033194884999998564', + ], + [ + 1488469098.15, + '0.00025201496666665455', + ], + [ + 1488469158.15, + '0.0002777531500000189', + ], + [ + 1488469218.15, + '0.0003314885833333392', + ], + [ + 1488469278.15, + '0.0002234891422095589', + ], + [ + 1488469338.15, + '0.000349117355867791', + ], + [ + 1488469398.15, + '0.0004036731333333303', + ], + [ + 1488469458.15, + '0.00024553911666667835', + ], + [ + 1488469518.15, + '0.0003056456833333184', + ], + [ + 1488469578.15, + '0.0002618737166666681', + ], + [ + 1488469638.15, + '0.00022972643333331414', + ], + [ + 1488469698.15, + '0.0003713522500000307', + ], + [ + 1488469758.15, + '0.00018322576666666515', + ], + [ + 1488469818.15, + '0.00034534762753952466', + ], + [ + 1488469878.15, + '0.00028200510008501677', + ], + [ + 1488469938.15, + '0.0002773708499999768', + ], + [ + 1488469998.15, + '0.00027547160000001013', + ], + [ + 1488470058.15, + '0.00031713610000000023', + ], + [ + 1488470118.15, + '0.00035276853333332525', + ], + ], + }, + ], + cpu_current: [ + { + metric: { + }, + value: [ + 1488470118.566, + '0.00035276853333332525', + ], + }, + ], + last_update: '2017-03-02T15:55:18.981Z', + }, + }, +}; diff --git a/spec/javascripts/new_branch_spec.js b/spec/javascripts/new_branch_spec.js index f132537b943..90a429beeca 100644 --- a/spec/javascripts/new_branch_spec.js +++ b/spec/javascripts/new_branch_spec.js @@ -1,7 +1,6 @@ /* eslint-disable space-before-function-paren, one-var, no-var, one-var-declaration-per-line, no-return-assign, quotes, max-len */ /* global NewBranchForm */ -require('jquery-ui/ui/autocomplete'); require('~/new_branch_form'); (function() { diff --git a/spec/javascripts/pager_spec.js b/spec/javascripts/pager_spec.js new file mode 100644 index 00000000000..d966226909b --- /dev/null +++ b/spec/javascripts/pager_spec.js @@ -0,0 +1,90 @@ +/* global fixture */ + +require('~/pager'); + +describe('pager', () => { + const Pager = window.Pager; + + it('is defined on window', () => { + expect(window.Pager).toBeDefined(); + }); + + describe('init', () => { + const originalHref = window.location.href; + + beforeEach(() => { + setFixtures('<div class="content_list"></div><div class="loading"></div>'); + spyOn($, 'ajax'); + }); + + afterEach(() => { + window.history.replaceState({}, null, originalHref); + }); + + it('should use data-href attribute from list element', () => { + const href = `${gl.TEST_HOST}/some_list.json`; + setFixtures(`<div class="content_list" data-href="${href}"></div>`); + Pager.init(); + expect(Pager.url).toBe(href); + }); + + it('should use current url if data-href attribute not provided', () => { + const href = `${gl.TEST_HOST}/some_list`; + spyOn(gl.utils, 'removeParams').and.returnValue(href); + Pager.init(); + expect(Pager.url).toBe(href); + }); + + it('should get initial offset from query parameter', () => { + window.history.replaceState({}, null, '?offset=100'); + Pager.init(); + expect(Pager.offset).toBe(100); + }); + + it('keeps extra query parameters from url', () => { + window.history.replaceState({}, null, '?filter=test&offset=100'); + const href = `${gl.TEST_HOST}/some_list?filter=test`; + spyOn(gl.utils, 'removeParams').and.returnValue(href); + Pager.init(); + expect(gl.utils.removeParams).toHaveBeenCalledWith(['limit', 'offset']); + expect(Pager.url).toEqual(href); + }); + }); + + describe('getOld', () => { + beforeEach(() => { + setFixtures('<div class="content_list" data-href="/some_list"></div><div class="loading"></div>'); + Pager.init(); + }); + + it('shows loader while loading next page', () => { + spyOn(Pager.loading, 'show'); + Pager.getOld(); + expect(Pager.loading.show).toHaveBeenCalled(); + }); + + it('hides loader on success', () => { + spyOn($, 'ajax').and.callFake(options => options.success({})); + spyOn(Pager.loading, 'hide'); + Pager.getOld(); + expect(Pager.loading.hide).toHaveBeenCalled(); + }); + + it('hides loader on error', () => { + spyOn($, 'ajax').and.callFake(options => options.error()); + spyOn(Pager.loading, 'hide'); + Pager.getOld(); + expect(Pager.loading.hide).toHaveBeenCalled(); + }); + + it('sends request to url with offset and limit params', () => { + spyOn($, 'ajax'); + Pager.offset = 100; + Pager.limit = 20; + Pager.getOld(); + const [{ data, url }] = $.ajax.calls.argsFor(0); + expect(data).toBe('limit=20&offset=100'); + expect(url).toBe('/some_list'); + }); + }); +}); diff --git a/spec/javascripts/pipelines_spec.js.es6 b/spec/javascripts/pipelines_spec.js index 72770a702d3..72770a702d3 100644 --- a/spec/javascripts/pipelines_spec.js.es6 +++ b/spec/javascripts/pipelines_spec.js diff --git a/spec/javascripts/polyfills/element_spec.js b/spec/javascripts/polyfills/element_spec.js new file mode 100644 index 00000000000..ecaaf1907ea --- /dev/null +++ b/spec/javascripts/polyfills/element_spec.js @@ -0,0 +1,36 @@ +import '~/commons/polyfills/element'; + +describe('Element polyfills', function () { + beforeEach(() => { + this.element = document.createElement('ul'); + }); + + describe('matches', () => { + it('returns true if element matches the selector', () => { + expect(this.element.matches('ul')).toBeTruthy(); + }); + + it("returns false if element doesn't match the selector", () => { + expect(this.element.matches('.not-an-element')).toBeFalsy(); + }); + }); + + describe('closest', () => { + beforeEach(() => { + this.childElement = document.createElement('li'); + this.element.appendChild(this.childElement); + }); + + it('returns the closest parent that matches the selector', () => { + expect(this.childElement.closest('ul').toString()).toBe(this.element.toString()); + }); + + it('returns itself if it matches the selector', () => { + expect(this.childElement.closest('li').toString()).toBe(this.childElement.toString()); + }); + + it('returns undefined if nothing matches the selector', () => { + expect(this.childElement.closest('.no-an-element')).toBeFalsy(); + }); + }); +}); diff --git a/spec/javascripts/pretty_time_spec.js.es6 b/spec/javascripts/pretty_time_spec.js index a4662cfb557..a4662cfb557 100644 --- a/spec/javascripts/pretty_time_spec.js.es6 +++ b/spec/javascripts/pretty_time_spec.js diff --git a/spec/javascripts/project_title_spec.js b/spec/javascripts/project_title_spec.js index 69d9587771f..3a1d4e2440f 100644 --- a/spec/javascripts/project_title_spec.js +++ b/spec/javascripts/project_title_spec.js @@ -26,7 +26,7 @@ require('~/project'); var fakeAjaxResponse = function fakeAjaxResponse(req) { var d; expect(req.url).toBe('/api/v3/projects.json?simple=true'); - expect(req.data).toEqual({ search: '', order_by: 'last_activity_at', per_page: 20 }); + expect(req.data).toEqual({ search: '', order_by: 'last_activity_at', per_page: 20, membership: true }); d = $.Deferred(); d.resolve(this.projects_data); return d.promise(); diff --git a/spec/javascripts/right_sidebar_spec.js b/spec/javascripts/right_sidebar_spec.js index 4ac7e911740..285b7940174 100644 --- a/spec/javascripts/right_sidebar_spec.js +++ b/spec/javascripts/right_sidebar_spec.js @@ -1,8 +1,8 @@ /* eslint-disable space-before-function-paren, no-var, one-var, one-var-declaration-per-line, new-parens, no-return-assign, new-cap, vars-on-top, max-len */ /* global Sidebar */ -require('~/right_sidebar'); -require('~/extensions/jquery.js'); +import '~/commons/bootstrap'; +import '~/right_sidebar'; (function() { var $aside, $icon, $labelsIcon, $page, $toggle, assertSidebarState; diff --git a/spec/javascripts/shortcuts_issuable_spec.js b/spec/javascripts/shortcuts_issuable_spec.js index ffff643e371..9e19dabd0e3 100644 --- a/spec/javascripts/shortcuts_issuable_spec.js +++ b/spec/javascripts/shortcuts_issuable_spec.js @@ -31,13 +31,9 @@ require('~/shortcuts_issuable'); this.shortcut.replyWithSelectedText(); expect($(this.selector).val()).toBe(''); }); - it('triggers `input`', function() { - var focused = false; - $(this.selector).on('focus', function() { - focused = true; - }); + it('triggers `focus`', function() { this.shortcut.replyWithSelectedText(); - expect(focused).toBe(true); + expect(document.activeElement).toBe(document.querySelector(this.selector)); }); }); describe('with any selection', function() { diff --git a/spec/javascripts/signin_tabs_memoizer_spec.js.es6 b/spec/javascripts/signin_tabs_memoizer_spec.js index d83d9a57b42..d83d9a57b42 100644 --- a/spec/javascripts/signin_tabs_memoizer_spec.js.es6 +++ b/spec/javascripts/signin_tabs_memoizer_spec.js diff --git a/spec/javascripts/smart_interval_spec.js.es6 b/spec/javascripts/smart_interval_spec.js index 4366ec2a5b8..4366ec2a5b8 100644 --- a/spec/javascripts/smart_interval_spec.js.es6 +++ b/spec/javascripts/smart_interval_spec.js diff --git a/spec/javascripts/subbable_resource_spec.js.es6 b/spec/javascripts/subbable_resource_spec.js index 454386697f5..454386697f5 100644 --- a/spec/javascripts/subbable_resource_spec.js.es6 +++ b/spec/javascripts/subbable_resource_spec.js diff --git a/spec/javascripts/test_bundle.js b/spec/javascripts/test_bundle.js index ca707d872a4..5cdb6473eda 100644 --- a/spec/javascripts/test_bundle.js +++ b/spec/javascripts/test_bundle.js @@ -5,23 +5,12 @@ jasmine.getFixtures().fixturesPath = 'base/spec/javascripts/fixtures'; jasmine.getJSONFixtures().fixturesPath = 'base/spec/javascripts/fixtures'; // include common libraries +require('~/commons/index.js'); window.$ = window.jQuery = require('jquery'); window._ = require('underscore'); window.Cookies = require('js-cookie'); window.Vue = require('vue'); window.Vue.use(require('vue-resource')); -require('jquery-ujs'); -require('bootstrap/js/affix'); -require('bootstrap/js/alert'); -require('bootstrap/js/button'); -require('bootstrap/js/collapse'); -require('bootstrap/js/dropdown'); -require('bootstrap/js/modal'); -require('bootstrap/js/scrollspy'); -require('bootstrap/js/tab'); -require('bootstrap/js/transition'); -require('bootstrap/js/tooltip'); -require('bootstrap/js/popover'); // stub expected globals window.gl = window.gl || {}; @@ -43,10 +32,11 @@ testsContext.keys().forEach(function (path) { } }); -// workaround: include all source files to find files with 0% coverage -// see also https://github.com/deepsweet/istanbul-instrumenter-loader/issues/15 -describe('Uncovered files', function () { - // the following files throw errors because of undefined variables +// if we're generating coverage reports, make sure to include all files so +// that we can catch files with 0% coverage +// see: https://github.com/deepsweet/istanbul-instrumenter-loader/issues/15 +if (process.env.BABEL_ENV === 'coverage') { + // exempt these files from the coverage report const troubleMakers = [ './blob_edit/blob_edit_bundle.js', './cycle_analytics/components/stage_plan_component.js', @@ -59,21 +49,23 @@ describe('Uncovered files', function () { './network/branch_graph.js', ]; - const sourceFiles = require.context('~', true, /^\.\/(?!application\.js).*\.(js|es6)$/); - sourceFiles.keys().forEach(function (path) { - // ignore if there is a matching spec file - if (testsContext.keys().indexOf(`${path.replace(/\.js(\.es6)?$/, '')}_spec`) > -1) { - return; - } + describe('Uncovered files', function () { + const sourceFiles = require.context('~', true, /\.js$/); + sourceFiles.keys().forEach(function (path) { + // ignore if there is a matching spec file + if (testsContext.keys().indexOf(`${path.replace(/\.js$/, '')}_spec`) > -1) { + return; + } - it(`includes '${path}'`, function () { - try { - sourceFiles(path); - } catch (err) { - if (troubleMakers.indexOf(path) === -1) { - expect(err).toBeNull(); + it(`includes '${path}'`, function () { + try { + sourceFiles(path); + } catch (err) { + if (troubleMakers.indexOf(path) === -1) { + expect(err).toBeNull(); + } } - } + }); }); }); -}); +} diff --git a/spec/javascripts/user_callout_spec.js b/spec/javascripts/user_callout_spec.js new file mode 100644 index 00000000000..205e72af600 --- /dev/null +++ b/spec/javascripts/user_callout_spec.js @@ -0,0 +1,57 @@ +const UserCallout = require('~/user_callout'); + +const USER_CALLOUT_COOKIE = 'user_callout_dismissed'; +const Cookie = window.Cookies; + +describe('UserCallout', function () { + const fixtureName = 'static/user_callout.html.raw'; + preloadFixtures(fixtureName); + + beforeEach(() => { + loadFixtures(fixtureName); + Cookie.remove(USER_CALLOUT_COOKIE); + + this.userCallout = new UserCallout(); + this.closeButton = $('.close-user-callout'); + this.userCalloutBtn = $('.user-callout-btn'); + this.userCalloutContainer = $('.user-callout'); + }); + + it('does not show when cookie is set not defined', () => { + expect(Cookie.get(USER_CALLOUT_COOKIE)).toBeUndefined(); + expect(this.userCalloutContainer.is(':visible')).toBe(true); + }); + + it('shows when cookie is set to false', () => { + Cookie.set(USER_CALLOUT_COOKIE, 'false'); + + expect(Cookie.get(USER_CALLOUT_COOKIE)).toBeDefined(); + expect(this.userCalloutContainer.is(':visible')).toBe(true); + }); + + it('hides when user clicks on the dismiss-icon', () => { + this.closeButton.click(); + expect(Cookie.get(USER_CALLOUT_COOKIE)).toBe('true'); + }); + + it('hides when user clicks on the "check it out" button', () => { + this.userCalloutBtn.click(); + expect(Cookie.get(USER_CALLOUT_COOKIE)).toBe('true'); + }); +}); + +describe('UserCallout when cookie is present', function () { + const fixtureName = 'static/user_callout.html.raw'; + preloadFixtures(fixtureName); + + beforeEach(() => { + loadFixtures(fixtureName); + Cookie.set(USER_CALLOUT_COOKIE, 'true'); + this.userCallout = new UserCallout(); + this.userCalloutContainer = $('.user-callout'); + }); + + it('removes the DOM element', () => { + expect(this.userCalloutContainer.length).toBe(0); + }); +}); diff --git a/spec/javascripts/version_check_image_spec.js b/spec/javascripts/version_check_image_spec.js new file mode 100644 index 00000000000..464c1fce210 --- /dev/null +++ b/spec/javascripts/version_check_image_spec.js @@ -0,0 +1,33 @@ +const ClassSpecHelper = require('./helpers/class_spec_helper'); +const VersionCheckImage = require('~/version_check_image'); +require('jquery'); + +describe('VersionCheckImage', function () { + describe('.bindErrorEvent', function () { + ClassSpecHelper.itShouldBeAStaticMethod(VersionCheckImage, 'bindErrorEvent'); + + beforeEach(function () { + this.imageElement = $('<div></div>'); + }); + + it('registers an error event', function () { + spyOn($.prototype, 'on'); + spyOn($.prototype, 'off').and.callFake(function () { return this; }); + + VersionCheckImage.bindErrorEvent(this.imageElement); + + expect($.prototype.off).toHaveBeenCalledWith('error'); + expect($.prototype.on).toHaveBeenCalledWith('error', jasmine.any(Function)); + }); + + it('hides the imageElement on error', function () { + spyOn($.prototype, 'hide'); + + VersionCheckImage.bindErrorEvent(this.imageElement); + + this.imageElement.trigger('error'); + + expect($.prototype.hide).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/javascripts/visibility_select_spec.js.es6 b/spec/javascripts/visibility_select_spec.js index 9727c03c91e..9727c03c91e 100644 --- a/spec/javascripts/visibility_select_spec.js.es6 +++ b/spec/javascripts/visibility_select_spec.js diff --git a/spec/javascripts/vue_pipelines_index/async_button_spec.js b/spec/javascripts/vue_pipelines_index/async_button_spec.js new file mode 100644 index 00000000000..bc8e504c413 --- /dev/null +++ b/spec/javascripts/vue_pipelines_index/async_button_spec.js @@ -0,0 +1,93 @@ +import Vue from 'vue'; +import asyncButtonComp from '~/vue_pipelines_index/components/async_button'; + +describe('Pipelines Async Button', () => { + let component; + let spy; + let AsyncButtonComponent; + + beforeEach(() => { + AsyncButtonComponent = Vue.extend(asyncButtonComp); + + spy = jasmine.createSpy('spy').and.returnValue(Promise.resolve()); + + component = new AsyncButtonComponent({ + propsData: { + endpoint: '/foo', + title: 'Foo', + icon: 'fa fa-foo', + cssClass: 'bar', + service: { + postAction: spy, + }, + }, + }).$mount(); + }); + + it('should render a button', () => { + expect(component.$el.tagName).toEqual('BUTTON'); + }); + + it('should render the provided icon', () => { + expect(component.$el.querySelector('i').getAttribute('class')).toContain('fa fa-foo'); + }); + + it('should render the provided title', () => { + expect(component.$el.getAttribute('title')).toContain('Foo'); + expect(component.$el.getAttribute('aria-label')).toContain('Foo'); + }); + + it('should render the provided cssClass', () => { + expect(component.$el.getAttribute('class')).toContain('bar'); + }); + + it('should call the service when it is clicked with the provided endpoint', () => { + component.$el.click(); + expect(spy).toHaveBeenCalledWith('/foo'); + }); + + it('should hide loading if request fails', () => { + spy = jasmine.createSpy('spy').and.returnValue(Promise.reject()); + + component = new AsyncButtonComponent({ + propsData: { + endpoint: '/foo', + title: 'Foo', + icon: 'fa fa-foo', + cssClass: 'bar', + dataAttributes: { + 'data-foo': 'foo', + }, + service: { + postAction: spy, + }, + }, + }).$mount(); + + component.$el.click(); + expect(component.$el.querySelector('.fa-spinner')).toBe(null); + }); + + describe('With confirm dialog', () => { + it('should call the service when confimation is positive', () => { + spyOn(window, 'confirm').and.returnValue(true); + spy = jasmine.createSpy('spy').and.returnValue(Promise.resolve()); + + component = new AsyncButtonComponent({ + propsData: { + endpoint: '/foo', + title: 'Foo', + icon: 'fa fa-foo', + cssClass: 'bar', + service: { + postAction: spy, + }, + confirmActionMessage: 'bar', + }, + }).$mount(); + + component.$el.click(); + expect(spy).toHaveBeenCalledWith('/foo'); + }); + }); +}); diff --git a/spec/javascripts/vue_pipelines_index/pipeline_url_spec.js b/spec/javascripts/vue_pipelines_index/pipeline_url_spec.js new file mode 100644 index 00000000000..96a2a37b5f7 --- /dev/null +++ b/spec/javascripts/vue_pipelines_index/pipeline_url_spec.js @@ -0,0 +1,100 @@ +import Vue from 'vue'; +import pipelineUrlComp from '~/vue_pipelines_index/components/pipeline_url'; + +describe('Pipeline Url Component', () => { + let PipelineUrlComponent; + + beforeEach(() => { + PipelineUrlComponent = Vue.extend(pipelineUrlComp); + }); + + it('should render a table cell', () => { + const component = new PipelineUrlComponent({ + propsData: { + pipeline: { + id: 1, + path: 'foo', + flags: {}, + }, + }, + }).$mount(); + + expect(component.$el.tagName).toEqual('TD'); + }); + + it('should render a link the provided path and id', () => { + const component = new PipelineUrlComponent({ + propsData: { + pipeline: { + id: 1, + path: 'foo', + flags: {}, + }, + }, + }).$mount(); + + expect(component.$el.querySelector('.js-pipeline-url-link').getAttribute('href')).toEqual('foo'); + expect(component.$el.querySelector('.js-pipeline-url-link span').textContent).toEqual('#1'); + }); + + it('should render user information when a user is provided', () => { + const mockData = { + pipeline: { + id: 1, + path: 'foo', + flags: {}, + user: { + web_url: '/', + name: 'foo', + avatar_url: '/', + }, + }, + }; + + const component = new PipelineUrlComponent({ + propsData: mockData, + }).$mount(); + + const image = component.$el.querySelector('.js-pipeline-url-user img'); + + expect( + component.$el.querySelector('.js-pipeline-url-user').getAttribute('href'), + ).toEqual(mockData.pipeline.user.web_url); + expect(image.getAttribute('title')).toEqual(mockData.pipeline.user.name); + expect(image.getAttribute('src')).toEqual(mockData.pipeline.user.avatar_url); + }); + + it('should render "API" when no user is provided', () => { + const component = new PipelineUrlComponent({ + propsData: { + pipeline: { + id: 1, + path: 'foo', + flags: {}, + }, + }, + }).$mount(); + + expect(component.$el.querySelector('.js-pipeline-url-api').textContent).toContain('API'); + }); + + it('should render latest, yaml invalid and stuck flags when provided', () => { + const component = new PipelineUrlComponent({ + propsData: { + pipeline: { + id: 1, + path: 'foo', + flags: { + latest: true, + yaml_errors: true, + stuck: true, + }, + }, + }, + }).$mount(); + + expect(component.$el.querySelector('.js-pipeline-url-lastest').textContent).toContain('latest'); + expect(component.$el.querySelector('.js-pipeline-url-yaml').textContent).toContain('yaml invalid'); + expect(component.$el.querySelector('.js-pipeline-url-stuck').textContent).toContain('stuck'); + }); +}); diff --git a/spec/javascripts/vue_pipelines_index/pipelines_actions_spec.js b/spec/javascripts/vue_pipelines_index/pipelines_actions_spec.js new file mode 100644 index 00000000000..dba998c7688 --- /dev/null +++ b/spec/javascripts/vue_pipelines_index/pipelines_actions_spec.js @@ -0,0 +1,62 @@ +import Vue from 'vue'; +import pipelinesActionsComp from '~/vue_pipelines_index/components/pipelines_actions'; + +describe('Pipelines Actions dropdown', () => { + let component; + let spy; + let actions; + let ActionsComponent; + + beforeEach(() => { + ActionsComponent = Vue.extend(pipelinesActionsComp); + + actions = [ + { + name: 'stop_review', + path: '/root/review-app/builds/1893/play', + }, + ]; + + spy = jasmine.createSpy('spy').and.returnValue(Promise.resolve()); + + component = new ActionsComponent({ + propsData: { + actions, + service: { + postAction: spy, + }, + }, + }).$mount(); + }); + + it('should render a dropdown with the provided actions', () => { + expect( + component.$el.querySelectorAll('.dropdown-menu li').length, + ).toEqual(actions.length); + }); + + it('should call the service when an action is clicked', () => { + component.$el.querySelector('.js-pipeline-dropdown-manual-actions').click(); + component.$el.querySelector('.js-pipeline-action-link').click(); + + expect(spy).toHaveBeenCalledWith(actions[0].path); + }); + + it('should hide loading if request fails', () => { + spy = jasmine.createSpy('spy').and.returnValue(Promise.reject()); + + component = new ActionsComponent({ + propsData: { + actions, + service: { + postAction: spy, + }, + }, + }).$mount(); + + component.$el.querySelector('.js-pipeline-dropdown-manual-actions').click(); + component.$el.querySelector('.js-pipeline-action-link').click(); + + expect(component.$el.querySelector('.fa-spinner')).toEqual(null); + }); +}); diff --git a/spec/javascripts/vue_pipelines_index/pipelines_artifacts_spec.js b/spec/javascripts/vue_pipelines_index/pipelines_artifacts_spec.js new file mode 100644 index 00000000000..f7f49649c1c --- /dev/null +++ b/spec/javascripts/vue_pipelines_index/pipelines_artifacts_spec.js @@ -0,0 +1,40 @@ +import Vue from 'vue'; +import artifactsComp from '~/vue_pipelines_index/components/pipelines_artifacts'; + +describe('Pipelines Artifacts dropdown', () => { + let component; + let artifacts; + + beforeEach(() => { + const ArtifactsComponent = Vue.extend(artifactsComp); + + artifacts = [ + { + name: 'artifact', + path: '/download/path', + }, + ]; + + component = new ArtifactsComponent({ + propsData: { + artifacts, + }, + }).$mount(); + }); + + it('should render a dropdown with the provided artifacts', () => { + expect( + component.$el.querySelectorAll('.dropdown-menu li').length, + ).toEqual(artifacts.length); + }); + + it('should render a link with the provided path', () => { + expect( + component.$el.querySelector('.dropdown-menu li a').getAttribute('href'), + ).toEqual(artifacts[0].path); + + expect( + component.$el.querySelector('.dropdown-menu li a span').textContent, + ).toContain(artifacts[0].name); + }); +}); diff --git a/spec/javascripts/vue_pipelines_index/pipelines_store_spec.js b/spec/javascripts/vue_pipelines_index/pipelines_store_spec.js new file mode 100644 index 00000000000..5c0934404bb --- /dev/null +++ b/spec/javascripts/vue_pipelines_index/pipelines_store_spec.js @@ -0,0 +1,72 @@ +import PipelineStore from '~/vue_pipelines_index/stores/pipelines_store'; + +describe('Pipelines Store', () => { + let store; + + beforeEach(() => { + store = new PipelineStore(); + }); + + it('should be initialized with an empty state', () => { + expect(store.state.pipelines).toEqual([]); + expect(store.state.count).toEqual({}); + expect(store.state.pageInfo).toEqual({}); + }); + + describe('storePipelines', () => { + it('should use the default parameter if none is provided', () => { + store.storePipelines(); + expect(store.state.pipelines).toEqual([]); + }); + + it('should store the provided array', () => { + const array = [{ id: 1, status: 'running' }, { id: 2, status: 'success' }]; + store.storePipelines(array); + expect(store.state.pipelines).toEqual(array); + }); + }); + + describe('storeCount', () => { + it('should use the default parameter if none is provided', () => { + store.storeCount(); + expect(store.state.count).toEqual({}); + }); + + it('should store the provided count', () => { + const count = { all: 20, finished: 10 }; + store.storeCount(count); + + expect(store.state.count).toEqual(count); + }); + }); + + describe('storePagination', () => { + it('should use the default parameter if none is provided', () => { + store.storePagination(); + expect(store.state.pageInfo).toEqual({}); + }); + + it('should store pagination information normalized and parsed', () => { + const pagination = { + 'X-nExt-pAge': '2', + 'X-page': '1', + 'X-Per-Page': '1', + 'X-Prev-Page': '2', + 'X-TOTAL': '37', + 'X-Total-Pages': '2', + }; + + const expectedResult = { + perPage: 1, + page: 1, + total: 37, + totalPages: 2, + nextPage: 2, + previousPage: 2, + }; + + store.storePagination(pagination); + expect(store.state.pageInfo).toEqual(expectedResult); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/commit_spec.js.es6 b/spec/javascripts/vue_shared/components/commit_spec.js index 15ab10b9b69..df547299d75 100644 --- a/spec/javascripts/vue_shared/components/commit_spec.js.es6 +++ b/spec/javascripts/vue_shared/components/commit_spec.js @@ -1,13 +1,17 @@ -require('~/vue_shared/components/commit'); +import Vue from 'vue'; +import commitComp from '~/vue_shared/components/commit'; describe('Commit component', () => { let props; let component; + let CommitComponent; + + beforeEach(() => { + CommitComponent = Vue.extend(commitComp); + }); it('should render a code-fork icon if it does not represent a tag', () => { - setFixtures('<div class="test-commit-container"></div>'); - component = new window.gl.CommitComponent({ - el: document.querySelector('.test-commit-container'), + component = new CommitComponent({ propsData: { tag: false, commitRef: { @@ -23,15 +27,13 @@ describe('Commit component', () => { username: 'jschatz1', }, }, - }); + }).$mount(); expect(component.$el.querySelector('.icon-container i').classList).toContain('fa-code-fork'); }); describe('Given all the props', () => { beforeEach(() => { - setFixtures('<div class="test-commit-container"></div>'); - props = { tag: true, commitRef: { @@ -49,10 +51,9 @@ describe('Commit component', () => { commitIconSvg: '<svg></svg>', }; - component = new window.gl.CommitComponent({ - el: document.querySelector('.test-commit-container'), + component = new CommitComponent({ propsData: props, - }); + }).$mount(); }); it('should render a tag icon if it represents a tag', () => { @@ -105,7 +106,6 @@ describe('Commit component', () => { describe('When commit title is not provided', () => { it('should render default message', () => { - setFixtures('<div class="test-commit-container"></div>'); props = { tag: false, commitRef: { @@ -118,10 +118,9 @@ describe('Commit component', () => { author: {}, }; - component = new window.gl.CommitComponent({ - el: document.querySelector('.test-commit-container'), + component = new CommitComponent({ propsData: props, - }); + }).$mount(); expect( component.$el.querySelector('.commit-title span').textContent, diff --git a/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js.es6 b/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js index 412abfd5e41..699625cdbb7 100644 --- a/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js.es6 +++ b/spec/javascripts/vue_shared/components/pipelines_table_row_spec.js @@ -1,20 +1,20 @@ -require('~/vue_shared/components/pipelines_table_row'); -const pipeline = require('../../commit/pipelines/mock_data'); +import Vue from 'vue'; +import tableRowComp from '~/vue_shared/components/pipelines_table_row'; +import pipeline from '../../commit/pipelines/mock_data'; describe('Pipelines Table Row', () => { let component; - preloadFixtures('static/environments/element.html.raw'); beforeEach(() => { - loadFixtures('static/environments/element.html.raw'); + const PipelinesTableRowComponent = Vue.extend(tableRowComp); - component = new gl.pipelines.PipelinesTableRowComponent({ + component = new PipelinesTableRowComponent({ el: document.querySelector('.test-dom-element'), propsData: { pipeline, - svgs: {}, + service: {}, }, - }); + }).$mount(); }); it('should render a table row', () => { diff --git a/spec/javascripts/vue_shared/components/pipelines_table_spec.js.es6 b/spec/javascripts/vue_shared/components/pipelines_table_spec.js index 54d81e2ea7d..b0b1df5a753 100644 --- a/spec/javascripts/vue_shared/components/pipelines_table_spec.js.es6 +++ b/spec/javascripts/vue_shared/components/pipelines_table_spec.js @@ -1,24 +1,24 @@ -require('~/vue_shared/components/pipelines_table'); -require('~/lib/utils/datetime_utility'); -const pipeline = require('../../commit/pipelines/mock_data'); +import Vue from 'vue'; +import pipelinesTableComp from '~/vue_shared/components/pipelines_table'; +import '~/lib/utils/datetime_utility'; +import pipeline from '../../commit/pipelines/mock_data'; describe('Pipelines Table', () => { - preloadFixtures('static/environments/element.html.raw'); + let PipelinesTableComponent; beforeEach(() => { - loadFixtures('static/environments/element.html.raw'); + PipelinesTableComponent = Vue.extend(pipelinesTableComp); }); describe('table', () => { let component; beforeEach(() => { - component = new gl.pipelines.PipelinesTableComponent({ - el: document.querySelector('.test-dom-element'), + component = new PipelinesTableComponent({ propsData: { pipelines: [], - svgs: {}, + service: {}, }, - }); + }).$mount(); }); it('should render a table', () => { @@ -37,26 +37,25 @@ describe('Pipelines Table', () => { describe('without data', () => { it('should render an empty table', () => { - const component = new gl.pipelines.PipelinesTableComponent({ - el: document.querySelector('.test-dom-element'), + const component = new PipelinesTableComponent({ propsData: { pipelines: [], - svgs: {}, + service: {}, }, - }); + }).$mount(); expect(component.$el.querySelectorAll('tbody tr').length).toEqual(0); }); }); describe('with data', () => { it('should render rows', () => { - const component = new gl.pipelines.PipelinesTableComponent({ + const component = new PipelinesTableComponent({ el: document.querySelector('.test-dom-element'), propsData: { pipelines: [pipeline], - svgs: {}, + service: {}, }, - }); + }).$mount(); expect(component.$el.querySelectorAll('tbody tr').length).toEqual(1); }); diff --git a/spec/javascripts/vue_shared/components/table_pagination_spec.js.es6 b/spec/javascripts/vue_shared/components/table_pagination_spec.js index dd495cb43bc..a5c3870b3ac 100644 --- a/spec/javascripts/vue_shared/components/table_pagination_spec.js.es6 +++ b/spec/javascripts/vue_shared/components/table_pagination_spec.js @@ -1,24 +1,25 @@ -require('~/lib/utils/common_utils'); -require('~/vue_shared/components/table_pagination'); +import Vue from 'vue'; +import paginationComp from '~/vue_shared/components/table_pagination'; +import '~/lib/utils/common_utils'; describe('Pagination component', () => { let component; + let PaginationComponent; const changeChanges = { one: '', - two: '', }; - const change = (one, two) => { + const change = (one) => { changeChanges.one = one; - changeChanges.two = two; }; - it('should render and start at page 1', () => { - setFixtures('<div class="test-pagination-container"></div>'); + beforeEach(() => { + PaginationComponent = Vue.extend(paginationComp); + }); - component = new window.gl.VueGlPagination({ - el: document.querySelector('.test-pagination-container'), + it('should render and start at page 1', () => { + component = new PaginationComponent({ propsData: { pageInfo: { totalPages: 10, @@ -27,21 +28,17 @@ describe('Pagination component', () => { }, change, }, - }); + }).$mount(); expect(component.$el.classList).toContain('gl-pagination'); component.changePage({ target: { innerText: '1' } }); expect(changeChanges.one).toEqual(1); - expect(changeChanges.two).toEqual(null); }); it('should go to the previous page', () => { - setFixtures('<div class="test-pagination-container"></div>'); - - component = new window.gl.VueGlPagination({ - el: document.querySelector('.test-pagination-container'), + component = new PaginationComponent({ propsData: { pageInfo: { totalPages: 10, @@ -50,19 +47,15 @@ describe('Pagination component', () => { }, change, }, - }); + }).$mount(); component.changePage({ target: { innerText: 'Prev' } }); expect(changeChanges.one).toEqual(1); - expect(changeChanges.two).toEqual(null); }); it('should go to the next page', () => { - setFixtures('<div class="test-pagination-container"></div>'); - - component = new window.gl.VueGlPagination({ - el: document.querySelector('.test-pagination-container'), + component = new PaginationComponent({ propsData: { pageInfo: { totalPages: 10, @@ -71,19 +64,15 @@ describe('Pagination component', () => { }, change, }, - }); + }).$mount(); component.changePage({ target: { innerText: 'Next' } }); expect(changeChanges.one).toEqual(5); - expect(changeChanges.two).toEqual(null); }); it('should go to the last page', () => { - setFixtures('<div class="test-pagination-container"></div>'); - - component = new window.gl.VueGlPagination({ - el: document.querySelector('.test-pagination-container'), + component = new PaginationComponent({ propsData: { pageInfo: { totalPages: 10, @@ -92,19 +81,15 @@ describe('Pagination component', () => { }, change, }, - }); + }).$mount(); component.changePage({ target: { innerText: 'Last >>' } }); expect(changeChanges.one).toEqual(10); - expect(changeChanges.two).toEqual(null); }); it('should go to the first page', () => { - setFixtures('<div class="test-pagination-container"></div>'); - - component = new window.gl.VueGlPagination({ - el: document.querySelector('.test-pagination-container'), + component = new PaginationComponent({ propsData: { pageInfo: { totalPages: 10, @@ -113,19 +98,15 @@ describe('Pagination component', () => { }, change, }, - }); + }).$mount(); component.changePage({ target: { innerText: '<< First' } }); expect(changeChanges.one).toEqual(1); - expect(changeChanges.two).toEqual(null); }); it('should do nothing', () => { - setFixtures('<div class="test-pagination-container"></div>'); - - component = new window.gl.VueGlPagination({ - el: document.querySelector('.test-pagination-container'), + component = new PaginationComponent({ propsData: { pageInfo: { totalPages: 10, @@ -134,12 +115,11 @@ describe('Pagination component', () => { }, change, }, - }); + }).$mount(); component.changePage({ target: { innerText: '...' } }); expect(changeChanges.one).toEqual(1); - expect(changeChanges.two).toEqual(null); }); }); diff --git a/spec/lib/banzai/filter/emoji_filter_spec.rb b/spec/lib/banzai/filter/emoji_filter_spec.rb index c8e62f528df..707212e07fd 100644 --- a/spec/lib/banzai/filter/emoji_filter_spec.rb +++ b/spec/lib/banzai/filter/emoji_filter_spec.rb @@ -14,12 +14,12 @@ describe Banzai::Filter::EmojiFilter, lib: true do it 'replaces supported name emoji' do doc = filter('<p>:heart:</p>') - expect(doc.css('img').first.attr('src')).to eq 'https://foo.com/assets/2764.png' + expect(doc.css('gl-emoji').first.text).to eq '❤' end it 'replaces supported unicode emoji' do doc = filter('<p>❤️</p>') - expect(doc.css('img').first.attr('src')).to eq 'https://foo.com/assets/2764.png' + expect(doc.css('gl-emoji').first.text).to eq '❤' end it 'ignores unsupported emoji' do @@ -30,152 +30,78 @@ describe Banzai::Filter::EmojiFilter, lib: true do it 'correctly encodes the URL' do doc = filter('<p>:+1:</p>') - expect(doc.css('img').first.attr('src')).to eq 'https://foo.com/assets/1F44D.png' + expect(doc.css('gl-emoji').first.text).to eq '👍' end it 'correctly encodes unicode to the URL' do doc = filter('<p>👍</p>') - expect(doc.css('img').first.attr('src')).to eq 'https://foo.com/assets/1F44D.png' + expect(doc.css('gl-emoji').first.text).to eq '👍' end it 'matches at the start of a string' do doc = filter(':+1:') - expect(doc.css('img').size).to eq 1 + expect(doc.css('gl-emoji').size).to eq 1 end it 'unicode matches at the start of a string' do doc = filter("'👍'") - expect(doc.css('img').size).to eq 1 + expect(doc.css('gl-emoji').size).to eq 1 end it 'matches at the end of a string' do doc = filter('This gets a :-1:') - expect(doc.css('img').size).to eq 1 + expect(doc.css('gl-emoji').size).to eq 1 end it 'unicode matches at the end of a string' do doc = filter('This gets a 👍') - expect(doc.css('img').size).to eq 1 + expect(doc.css('gl-emoji').size).to eq 1 end it 'matches with adjacent text' do doc = filter('+1 (:+1:)') - expect(doc.css('img').size).to eq 1 + expect(doc.css('gl-emoji').size).to eq 1 end it 'unicode matches with adjacent text' do doc = filter('+1 (👍)') - expect(doc.css('img').size).to eq 1 + expect(doc.css('gl-emoji').size).to eq 1 end it 'matches multiple emoji in a row' do doc = filter(':see_no_evil::hear_no_evil::speak_no_evil:') - expect(doc.css('img').size).to eq 3 + expect(doc.css('gl-emoji').size).to eq 3 end it 'unicode matches multiple emoji in a row' do doc = filter("'🙈🙉🙊'") - expect(doc.css('img').size).to eq 3 + expect(doc.css('gl-emoji').size).to eq 3 end it 'mixed matches multiple emoji in a row' do doc = filter("'🙈:see_no_evil:🙉:hear_no_evil:🙊:speak_no_evil:'") - expect(doc.css('img').size).to eq 6 + expect(doc.css('gl-emoji').size).to eq 6 end - it 'has a title attribute' do + it 'has a data-name attribute' do doc = filter(':-1:') - expect(doc.css('img').first.attr('title')).to eq ':-1:' + expect(doc.css('gl-emoji').first.attr('data-name')).to eq 'thumbsdown' end - it 'unicode has a title attribute' do - doc = filter("'👎'") - expect(doc.css('img').first.attr('title')).to eq ':thumbsdown:' - end - - it 'has an alt attribute' do + it 'has a data-unicode-version attribute' do doc = filter(':-1:') - expect(doc.css('img').first.attr('alt')).to eq ':-1:' - end - - it 'unicode has an alt attribute' do - doc = filter("'👎'") - expect(doc.css('img').first.attr('alt')).to eq ':thumbsdown:' - end - - it 'has an align attribute' do - doc = filter(':8ball:') - expect(doc.css('img').first.attr('align')).to eq 'absmiddle' - end - - it 'unicode has an align attribute' do - doc = filter("'🎱'") - expect(doc.css('img').first.attr('align')).to eq 'absmiddle' - end - - it 'has an emoji class' do - doc = filter(':cat:') - expect(doc.css('img').first.attr('class')).to eq 'emoji' - end - - it 'unicode has an emoji class' do - doc = filter("'🐱'") - expect(doc.css('img').first.attr('class')).to eq 'emoji' - end - - it 'has height and width attributes' do - doc = filter(':dog:') - img = doc.css('img').first - - expect(img.attr('width')).to eq '20' - expect(img.attr('height')).to eq '20' - end - - it 'unicode has height and width attributes' do - doc = filter("'🐶'") - img = doc.css('img').first - - expect(img.attr('width')).to eq '20' - expect(img.attr('height')).to eq '20' + expect(doc.css('gl-emoji').first.attr('data-unicode-version')).to eq '6.0' end it 'keeps whitespace intact' do doc = filter('This deserves a :+1:, big time.') - expect(doc.to_html).to match(/^This deserves a <img.+>, big time\.\z/) + expect(doc.to_html).to match(/^This deserves a <gl-emoji.+>, big time\.\z/) end it 'unicode keeps whitespace intact' do doc = filter('This deserves a 🎱, big time.') - expect(doc.to_html).to match(/^This deserves a <img.+>, big time\.\z/) - end - - it 'uses a custom asset_root context' do - root = Gitlab.config.gitlab.url + 'gitlab/root' - - doc = filter(':smile:', asset_root: root) - expect(doc.css('img').first.attr('src')).to start_with(root) - end - - it 'uses a custom asset_host context' do - ActionController::Base.asset_host = 'https://cdn.example.com' - - doc = filter(':frowning:', asset_host: 'https://this-is-ignored-i-guess?') - expect(doc.css('img').first.attr('src')).to start_with('https://cdn.example.com') - end - - it 'uses a custom asset_root context' do - root = Gitlab.config.gitlab.url + 'gitlab/root' - - doc = filter("'🎱'", asset_root: root) - expect(doc.css('img').first.attr('src')).to start_with(root) - end - - it 'uses a custom asset_host context' do - ActionController::Base.asset_host = 'https://cdn.example.com' - - doc = filter("'🎱'", asset_host: 'https://this-is-ignored-i-guess?') - expect(doc.css('img').first.attr('src')).to start_with('https://cdn.example.com') + expect(doc.to_html).to match(/^This deserves a <gl-emoji.+>, big time\.\z/) end end diff --git a/spec/lib/banzai/filter/image_link_filter_spec.rb b/spec/lib/banzai/filter/image_link_filter_spec.rb index a2a1ed58d1b..294558b3db2 100644 --- a/spec/lib/banzai/filter/image_link_filter_spec.rb +++ b/spec/lib/banzai/filter/image_link_filter_spec.rb @@ -13,8 +13,8 @@ describe Banzai::Filter::ImageLinkFilter, lib: true do end it 'does not wrap a duplicate link' do - exp = act = %q(<a href="/whatever">#{image('/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg')}</a>) - expect(filter(act).to_html).to eq exp + doc = filter(%Q(<a href="/whatever">#{image('/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg')}</a>)) + expect(doc.to_html).to match /^<a href="\/whatever"><img[^>]*><\/a>$/ end it 'works with external images' do @@ -22,8 +22,8 @@ describe Banzai::Filter::ImageLinkFilter, lib: true do expect(doc.at_css('img')['src']).to eq doc.at_css('a')['href'] end - it 'wraps the image with a link and a div' do - doc = filter(image('/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg')) - expect(doc.to_html).to include('<div class="image-container">') + it 'works with inline images' do + doc = filter(%Q(<p>test #{image('/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg')} inline</p>)) + expect(doc.to_html).to match /^<p>test <a[^>]*><img[^>]*><\/a> inline<\/p>$/ end end diff --git a/spec/lib/banzai/filter/sanitization_filter_spec.rb b/spec/lib/banzai/filter/sanitization_filter_spec.rb index b38e3b17e64..b4cd5f63a15 100644 --- a/spec/lib/banzai/filter/sanitization_filter_spec.rb +++ b/spec/lib/banzai/filter/sanitization_filter_spec.rb @@ -86,6 +86,16 @@ describe Banzai::Filter::SanitizationFilter, lib: true do expect(filter(act).to_html).to eq exp end + it 'allows `summary` elements' do + exp = act = '<summary>summary line</summary>' + expect(filter(act).to_html).to eq exp + end + + it 'allows `details` elements' do + exp = act = '<details>long text goes here</details>' + expect(filter(act).to_html).to eq exp + end + it 'removes `rel` attribute from `a` elements' do act = %q{<a href="#" rel="nofollow">Link</a>} exp = %q{<a href="#">Link</a>} diff --git a/spec/lib/banzai/filter/syntax_highlight_filter_spec.rb b/spec/lib/banzai/filter/syntax_highlight_filter_spec.rb index 69e3c52b35a..63fb1bb25c4 100644 --- a/spec/lib/banzai/filter/syntax_highlight_filter_spec.rb +++ b/spec/lib/banzai/filter/syntax_highlight_filter_spec.rb @@ -6,21 +6,21 @@ describe Banzai::Filter::SyntaxHighlightFilter, lib: true do context "when no language is specified" do it "highlights as plaintext" do result = filter('<pre><code>def fun end</code></pre>') - expect(result.to_html).to eq('<pre class="code highlight js-syntax-highlight plaintext" lang="plaintext" v-pre="true"><code>def fun end</code></pre>') + expect(result.to_html).to eq('<pre class="code highlight js-syntax-highlight plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">def fun end</span></code></pre>') end end context "when a valid language is specified" do it "highlights as that language" do result = filter('<pre><code class="ruby">def fun end</code></pre>') - expect(result.to_html).to eq('<pre class="code highlight js-syntax-highlight ruby" lang="ruby" v-pre="true"><code><span class="k">def</span> <span class="nf">fun</span> <span class="k">end</span></code></pre>') + expect(result.to_html).to eq('<pre class="code highlight js-syntax-highlight ruby" lang="ruby" v-pre="true"><code><span id="LC1" class="line" lang="ruby"><span class="k">def</span> <span class="nf">fun</span> <span class="k">end</span></span></code></pre>') end end context "when an invalid language is specified" do it "highlights as plaintext" do result = filter('<pre><code class="gnuplot">This is a test</code></pre>') - expect(result.to_html).to eq('<pre class="code highlight js-syntax-highlight plaintext" lang="plaintext" v-pre="true"><code>This is a test</code></pre>') + expect(result.to_html).to eq('<pre class="code highlight js-syntax-highlight plaintext" lang="plaintext" v-pre="true"><code><span id="LC1" class="line" lang="plaintext">This is a test</span></code></pre>') end end diff --git a/spec/lib/banzai/filter/user_reference_filter_spec.rb b/spec/lib/banzai/filter/user_reference_filter_spec.rb index d5d128c1907..9873774909e 100644 --- a/spec/lib/banzai/filter/user_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/user_reference_filter_spec.rb @@ -123,6 +123,12 @@ describe Banzai::Filter::UserReferenceFilter, lib: true do expect(doc.css('a').first.attr('href')).to eq urls.group_url(group) end + + it 'has the full group name as a title' do + doc = reference_filter("Hey #{reference}") + + expect(doc.css('a').first.attr('title')).to eq group.full_name + end end it 'links with adjacent text' do diff --git a/spec/lib/bitbucket/collection_spec.rb b/spec/lib/bitbucket/collection_spec.rb index 015a7f80e03..9008cb3e870 100644 --- a/spec/lib/bitbucket/collection_spec.rb +++ b/spec/lib/bitbucket/collection_spec.rb @@ -19,6 +19,6 @@ describe Bitbucket::Collection do it "iterates paginator" do collection = described_class.new(TestPaginator.new) - expect(collection.to_a).to match(["result_1_page_1", "result_2_page_1", "result_1_page_2", "result_2_page_2"]) + expect(collection.to_a).to match(%w(result_1_page_1 result_2_page_1 result_1_page_2 result_2_page_2)) end end diff --git a/spec/lib/bitbucket/representation/repo_spec.rb b/spec/lib/bitbucket/representation/repo_spec.rb index adcd978e1b3..405265cc669 100644 --- a/spec/lib/bitbucket/representation/repo_spec.rb +++ b/spec/lib/bitbucket/representation/repo_spec.rb @@ -29,7 +29,7 @@ describe Bitbucket::Representation::Repo do end describe '#owner_and_slug' do - it { expect(described_class.new({ 'full_name' => 'ben/test' }).owner_and_slug).to eq(['ben', 'test']) } + it { expect(described_class.new({ 'full_name' => 'ben/test' }).owner_and_slug).to eq(%w(ben test)) } end describe '#owner' do @@ -42,7 +42,7 @@ describe Bitbucket::Representation::Repo do describe '#clone_url' do it 'builds url' do - data = { 'links' => { 'clone' => [ { 'name' => 'https', 'href' => 'https://bibucket.org/test/test.git' }] } } + data = { 'links' => { 'clone' => [{ 'name' => 'https', 'href' => 'https://bibucket.org/test/test.git' }] } } expect(described_class.new(data).clone_url('abc')).to eq('https://x-token-auth:abc@bibucket.org/test/test.git') end end diff --git a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb index 68ad429608d..53abc056602 100644 --- a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb +++ b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb @@ -15,9 +15,9 @@ module Ci end describe '#build_attributes' do - describe 'coverage entry' do - subject { described_class.new(config, path).build_attributes(:rspec) } + subject { described_class.new(config, path).build_attributes(:rspec) } + describe 'coverage entry' do describe 'code coverage regexp' do let(:config) do YAML.dump(rspec: { script: 'rspec', @@ -30,6 +30,56 @@ module Ci end end end + + describe 'allow failure entry' do + context 'when job is a manual action' do + context 'when allow_failure is defined' do + let(:config) do + YAML.dump(rspec: { script: 'rspec', + when: 'manual', + allow_failure: false }) + end + + it 'is not allowed to fail' do + expect(subject[:allow_failure]).to be false + end + end + + context 'when allow_failure is not defined' do + let(:config) do + YAML.dump(rspec: { script: 'rspec', + when: 'manual' }) + end + + it 'is allowed to fail' do + expect(subject[:allow_failure]).to be true + end + end + end + + context 'when job is not a manual action' do + context 'when allow_failure is defined' do + let(:config) do + YAML.dump(rspec: { script: 'rspec', + allow_failure: false }) + end + + it 'is not allowed to fail' do + expect(subject[:allow_failure]).to be false + end + end + + context 'when allow_failure is not defined' do + let(:config) do + YAML.dump(rspec: { script: 'rspec' }) + end + + it 'is not allowed to fail' do + expect(subject[:allow_failure]).to be false + end + end + end + end end describe "#builds_for_ref" do @@ -96,7 +146,7 @@ module Ci it "returns builds if only has a list of branches including specified" do config = YAML.dump({ before_script: ["pwd"], - rspec: { script: "rspec", type: type, only: ["master", "deploy"] } + rspec: { script: "rspec", type: type, only: %w(master deploy) } }) config_processor = GitlabCiYamlProcessor.new(config, path) @@ -173,8 +223,8 @@ module Ci it "returns build only for specified type" do config = YAML.dump({ before_script: ["pwd"], - rspec: { script: "rspec", type: "test", only: ["master", "deploy"] }, - staging: { script: "deploy", type: "deploy", only: ["master", "deploy"] }, + rspec: { script: "rspec", type: "test", only: %w(master deploy) }, + staging: { script: "deploy", type: "deploy", only: %w(master deploy) }, production: { script: "deploy", type: "deploy", only: ["master@path", "deploy"] }, }) @@ -252,7 +302,7 @@ module Ci it "does not return builds if except has a list of branches including specified" do config = YAML.dump({ before_script: ["pwd"], - rspec: { script: "rspec", type: type, except: ["master", "deploy"] } + rspec: { script: "rspec", type: type, except: %w(master deploy) } }) config_processor = GitlabCiYamlProcessor.new(config, path) @@ -580,7 +630,7 @@ module Ci context 'when syntax is incorrect' do context 'when variables defined but invalid' do let(:variables) do - [ 'VAR1', 'value1', 'VAR2', 'value2' ] + %w(VAR1 value1 VAR2 value2) end it 'raises error' do @@ -909,7 +959,7 @@ module Ci end context 'dependencies to builds' do - let(:dependencies) { ['build1', 'build2'] } + let(:dependencies) { %w(build1 build2) } it { expect { subject }.not_to raise_error } end @@ -1215,7 +1265,7 @@ EOT end it "returns errors if job stage is not a defined stage" do - config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", type: "acceptance" } }) + config = YAML.dump({ types: %w(build test), rspec: { script: "test", type: "acceptance" } }) expect do GitlabCiYamlProcessor.new(config, path) end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: stage parameter should be build, test") @@ -1257,42 +1307,42 @@ EOT end it "returns errors if job artifacts:name is not an a string" do - config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", artifacts: { name: 1 } } }) + config = YAML.dump({ types: %w(build test), rspec: { script: "test", artifacts: { name: 1 } } }) expect do GitlabCiYamlProcessor.new(config) end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:rspec:artifacts name should be a string") end it "returns errors if job artifacts:when is not an a predefined value" do - config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", artifacts: { when: 1 } } }) + config = YAML.dump({ types: %w(build test), rspec: { script: "test", artifacts: { when: 1 } } }) expect do GitlabCiYamlProcessor.new(config) end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:rspec:artifacts when should be on_success, on_failure or always") end it "returns errors if job artifacts:expire_in is not an a string" do - config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", artifacts: { expire_in: 1 } } }) + config = YAML.dump({ types: %w(build test), rspec: { script: "test", artifacts: { expire_in: 1 } } }) expect do GitlabCiYamlProcessor.new(config) end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:rspec:artifacts expire in should be a duration") end it "returns errors if job artifacts:expire_in is not an a valid duration" do - config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", artifacts: { expire_in: "7 elephants" } } }) + config = YAML.dump({ types: %w(build test), rspec: { script: "test", artifacts: { expire_in: "7 elephants" } } }) expect do GitlabCiYamlProcessor.new(config) end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:rspec:artifacts expire in should be a duration") end it "returns errors if job artifacts:untracked is not an array of strings" do - config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", artifacts: { untracked: "string" } } }) + config = YAML.dump({ types: %w(build test), rspec: { script: "test", artifacts: { untracked: "string" } } }) expect do GitlabCiYamlProcessor.new(config) end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:rspec:artifacts untracked should be a boolean value") end it "returns errors if job artifacts:paths is not an array of strings" do - config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", artifacts: { paths: "string" } } }) + config = YAML.dump({ types: %w(build test), rspec: { script: "test", artifacts: { paths: "string" } } }) expect do GitlabCiYamlProcessor.new(config) end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:rspec:artifacts paths should be an array of strings") @@ -1320,28 +1370,28 @@ EOT end it "returns errors if job cache:key is not an a string" do - config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", cache: { key: 1 } } }) + config = YAML.dump({ types: %w(build test), rspec: { script: "test", cache: { key: 1 } } }) expect do GitlabCiYamlProcessor.new(config) end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:rspec:cache:key config should be a string or symbol") end it "returns errors if job cache:untracked is not an array of strings" do - config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", cache: { untracked: "string" } } }) + config = YAML.dump({ types: %w(build test), rspec: { script: "test", cache: { untracked: "string" } } }) expect do GitlabCiYamlProcessor.new(config) end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:rspec:cache:untracked config should be a boolean value") end it "returns errors if job cache:paths is not an array of strings" do - config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", cache: { paths: "string" } } }) + config = YAML.dump({ types: %w(build test), rspec: { script: "test", cache: { paths: "string" } } }) expect do GitlabCiYamlProcessor.new(config) end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:rspec:cache:paths config should be an array of strings") end it "returns errors if job dependencies is not an array of strings" do - config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", dependencies: "string" } }) + config = YAML.dump({ types: %w(build test), rspec: { script: "test", dependencies: "string" } }) expect do GitlabCiYamlProcessor.new(config) end.to raise_error(GitlabCiYamlProcessor::ValidationError, "jobs:rspec dependencies should be an array of strings") diff --git a/spec/lib/constraints/project_url_constrainer_spec.rb b/spec/lib/constraints/project_url_constrainer_spec.rb index a5251e9a8c2..4f25ad88960 100644 --- a/spec/lib/constraints/project_url_constrainer_spec.rb +++ b/spec/lib/constraints/project_url_constrainer_spec.rb @@ -6,7 +6,7 @@ describe ProjectUrlConstrainer, lib: true do describe '#matches?' do context 'valid request' do - let(:request) { build_request(namespace.path, project.path) } + let(:request) { build_request(namespace.full_path, project.path) } it { expect(subject.matches?(request)).to be_truthy } end @@ -19,7 +19,7 @@ describe ProjectUrlConstrainer, lib: true do end context "project id ending with .git" do - let(:request) { build_request(namespace.path, project.path + '.git') } + let(:request) { build_request(namespace.full_path, project.path + '.git') } it { expect(subject.matches?(request)).to be_falsey } end diff --git a/spec/lib/expand_variables_spec.rb b/spec/lib/expand_variables_spec.rb index 90bc7dad379..90628917943 100644 --- a/spec/lib/expand_variables_spec.rb +++ b/spec/lib/expand_variables_spec.rb @@ -7,58 +7,49 @@ describe ExpandVariables do tests = [ { value: 'key', result: 'key', - variables: [] - }, + variables: [] }, { value: 'key$variable', result: 'key', - variables: [] - }, + variables: [] }, { value: 'key$variable', result: 'keyvalue', variables: [ { key: 'variable', value: 'value' } - ] - }, + ] }, { value: 'key${variable}', result: 'keyvalue', variables: [ { key: 'variable', value: 'value' } - ] - }, + ] }, { value: 'key$variable$variable2', result: 'keyvalueresult', variables: [ { key: 'variable', value: 'value' }, { key: 'variable2', value: 'result' }, - ] - }, + ] }, { value: 'key${variable}${variable2}', result: 'keyvalueresult', variables: [ { key: 'variable', value: 'value' }, { key: 'variable2', value: 'result' } - ] - }, + ] }, { value: 'key$variable2$variable', result: 'keyresultvalue', variables: [ { key: 'variable', value: 'value' }, { key: 'variable2', value: 'result' }, - ] - }, + ] }, { value: 'key${variable2}${variable}', result: 'keyresultvalue', variables: [ { key: 'variable', value: 'value' }, { key: 'variable2', value: 'result' } - ] - }, - { value: 'review/$CI_BUILD_REF_NAME', + ] }, + { value: 'review/$CI_COMMIT_REF_NAME', result: 'review/feature/add-review-apps', variables: [ - { key: 'CI_BUILD_REF_NAME', value: 'feature/add-review-apps' } - ] - }, + { key: 'CI_COMMIT_REF_NAME', value: 'feature/add-review-apps' } + ] }, ] tests.each do |test| diff --git a/spec/lib/extracts_path_spec.rb b/spec/lib/extracts_path_spec.rb index 29c07655ae8..33ab005667a 100644 --- a/spec/lib/extracts_path_spec.rb +++ b/spec/lib/extracts_path_spec.rb @@ -177,12 +177,12 @@ describe ExtractsPath, lib: true do it "extracts a valid commit SHA" do expect(extract_ref('f4b14494ef6abf3d144c28e4af0c20143383e062/CHANGELOG')).to eq( - ['f4b14494ef6abf3d144c28e4af0c20143383e062', 'CHANGELOG'] + %w(f4b14494ef6abf3d144c28e4af0c20143383e062 CHANGELOG) ) end it "falls back to a primitive split for an invalid ref" do - expect(extract_ref('stable/CHANGELOG')).to eq(['stable', 'CHANGELOG']) + expect(extract_ref('stable/CHANGELOG')).to eq(%w(stable CHANGELOG)) end end end diff --git a/spec/lib/git_ref_validator_spec.rb b/spec/lib/git_ref_validator_spec.rb index dc57e94f193..cc8daa535d6 100644 --- a/spec/lib/git_ref_validator_spec.rb +++ b/spec/lib/git_ref_validator_spec.rb @@ -5,6 +5,7 @@ describe Gitlab::GitRefValidator, lib: true do it { expect(Gitlab::GitRefValidator.validate('implement_@all')).to be_truthy } it { expect(Gitlab::GitRefValidator.validate('my_new_feature')).to be_truthy } it { expect(Gitlab::GitRefValidator.validate('#1')).to be_truthy } + it { expect(Gitlab::GitRefValidator.validate('feature/refs/heads/foo')).to be_truthy } it { expect(Gitlab::GitRefValidator.validate('feature/~new/')).to be_falsey } it { expect(Gitlab::GitRefValidator.validate('feature/^new/')).to be_falsey } it { expect(Gitlab::GitRefValidator.validate('feature/:new/')).to be_falsey } @@ -17,4 +18,8 @@ describe Gitlab::GitRefValidator, lib: true do it { expect(Gitlab::GitRefValidator.validate('feature\new')).to be_falsey } it { expect(Gitlab::GitRefValidator.validate('feature//new')).to be_falsey } it { expect(Gitlab::GitRefValidator.validate('feature new')).to be_falsey } + it { expect(Gitlab::GitRefValidator.validate('refs/heads/')).to be_falsey } + it { expect(Gitlab::GitRefValidator.validate('refs/remotes/')).to be_falsey } + it { expect(Gitlab::GitRefValidator.validate('refs/heads/feature')).to be_falsey } + it { expect(Gitlab::GitRefValidator.validate('refs/remotes/origin')).to be_falsey } end diff --git a/spec/lib/gitlab/auth/unique_ips_limiter_spec.rb b/spec/lib/gitlab/auth/unique_ips_limiter_spec.rb new file mode 100644 index 00000000000..94dcddcc30c --- /dev/null +++ b/spec/lib/gitlab/auth/unique_ips_limiter_spec.rb @@ -0,0 +1,57 @@ +require 'spec_helper' + +describe Gitlab::Auth::UniqueIpsLimiter, :redis, lib: true do + include_context 'unique ips sign in limit' + let(:user) { create(:user) } + + describe '#count_unique_ips' do + context 'non unique IPs' do + it 'properly counts them' do + expect(described_class.update_and_return_ips_count(user.id, 'ip1')).to eq(1) + expect(described_class.update_and_return_ips_count(user.id, 'ip1')).to eq(1) + end + end + + context 'unique IPs' do + it 'properly counts them' do + expect(described_class.update_and_return_ips_count(user.id, 'ip2')).to eq(1) + expect(described_class.update_and_return_ips_count(user.id, 'ip3')).to eq(2) + end + end + + it 'resets count after specified time window' do + Timecop.freeze do + expect(described_class.update_and_return_ips_count(user.id, 'ip2')).to eq(1) + expect(described_class.update_and_return_ips_count(user.id, 'ip3')).to eq(2) + + Timecop.travel(Time.now.utc + described_class.config.unique_ips_limit_time_window) do + expect(described_class.update_and_return_ips_count(user.id, 'ip4')).to eq(1) + expect(described_class.update_and_return_ips_count(user.id, 'ip5')).to eq(2) + end + end + end + end + + describe '#limit_user!' do + include_examples 'user login operation with unique ip limit' do + def operation + described_class.limit_user! { user } + end + end + + context 'allow 2 unique ips' do + before { current_application_settings.update!(unique_ips_limit_per_user: 2) } + + it 'blocks user trying to login from third ip' do + change_ip('ip1') + expect(described_class.limit_user! { user }).to eq(user) + + change_ip('ip2') + expect(described_class.limit_user! { user }).to eq(user) + + change_ip('ip3') + expect { described_class.limit_user! { user } }.to raise_error(Gitlab::Auth::TooManyIps) + end + end + end +end diff --git a/spec/lib/gitlab/auth_spec.rb b/spec/lib/gitlab/auth_spec.rb index b234de4c772..03c4879ed6f 100644 --- a/spec/lib/gitlab/auth_spec.rb +++ b/spec/lib/gitlab/auth_spec.rb @@ -3,6 +3,24 @@ require 'spec_helper' describe Gitlab::Auth, lib: true do let(:gl_auth) { described_class } + describe 'constants' do + it 'API_SCOPES contains all scopes for API access' do + expect(subject::API_SCOPES).to eq [:api, :read_user] + end + + it 'OPENID_SCOPES contains all scopes for OpenID Connect' do + expect(subject::OPENID_SCOPES).to eq [:openid] + end + + it 'DEFAULT_SCOPES contains all default scopes' do + expect(subject::DEFAULT_SCOPES).to eq [:api] + end + + it 'OPTIONAL_SCOPES contains all non-default scopes' do + expect(subject::OPTIONAL_SCOPES).to eq [:read_user, :openid] + end + end + describe 'find_for_git_client' do context 'build token' do subject { gl_auth.find_for_git_client('gitlab-ci-token', build.token, project: project, ip: 'ip') } @@ -58,6 +76,14 @@ describe Gitlab::Auth, lib: true do expect(gl_auth.find_for_git_client(user.username, 'password', project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(user, nil, :gitlab_or_ldap, full_authentication_abilities)) end + include_examples 'user login operation with unique ip limit' do + let(:user) { create(:user, password: 'password') } + + def operation + expect(gl_auth.find_for_git_client(user.username, 'password', project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(user, nil, :gitlab_or_ldap, full_authentication_abilities)) + end + end + context 'while using LFS authenticate' do it 'recognizes user lfs tokens' do user = create(:user) @@ -110,25 +136,37 @@ describe Gitlab::Auth, lib: true do end context 'while using personal access tokens as passwords' do - let(:user) { create(:user) } - let(:token_w_api_scope) { create(:personal_access_token, user: user, scopes: ['api']) } - it 'succeeds for personal access tokens with the `api` scope' do - expect(gl_auth).to receive(:rate_limit!).with('ip', success: true, login: user.email) - expect(gl_auth.find_for_git_client(user.email, token_w_api_scope.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(user, nil, :personal_token, full_authentication_abilities)) + personal_access_token = create(:personal_access_token, scopes: ['api']) + + expect(gl_auth).to receive(:rate_limit!).with('ip', success: true, login: '') + expect(gl_auth.find_for_git_client('', personal_access_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(personal_access_token.user, nil, :personal_token, full_authentication_abilities)) + end + + it 'succeeds if it is an impersonation token' do + impersonation_token = create(:personal_access_token, :impersonation, scopes: ['api']) + + expect(gl_auth).to receive(:rate_limit!).with('ip', success: true, login: '') + expect(gl_auth.find_for_git_client('', impersonation_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(impersonation_token.user, nil, :personal_token, full_authentication_abilities)) end it 'fails for personal access tokens with other scopes' do - personal_access_token = create(:personal_access_token, user: user, scopes: ['read_user']) + personal_access_token = create(:personal_access_token, scopes: ['read_user']) - expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: user.email) - expect(gl_auth.find_for_git_client(user.email, personal_access_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(nil, nil)) + expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: '') + expect(gl_auth.find_for_git_client('', personal_access_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(nil, nil)) end - it 'does not try password auth before personal access tokens' do - expect(gl_auth).not_to receive(:find_with_user_password) + it 'fails for impersonation token with other scopes' do + impersonation_token = create(:personal_access_token, scopes: ['read_user']) + + expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: '') + expect(gl_auth.find_for_git_client('', impersonation_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(nil, nil)) + end - gl_auth.find_for_git_client(user.email, token_w_api_scope.token, project: nil, ip: 'ip') + it 'fails if password is nil' do + expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: '') + expect(gl_auth.find_for_git_client('', nil, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(nil, nil)) end end @@ -196,6 +234,24 @@ describe Gitlab::Auth, lib: true do expect( gl_auth.find_with_user_password(username, password) ).not_to eql user end + include_examples 'user login operation with unique ip limit' do + def operation + expect(gl_auth.find_with_user_password(username, password)).to eq(user) + end + end + + it "does not find user in blocked state" do + user.block + + expect( gl_auth.find_with_user_password(username, password) ).not_to eql user + end + + it "does not find user in ldap_blocked state" do + user.ldap_block + + expect( gl_auth.find_with_user_password(username, password) ).not_to eql user + end + context "with ldap enabled" do before do allow(Gitlab::LDAP::Config).to receive(:enabled?).and_return(true) diff --git a/spec/lib/gitlab/award_emoji_spec.rb b/spec/lib/gitlab/award_emoji_spec.rb deleted file mode 100644 index 00a110e31f8..00000000000 --- a/spec/lib/gitlab/award_emoji_spec.rb +++ /dev/null @@ -1,41 +0,0 @@ -require 'spec_helper' - -describe Gitlab::AwardEmoji do - describe '.urls' do - after do - Gitlab::AwardEmoji.instance_variable_set(:@urls, nil) - end - - subject { Gitlab::AwardEmoji.urls } - - it { is_expected.to be_an_instance_of(Array) } - it { is_expected.not_to be_empty } - - context 'every Hash in the Array' do - it 'has the correct keys and values' do - subject.each do |hash| - expect(hash[:name]).to be_an_instance_of(String) - expect(hash[:path]).to be_an_instance_of(String) - end - end - end - - context 'handles relative root' do - it 'includes the full path' do - allow(Gitlab::Application.config).to receive(:relative_url_root).and_return('/gitlab') - - subject.each do |hash| - expect(hash[:name]).to be_an_instance_of(String) - expect(hash[:path]).to start_with('/gitlab') - end - end - end - end - - describe '.emoji_by_category' do - it "only contains known categories" do - undefined_categories = Gitlab::AwardEmoji.emoji_by_category.keys - Gitlab::AwardEmoji::CATEGORIES.keys - expect(undefined_categories).to be_empty - end - end -end diff --git a/spec/lib/gitlab/bitbucket_import/importer_spec.rb b/spec/lib/gitlab/bitbucket_import/importer_spec.rb index 0a2fe5af2c3..a7ee7f53a6b 100644 --- a/spec/lib/gitlab/bitbucket_import/importer_spec.rb +++ b/spec/lib/gitlab/bitbucket_import/importer_spec.rb @@ -87,10 +87,10 @@ describe Gitlab::BitbucketImport::Importer, lib: true do body: issues_statuses_sample_data.to_json) stub_request(:get, "https://api.bitbucket.org/2.0/repositories/namespace/repo?pagelen=50&sort=created_on"). - with(headers: { 'Accept' => '*/*', 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 'Authorization' => 'Bearer', 'User-Agent' => 'Faraday v0.9.2' }). - to_return(status: 200, - body: "", - headers: {}) + with(headers: { 'Accept' => '*/*', 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 'Authorization' => 'Bearer', 'User-Agent' => 'Faraday v0.9.2' }). + to_return(status: 200, + body: "", + headers: {}) sample_issues_statuses.each_with_index do |issue, index| stub_request( diff --git a/spec/lib/gitlab/chat_commands/presenters/issue_show_spec.rb b/spec/lib/gitlab/chat_commands/presenters/issue_show_spec.rb index 5b678d31fce..3916fc704a4 100644 --- a/spec/lib/gitlab/chat_commands/presenters/issue_show_spec.rb +++ b/spec/lib/gitlab/chat_commands/presenters/issue_show_spec.rb @@ -26,6 +26,21 @@ describe Gitlab::ChatCommands::Presenters::IssueShow do end end + context 'with labels' do + let(:label) { create(:label, project: project, title: 'mep') } + let(:label1) { create(:label, project: project, title: 'mop') } + + before do + issue.labels << [label, label1] + end + + it 'shows the labels' do + labels = attachment[:fields].find { |f| f[:title] == 'Labels' } + + expect(labels[:value]).to eq("mep, mop") + end + end + context 'confidential issue' do let(:issue) { create(:issue, project: project) } diff --git a/spec/lib/gitlab/checks/change_access_spec.rb b/spec/lib/gitlab/checks/change_access_spec.rb index cadfbadca10..e22f88b7a32 100644 --- a/spec/lib/gitlab/checks/change_access_spec.rb +++ b/spec/lib/gitlab/checks/change_access_spec.rb @@ -12,8 +12,16 @@ describe Gitlab::Checks::ChangeAccess, lib: true do ref: 'refs/heads/master' } end - - subject { described_class.new(changes, project: project, user_access: user_access).exec } + let(:protocol) { 'ssh' } + + subject do + described_class.new( + changes, + project: project, + user_access: user_access, + protocol: protocol + ).exec + end before { allow(user_access).to receive(:can_do_action?).with(:push_code).and_return(true) } diff --git a/spec/lib/gitlab/ci/build/image_spec.rb b/spec/lib/gitlab/ci/build/image_spec.rb new file mode 100644 index 00000000000..382385dfd6b --- /dev/null +++ b/spec/lib/gitlab/ci/build/image_spec.rb @@ -0,0 +1,67 @@ +require 'spec_helper' + +describe Gitlab::Ci::Build::Image do + let(:job) { create(:ci_build, :no_options) } + + describe '#from_image' do + subject { described_class.from_image(job) } + + context 'when image is defined in job' do + let(:image_name) { 'ruby:2.1' } + let(:job) { create(:ci_build, options: { image: image_name } ) } + + it 'fabricates an object of the proper class' do + is_expected.to be_kind_of(described_class) + end + + it 'populates fabricated object with the proper name attribute' do + expect(subject.name).to eq(image_name) + end + + context 'when image name is empty' do + let(:image_name) { '' } + + it 'does not fabricate an object' do + is_expected.to be_nil + end + end + end + + context 'when image is not defined in job' do + it 'does not fabricate an object' do + is_expected.to be_nil + end + end + end + + describe '#from_services' do + subject { described_class.from_services(job) } + + context 'when services are defined in job' do + let(:service_image_name) { 'postgres' } + let(:job) { create(:ci_build, options: { services: [service_image_name] }) } + + it 'fabricates an non-empty array of objects' do + is_expected.to be_kind_of(Array) + is_expected.not_to be_empty + expect(subject.first.name).to eq(service_image_name) + end + + context 'when service image name is empty' do + let(:service_image_name) { '' } + + it 'fabricates an empty array' do + is_expected.to be_kind_of(Array) + is_expected.to be_empty + end + end + end + + context 'when services are not defined in job' do + it 'fabricates an empty array' do + is_expected.to be_kind_of(Array) + is_expected.to be_empty + end + end + end +end diff --git a/spec/lib/gitlab/ci/build/step_spec.rb b/spec/lib/gitlab/ci/build/step_spec.rb new file mode 100644 index 00000000000..2a314a744ca --- /dev/null +++ b/spec/lib/gitlab/ci/build/step_spec.rb @@ -0,0 +1,39 @@ +require 'spec_helper' + +describe Gitlab::Ci::Build::Step do + let(:job) { create(:ci_build, :no_options, commands: "ls -la\ndate") } + + describe '#from_commands' do + subject { described_class.from_commands(job) } + + it 'fabricates an object' do + expect(subject.name).to eq(:script) + expect(subject.script).to eq(['ls -la', 'date']) + expect(subject.timeout).to eq(job.timeout) + expect(subject.when).to eq('on_success') + expect(subject.allow_failure).to be_falsey + end + end + + describe '#from_after_script' do + subject { described_class.from_after_script(job) } + + context 'when after_script is empty' do + it 'doesn not fabricate an object' do + is_expected.to be_nil + end + end + + context 'when after_script is not empty' do + let(:job) { create(:ci_build, options: { after_script: "ls -la\ndate" }) } + + it 'fabricates an object' do + expect(subject.name).to eq(:after_script) + expect(subject.script).to eq(['ls -la', 'date']) + expect(subject.timeout).to eq(job.timeout) + expect(subject.when).to eq('always') + expect(subject.allow_failure).to be_truthy + end + end + end +end diff --git a/spec/lib/gitlab/ci/config/entry/cache_spec.rb b/spec/lib/gitlab/ci/config/entry/cache_spec.rb index 70a327c5183..2ed120f356a 100644 --- a/spec/lib/gitlab/ci/config/entry/cache_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/cache_spec.rb @@ -24,6 +24,20 @@ describe Gitlab::Ci::Config::Entry::Cache do expect(entry).to be_valid end end + + context 'when key is missing' do + let(:config) do + { untracked: true, + paths: ['some/path/'] } + end + + describe '#value' do + it 'sets key with the default' do + expect(entry.value[:key]) + .to eq(Gitlab::Ci::Config::Entry::Key.default) + end + end + end end context 'when entry value is not correct' do diff --git a/spec/lib/gitlab/ci/config/entry/commands_spec.rb b/spec/lib/gitlab/ci/config/entry/commands_spec.rb index b8b0825a1c7..afa4a089418 100644 --- a/spec/lib/gitlab/ci/config/entry/commands_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/commands_spec.rb @@ -4,7 +4,7 @@ describe Gitlab::Ci::Config::Entry::Commands do let(:entry) { described_class.new(config) } context 'when entry config value is an array' do - let(:config) { ['ls', 'pwd'] } + let(:config) { %w(ls pwd) } describe '#value' do it 'returns array of strings' do diff --git a/spec/lib/gitlab/ci/config/entry/environment_spec.rb b/spec/lib/gitlab/ci/config/entry/environment_spec.rb index 2adbed2154f..c330e609337 100644 --- a/spec/lib/gitlab/ci/config/entry/environment_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/environment_spec.rb @@ -151,8 +151,8 @@ describe Gitlab::Ci::Config::Entry::Environment do context 'when variables are used for environment' do let(:config) do - { name: 'review/$CI_BUILD_REF_NAME', - url: 'https://$CI_BUILD_REF_NAME.review.gitlab.com' } + { name: 'review/$CI_COMMIT_REF_NAME', + url: 'https://$CI_COMMIT_REF_NAME.review.gitlab.com' } end describe '#valid?' do diff --git a/spec/lib/gitlab/ci/config/entry/factory_spec.rb b/spec/lib/gitlab/ci/config/entry/factory_spec.rb index 00dad5d9591..8dd48e4efae 100644 --- a/spec/lib/gitlab/ci/config/entry/factory_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/factory_spec.rb @@ -8,20 +8,20 @@ describe Gitlab::Ci::Config::Entry::Factory do context 'when setting a concrete value' do it 'creates entry with valid value' do entry = factory - .value(['ls', 'pwd']) + .value(%w(ls pwd)) .create! - expect(entry.value).to eq ['ls', 'pwd'] + expect(entry.value).to eq %w(ls pwd) end context 'when setting description' do it 'creates entry with description' do entry = factory - .value(['ls', 'pwd']) + .value(%w(ls pwd)) .with(description: 'test description') .create! - expect(entry.value).to eq ['ls', 'pwd'] + expect(entry.value).to eq %w(ls pwd) expect(entry.description).to eq 'test description' end end @@ -29,7 +29,7 @@ describe Gitlab::Ci::Config::Entry::Factory do context 'when setting key' do it 'creates entry with custom key' do entry = factory - .value(['ls', 'pwd']) + .value(%w(ls pwd)) .with(key: 'test key') .create! @@ -60,13 +60,13 @@ describe Gitlab::Ci::Config::Entry::Factory do end context 'when creating entry with nil value' do - it 'creates an undefined entry' do + it 'creates an unspecified entry' do entry = factory .value(nil) .create! expect(entry) - .to be_an_instance_of Gitlab::Ci::Config::Entry::Unspecified + .not_to be_specified end end diff --git a/spec/lib/gitlab/ci/config/entry/global_spec.rb b/spec/lib/gitlab/ci/config/entry/global_spec.rb index 432a99dce33..684d01e9056 100644 --- a/spec/lib/gitlab/ci/config/entry/global_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/global_spec.rb @@ -21,12 +21,12 @@ describe Gitlab::Ci::Config::Entry::Global do context 'when configuration is valid' do context 'when some entries defined' do let(:hash) do - { before_script: ['ls', 'pwd'], + { before_script: %w(ls pwd), image: 'ruby:2.2', services: ['postgres:9.1', 'mysql:5.5'], variables: { VAR: 'value' }, after_script: ['make clean'], - stages: ['build', 'pages'], + stages: %w(build pages), cache: { key: 'k', untracked: true, paths: ['public/'] }, rspec: { script: %w[rspec ls] }, spinach: { before_script: [], variables: {}, script: 'spinach' } } @@ -89,7 +89,7 @@ describe Gitlab::Ci::Config::Entry::Global do describe '#before_script_value' do it 'returns correct script' do - expect(global.before_script_value).to eq ['ls', 'pwd'] + expect(global.before_script_value).to eq %w(ls pwd) end end @@ -126,7 +126,7 @@ describe Gitlab::Ci::Config::Entry::Global do context 'when deprecated types key defined' do let(:hash) do - { types: ['test', 'deploy'], + { types: %w(test deploy), rspec: { script: 'rspec' } } end @@ -148,13 +148,14 @@ describe Gitlab::Ci::Config::Entry::Global do expect(global.jobs_value).to eq( rspec: { name: :rspec, script: %w[rspec ls], - before_script: ['ls', 'pwd'], + before_script: %w(ls pwd), commands: "ls\npwd\nrspec\nls", image: 'ruby:2.2', services: ['postgres:9.1', 'mysql:5.5'], stage: 'test', cache: { key: 'k', untracked: true, paths: ['public/'] }, variables: { VAR: 'value' }, + ignore: false, after_script: ['make clean'] }, spinach: { name: :spinach, before_script: [], @@ -165,6 +166,7 @@ describe Gitlab::Ci::Config::Entry::Global do stage: 'test', cache: { key: 'k', untracked: true, paths: ['public/'] }, variables: {}, + ignore: false, after_script: ['make clean'] }, ) end @@ -186,7 +188,7 @@ describe Gitlab::Ci::Config::Entry::Global do it 'contains unspecified nodes' do expect(global.descendants.first) - .to be_an_instance_of Gitlab::Ci::Config::Entry::Unspecified + .not_to be_specified end end diff --git a/spec/lib/gitlab/ci/config/entry/job_spec.rb b/spec/lib/gitlab/ci/config/entry/job_spec.rb index d20f4ec207d..9249bb9c172 100644 --- a/spec/lib/gitlab/ci/config/entry/job_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/job_spec.rb @@ -144,6 +144,7 @@ describe Gitlab::Ci::Config::Entry::Job do script: %w[rspec], commands: "ls\npwd\nrspec", stage: 'test', + ignore: false, after_script: %w[cleanup]) end end @@ -159,4 +160,82 @@ describe Gitlab::Ci::Config::Entry::Job do end end end + + describe '#manual_action?' do + context 'when job is a manual action' do + let(:config) { { script: 'deploy', when: 'manual' } } + + it 'is a manual action' do + expect(entry).to be_manual_action + end + end + + context 'when job is not a manual action' do + let(:config) { { script: 'deploy' } } + + it 'is not a manual action' do + expect(entry).not_to be_manual_action + end + end + end + + describe '#ignored?' do + context 'when job is a manual action' do + context 'when it is not specified if job is allowed to fail' do + let(:config) do + { script: 'deploy', when: 'manual' } + end + + it 'is an ignored job' do + expect(entry).to be_ignored + end + end + + context 'when job is allowed to fail' do + let(:config) do + { script: 'deploy', when: 'manual', allow_failure: true } + end + + it 'is an ignored job' do + expect(entry).to be_ignored + end + end + + context 'when job is not allowed to fail' do + let(:config) do + { script: 'deploy', when: 'manual', allow_failure: false } + end + + it 'is not an ignored job' do + expect(entry).not_to be_ignored + end + end + end + + context 'when job is not a manual action' do + context 'when it is not specified if job is allowed to fail' do + let(:config) { { script: 'deploy' } } + + it 'is not an ignored job' do + expect(entry).not_to be_ignored + end + end + + context 'when job is allowed to fail' do + let(:config) { { script: 'deploy', allow_failure: true } } + + it 'is an ignored job' do + expect(entry).to be_ignored + end + end + + context 'when job is not allowed to fail' do + let(:config) { { script: 'deploy', allow_failure: false } } + + it 'is not an ignored job' do + expect(entry).not_to be_ignored + end + end + end + end end diff --git a/spec/lib/gitlab/ci/config/entry/jobs_spec.rb b/spec/lib/gitlab/ci/config/entry/jobs_spec.rb index aaebf783962..7d104372ac6 100644 --- a/spec/lib/gitlab/ci/config/entry/jobs_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/jobs_spec.rb @@ -62,10 +62,12 @@ describe Gitlab::Ci::Config::Entry::Jobs do rspec: { name: :rspec, script: %w[rspec], commands: 'rspec', + ignore: false, stage: 'test' }, spinach: { name: :spinach, script: %w[spinach], commands: 'spinach', + ignore: false, stage: 'test' }) end end diff --git a/spec/lib/gitlab/ci/config/entry/key_spec.rb b/spec/lib/gitlab/ci/config/entry/key_spec.rb index a55e5b4b8ac..5d4de60bc8a 100644 --- a/spec/lib/gitlab/ci/config/entry/key_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/key_spec.rb @@ -21,7 +21,7 @@ describe Gitlab::Ci::Config::Entry::Key do end context 'when entry value is not correct' do - let(:config) { [ 'incorrect' ] } + let(:config) { ['incorrect'] } describe '#errors' do it 'saves errors' do @@ -31,4 +31,10 @@ describe Gitlab::Ci::Config::Entry::Key do end end end + + describe '.default' do + it 'returns default key' do + expect(described_class.default).to eq 'default' + end + end end diff --git a/spec/lib/gitlab/ci/config/entry/paths_spec.rb b/spec/lib/gitlab/ci/config/entry/paths_spec.rb index e60c9aaf661..1d9c5ddee9b 100644 --- a/spec/lib/gitlab/ci/config/entry/paths_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/paths_spec.rb @@ -21,7 +21,7 @@ describe Gitlab::Ci::Config::Entry::Paths do end context 'when entry value is not valid' do - let(:config) { [ 1 ] } + let(:config) { [1] } describe '#errors' do it 'saves errors' do diff --git a/spec/lib/gitlab/ci/config/entry/script_spec.rb b/spec/lib/gitlab/ci/config/entry/script_spec.rb index aa99cee2690..069eaa26422 100644 --- a/spec/lib/gitlab/ci/config/entry/script_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/script_spec.rb @@ -5,7 +5,7 @@ describe Gitlab::Ci::Config::Entry::Script do describe 'validations' do context 'when entry config value is correct' do - let(:config) { ['ls', 'pwd'] } + let(:config) { %w(ls pwd) } describe '#value' do it 'returns array of strings' do diff --git a/spec/lib/gitlab/ci/config/entry/variables_spec.rb b/spec/lib/gitlab/ci/config/entry/variables_spec.rb index 58327d08904..f15f02f403e 100644 --- a/spec/lib/gitlab/ci/config/entry/variables_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/variables_spec.rb @@ -29,7 +29,7 @@ describe Gitlab::Ci::Config::Entry::Variables do end context 'when entry value is not correct' do - let(:config) { [ :VAR, 'test' ] } + let(:config) { [:VAR, 'test'] } describe '#errors' do it 'saves errors' do diff --git a/spec/lib/gitlab/ci/status/build/factory_spec.rb b/spec/lib/gitlab/ci/status/build/factory_spec.rb index 0c40fca0c1a..8b3bd08cf13 100644 --- a/spec/lib/gitlab/ci/status/build/factory_spec.rb +++ b/spec/lib/gitlab/ci/status/build/factory_spec.rb @@ -192,7 +192,7 @@ describe Gitlab::Ci::Status::Build::Factory do let(:build) { create(:ci_build, :playable) } it 'matches correct core status' do - expect(factory.core_status).to be_a Gitlab::Ci::Status::Skipped + expect(factory.core_status).to be_a Gitlab::Ci::Status::Manual end it 'matches correct extended statuses' do @@ -200,12 +200,13 @@ describe Gitlab::Ci::Status::Build::Factory do .to eq [Gitlab::Ci::Status::Build::Play] end - it 'fabricates a core skipped status' do + it 'fabricates a play detailed status' do expect(status).to be_a Gitlab::Ci::Status::Build::Play end it 'fabricates status with correct details' do expect(status.text).to eq 'manual' + expect(status.group).to eq 'manual' expect(status.icon).to eq 'icon_status_manual' expect(status.label).to eq 'manual play action' expect(status).to have_details @@ -218,7 +219,7 @@ describe Gitlab::Ci::Status::Build::Factory do let(:build) { create(:ci_build, :playable, :teardown_environment) } it 'matches correct core status' do - expect(factory.core_status).to be_a Gitlab::Ci::Status::Skipped + expect(factory.core_status).to be_a Gitlab::Ci::Status::Manual end it 'matches correct extended statuses' do @@ -226,12 +227,13 @@ describe Gitlab::Ci::Status::Build::Factory do .to eq [Gitlab::Ci::Status::Build::Stop] end - it 'fabricates a core skipped status' do + it 'fabricates a stop detailed status' do expect(status).to be_a Gitlab::Ci::Status::Build::Stop end it 'fabricates status with correct details' do expect(status.text).to eq 'manual' + expect(status.group).to eq 'manual' expect(status.icon).to eq 'icon_status_manual' expect(status.label).to eq 'manual stop action' expect(status).to have_details diff --git a/spec/lib/gitlab/ci/status/build/play_spec.rb b/spec/lib/gitlab/ci/status/build/play_spec.rb index f3e72ea1796..6c97a4fe5ca 100644 --- a/spec/lib/gitlab/ci/status/build/play_spec.rb +++ b/spec/lib/gitlab/ci/status/build/play_spec.rb @@ -6,22 +6,10 @@ describe Gitlab::Ci::Status::Build::Play do subject { described_class.new(status) } - describe '#text' do - it { expect(subject.text).to eq 'manual' } - end - describe '#label' do it { expect(subject.label).to eq 'manual play action' } end - describe '#icon' do - it { expect(subject.icon).to eq 'icon_status_manual' } - end - - describe '#group' do - it { expect(subject.group).to eq 'manual' } - end - describe 'action details' do let(:user) { create(:user) } let(:build) { create(:ci_build) } diff --git a/spec/lib/gitlab/ci/status/build/stop_spec.rb b/spec/lib/gitlab/ci/status/build/stop_spec.rb index 41c2b624774..8d021c35a69 100644 --- a/spec/lib/gitlab/ci/status/build/stop_spec.rb +++ b/spec/lib/gitlab/ci/status/build/stop_spec.rb @@ -8,22 +8,10 @@ describe Gitlab::Ci::Status::Build::Stop do described_class.new(status) end - describe '#text' do - it { expect(subject.text).to eq 'manual' } - end - describe '#label' do it { expect(subject.label).to eq 'manual stop action' } end - describe '#icon' do - it { expect(subject.icon).to eq 'icon_status_manual' } - end - - describe '#group' do - it { expect(subject.group).to eq 'manual' } - end - describe 'action details' do let(:user) { create(:user) } let(:build) { create(:ci_build) } diff --git a/spec/lib/gitlab/ci/status/canceled_spec.rb b/spec/lib/gitlab/ci/status/canceled_spec.rb index 38412fe2e4f..768f8926f1d 100644 --- a/spec/lib/gitlab/ci/status/canceled_spec.rb +++ b/spec/lib/gitlab/ci/status/canceled_spec.rb @@ -6,7 +6,7 @@ describe Gitlab::Ci::Status::Canceled do end describe '#text' do - it { expect(subject.label).to eq 'canceled' } + it { expect(subject.text).to eq 'canceled' } end describe '#label' do diff --git a/spec/lib/gitlab/ci/status/created_spec.rb b/spec/lib/gitlab/ci/status/created_spec.rb index 6d847484693..e96c13aede3 100644 --- a/spec/lib/gitlab/ci/status/created_spec.rb +++ b/spec/lib/gitlab/ci/status/created_spec.rb @@ -6,7 +6,7 @@ describe Gitlab::Ci::Status::Created do end describe '#text' do - it { expect(subject.label).to eq 'created' } + it { expect(subject.text).to eq 'created' } end describe '#label' do diff --git a/spec/lib/gitlab/ci/status/failed_spec.rb b/spec/lib/gitlab/ci/status/failed_spec.rb index 990d686d22c..e5da0a91159 100644 --- a/spec/lib/gitlab/ci/status/failed_spec.rb +++ b/spec/lib/gitlab/ci/status/failed_spec.rb @@ -6,7 +6,7 @@ describe Gitlab::Ci::Status::Failed do end describe '#text' do - it { expect(subject.label).to eq 'failed' } + it { expect(subject.text).to eq 'failed' } end describe '#label' do diff --git a/spec/lib/gitlab/ci/status/manual_spec.rb b/spec/lib/gitlab/ci/status/manual_spec.rb new file mode 100644 index 00000000000..3fd3727b92d --- /dev/null +++ b/spec/lib/gitlab/ci/status/manual_spec.rb @@ -0,0 +1,23 @@ +require 'spec_helper' + +describe Gitlab::Ci::Status::Manual do + subject do + described_class.new(double('subject'), double('user')) + end + + describe '#text' do + it { expect(subject.text).to eq 'manual' } + end + + describe '#label' do + it { expect(subject.label).to eq 'manual action' } + end + + describe '#icon' do + it { expect(subject.icon).to eq 'icon_status_manual' } + end + + describe '#group' do + it { expect(subject.group).to eq 'manual' } + end +end diff --git a/spec/lib/gitlab/ci/status/pending_spec.rb b/spec/lib/gitlab/ci/status/pending_spec.rb index 7bb6579c317..8d09cf2a05a 100644 --- a/spec/lib/gitlab/ci/status/pending_spec.rb +++ b/spec/lib/gitlab/ci/status/pending_spec.rb @@ -6,7 +6,7 @@ describe Gitlab::Ci::Status::Pending do end describe '#text' do - it { expect(subject.label).to eq 'pending' } + it { expect(subject.text).to eq 'pending' } end describe '#label' do diff --git a/spec/lib/gitlab/ci/status/pipeline/blocked_spec.rb b/spec/lib/gitlab/ci/status/pipeline/blocked_spec.rb new file mode 100644 index 00000000000..1a2b952d374 --- /dev/null +++ b/spec/lib/gitlab/ci/status/pipeline/blocked_spec.rb @@ -0,0 +1,42 @@ +require 'spec_helper' + +describe Gitlab::Ci::Status::Pipeline::Blocked do + let(:pipeline) { double('pipeline') } + + subject do + described_class.new(pipeline) + end + + describe '#text' do + it 'overrides status text' do + expect(subject.text).to eq 'blocked' + end + end + + describe '#label' do + it 'overrides status label' do + expect(subject.label).to eq 'waiting for manual action' + end + end + + describe '.matches?' do + let(:user) { double('user') } + subject { described_class.matches?(pipeline, user) } + + context 'when pipeline is blocked' do + let(:pipeline) { create(:ci_pipeline, :blocked) } + + it 'is a correct match' do + expect(subject).to be true + end + end + + context 'when pipeline is not blocked' do + let(:pipeline) { create(:ci_pipeline, :success) } + + it 'does not match' do + expect(subject).to be false + end + end + end +end diff --git a/spec/lib/gitlab/ci/status/pipeline/factory_spec.rb b/spec/lib/gitlab/ci/status/pipeline/factory_spec.rb index b10a447c27a..dd754b849b2 100644 --- a/spec/lib/gitlab/ci/status/pipeline/factory_spec.rb +++ b/spec/lib/gitlab/ci/status/pipeline/factory_spec.rb @@ -11,7 +11,8 @@ describe Gitlab::Ci::Status::Pipeline::Factory do end context 'when pipeline has a core status' do - HasStatus::AVAILABLE_STATUSES.each do |simple_status| + (HasStatus::AVAILABLE_STATUSES - [HasStatus::BLOCKED_STATUS]) + .each do |simple_status| context "when core status is #{simple_status}" do let(:pipeline) { create(:ci_pipeline, status: simple_status) } @@ -23,7 +24,7 @@ describe Gitlab::Ci::Status::Pipeline::Factory do expect(factory.core_status).to be_a expected_status end - it 'does not matche extended statuses' do + it 'does not match extended statuses' do expect(factory.extended_statuses).to be_empty end @@ -39,6 +40,27 @@ describe Gitlab::Ci::Status::Pipeline::Factory do end end end + + context "when core status is manual" do + let(:pipeline) { create(:ci_pipeline, status: :manual) } + + it "matches manual core status" do + expect(factory.core_status) + .to be_a Gitlab::Ci::Status::Manual + end + + it 'matches a correct extended statuses' do + expect(factory.extended_statuses) + .to eq [Gitlab::Ci::Status::Pipeline::Blocked] + end + + it 'extends core status with common pipeline methods' do + expect(status).to have_details + expect(status).not_to have_action + expect(status.details_path) + .to include "pipelines/#{pipeline.id}" + end + end end context 'when pipeline has warnings' do diff --git a/spec/lib/gitlab/ci/status/running_spec.rb b/spec/lib/gitlab/ci/status/running_spec.rb index 852d6c06baf..10d3bf749c1 100644 --- a/spec/lib/gitlab/ci/status/running_spec.rb +++ b/spec/lib/gitlab/ci/status/running_spec.rb @@ -6,7 +6,7 @@ describe Gitlab::Ci::Status::Running do end describe '#text' do - it { expect(subject.label).to eq 'running' } + it { expect(subject.text).to eq 'running' } end describe '#label' do diff --git a/spec/lib/gitlab/ci/status/skipped_spec.rb b/spec/lib/gitlab/ci/status/skipped_spec.rb index e00b356a24b..10db93d3802 100644 --- a/spec/lib/gitlab/ci/status/skipped_spec.rb +++ b/spec/lib/gitlab/ci/status/skipped_spec.rb @@ -6,7 +6,7 @@ describe Gitlab::Ci::Status::Skipped do end describe '#text' do - it { expect(subject.label).to eq 'skipped' } + it { expect(subject.text).to eq 'skipped' } end describe '#label' do diff --git a/spec/lib/gitlab/ci/status/success_spec.rb b/spec/lib/gitlab/ci/status/success_spec.rb index 4a89e1faf40..230f24b94a4 100644 --- a/spec/lib/gitlab/ci/status/success_spec.rb +++ b/spec/lib/gitlab/ci/status/success_spec.rb @@ -6,7 +6,7 @@ describe Gitlab::Ci::Status::Success do end describe '#text' do - it { expect(subject.label).to eq 'passed' } + it { expect(subject.text).to eq 'passed' } end describe '#label' do diff --git a/spec/lib/gitlab/conflict/file_spec.rb b/spec/lib/gitlab/conflict/file_spec.rb index fbf679c5215..780ac0ad97e 100644 --- a/spec/lib/gitlab/conflict/file_spec.rb +++ b/spec/lib/gitlab/conflict/file_spec.rb @@ -44,7 +44,7 @@ describe Gitlab::Conflict::File, lib: true do it 'returns a file containing only the chosen parts of the resolved sections' do expect(resolved_lines.chunk { |line| line.type || 'both' }.map(&:first)). - to eq(['both', 'new', 'both', 'old', 'both', 'new', 'both']) + to eq(%w(both new both old both new both)) end end @@ -123,7 +123,7 @@ describe Gitlab::Conflict::File, lib: true do it 'sets conflict to true for sections with only changed lines' do conflict_file.sections.select { |section| section[:conflict] }.each do |section| section[:lines].each do |line| - expect(line.type).to be_in(['new', 'old']) + expect(line.type).to be_in(%w(new old)) end end end @@ -251,7 +251,7 @@ FILE describe '#as_json' do it 'includes the blob path for the file' do expect(conflict_file.as_json[:blob_path]). - to eq("/#{project.namespace.to_param}/#{merge_request.project.to_param}/blob/#{our_commit.oid}/files/ruby/regex.rb") + to eq("/#{project.full_path}/blob/#{our_commit.oid}/files/ruby/regex.rb") end it 'includes the blob icon for the file' do diff --git a/spec/lib/gitlab/conflict/parser_spec.rb b/spec/lib/gitlab/conflict/parser_spec.rb index 16eb3766356..2570f95dd21 100644 --- a/spec/lib/gitlab/conflict/parser_spec.rb +++ b/spec/lib/gitlab/conflict/parser_spec.rb @@ -120,43 +120,61 @@ CONFLICT end context 'when the file contents include conflict delimiters' do - it 'raises UnexpectedDelimiter when there is a non-start delimiter first' do - expect { parse_text('=======') }. - to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter) - - expect { parse_text('>>>>>>> README.md') }. - to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter) - - expect { parse_text('>>>>>>> some-other-path.md') }. - not_to raise_error + context 'when there is a non-start delimiter first' do + it 'raises UnexpectedDelimiter when there is a middle delimiter first' do + expect { parse_text('=======') }. + to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter) + end + + it 'raises UnexpectedDelimiter when there is an end delimiter first' do + expect { parse_text('>>>>>>> README.md') }. + to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter) + end + + it 'does not raise when there is an end delimiter for a different path first' do + expect { parse_text('>>>>>>> some-other-path.md') }. + not_to raise_error + end end - it 'raises UnexpectedDelimiter when a start delimiter is followed by a non-middle delimiter' do - start_text = "<<<<<<< README.md\n" - end_text = "\n=======\n>>>>>>> README.md" + context 'when a start delimiter is followed by a non-middle delimiter' do + let(:start_text) { "<<<<<<< README.md\n" } + let(:end_text) { "\n=======\n>>>>>>> README.md" } - expect { parse_text(start_text + '>>>>>>> README.md' + end_text) }. - to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter) + it 'raises UnexpectedDelimiter when it is followed by an end delimiter' do + expect { parse_text(start_text + '>>>>>>> README.md' + end_text) }. + to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter) + end - expect { parse_text(start_text + start_text + end_text) }. - to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter) + it 'raises UnexpectedDelimiter when it is followed by another start delimiter' do + expect { parse_text(start_text + start_text + end_text) }. + to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter) + end - expect { parse_text(start_text + '>>>>>>> some-other-path.md' + end_text) }. - not_to raise_error + it 'does not raise when it is followed by a start delimiter for a different path' do + expect { parse_text(start_text + '>>>>>>> some-other-path.md' + end_text) }. + not_to raise_error + end end - it 'raises UnexpectedDelimiter when a middle delimiter is followed by a non-end delimiter' do - start_text = "<<<<<<< README.md\n=======\n" - end_text = "\n>>>>>>> README.md" + context 'when a middle delimiter is followed by a non-end delimiter' do + let(:start_text) { "<<<<<<< README.md\n=======\n" } + let(:end_text) { "\n>>>>>>> README.md" } - expect { parse_text(start_text + '=======' + end_text) }. - to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter) + it 'raises UnexpectedDelimiter when it is followed by another middle delimiter' do + expect { parse_text(start_text + '=======' + end_text) }. + to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter) + end - expect { parse_text(start_text + start_text + end_text) }. - to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter) + it 'raises UnexpectedDelimiter when it is followed by a start delimiter' do + expect { parse_text(start_text + start_text + end_text) }. + to raise_error(Gitlab::Conflict::Parser::UnexpectedDelimiter) + end - expect { parse_text(start_text + '>>>>>>> some-other-path.md' + end_text) }. - not_to raise_error + it 'does not raise when it is followed by a start delimiter for another path' do + expect { parse_text(start_text + '<<<<<<< some-other-path.md' + end_text) }. + not_to raise_error + end end it 'raises MissingEndDelimiter when there is no end delimiter at the end' do @@ -184,9 +202,20 @@ CONFLICT to raise_error(Gitlab::Conflict::Parser::UnmergeableFile) end - it 'raises UnsupportedEncoding when the file contains non-UTF-8 characters' do - expect { parse_text("a\xC4\xFC".force_encoding(Encoding::ASCII_8BIT)) }. - to raise_error(Gitlab::Conflict::Parser::UnsupportedEncoding) + # All text from Rugged has an encoding of ASCII_8BIT, so force that in + # these strings. + context 'when the file contains UTF-8 characters' do + it 'does not raise' do + expect { parse_text("Espa\xC3\xB1a".force_encoding(Encoding::ASCII_8BIT)) }. + not_to raise_error + end + end + + context 'when the file contains non-UTF-8 characters' do + it 'raises UnsupportedEncoding' do + expect { parse_text("a\xC4\xFC".force_encoding(Encoding::ASCII_8BIT)) }. + to raise_error(Gitlab::Conflict::Parser::UnsupportedEncoding) + end end end end diff --git a/spec/lib/gitlab/database_spec.rb b/spec/lib/gitlab/database_spec.rb index f01c42aff91..4ce4e6e1034 100644 --- a/spec/lib/gitlab/database_spec.rb +++ b/spec/lib/gitlab/database_spec.rb @@ -1,10 +1,16 @@ require 'spec_helper' -class MigrationTest - include Gitlab::Database -end - describe Gitlab::Database, lib: true do + before do + stub_const('MigrationTest', Class.new { include Gitlab::Database }) + end + + describe '.config' do + it 'returns a Hash' do + expect(described_class.config).to be_an_instance_of(Hash) + end + end + describe '.adapter_name' do it 'returns the name of the adapter' do expect(described_class.adapter_name).to be_an_instance_of(String) @@ -119,9 +125,24 @@ describe Gitlab::Database, lib: true do it 'creates a new connection pool with specific pool size' do pool = described_class.create_connection_pool(5) - expect(pool) - .to be_kind_of(ActiveRecord::ConnectionAdapters::ConnectionPool) - expect(pool.spec.config[:pool]).to eq(5) + begin + expect(pool) + .to be_kind_of(ActiveRecord::ConnectionAdapters::ConnectionPool) + + expect(pool.spec.config[:pool]).to eq(5) + ensure + pool.disconnect! + end + end + + it 'allows setting of a custom hostname' do + pool = described_class.create_connection_pool(5, '127.0.0.1') + + begin + expect(pool.spec.config[:host]).to eq('127.0.0.1') + ensure + pool.disconnect! + end end end diff --git a/spec/lib/gitlab/diff/highlight_spec.rb b/spec/lib/gitlab/diff/highlight_spec.rb index 5893485634d..c6bd4e81f4f 100644 --- a/spec/lib/gitlab/diff/highlight_spec.rb +++ b/spec/lib/gitlab/diff/highlight_spec.rb @@ -22,19 +22,19 @@ describe Gitlab::Diff::Highlight, lib: true do end it 'highlights and marks unchanged lines' do - code = %Q{ <span id="LC7" class="line"> <span class="k">def</span> <span class="nf">popen</span><span class="p">(</span><span class="n">cmd</span><span class="p">,</span> <span class="n">path</span><span class="o">=</span><span class="kp">nil</span><span class="p">)</span></span>\n} + code = %Q{ <span id="LC7" class="line" lang="ruby"> <span class="k">def</span> <span class="nf">popen</span><span class="p">(</span><span class="n">cmd</span><span class="p">,</span> <span class="n">path</span><span class="o">=</span><span class="kp">nil</span><span class="p">)</span></span>\n} expect(subject[2].text).to eq(code) end it 'highlights and marks removed lines' do - code = %Q{-<span id="LC9" class="line"> <span class="k">raise</span> <span class="s2">"System commands must be given as an array of strings"</span></span>\n} + code = %Q{-<span id="LC9" class="line" lang="ruby"> <span class="k">raise</span> <span class="s2">"System commands must be given as an array of strings"</span></span>\n} expect(subject[4].text).to eq(code) end it 'highlights and marks added lines' do - code = %Q{+<span id="LC9" class="line"> <span class="k">raise</span> <span class="no"><span class='idiff left'>RuntimeError</span></span><span class="p"><span class='idiff'>,</span></span><span class='idiff right'> </span><span class="s2">"System commands must be given as an array of strings"</span></span>\n} + code = %Q{+<span id="LC9" class="line" lang="ruby"> <span class="k">raise</span> <span class="no"><span class='idiff left'>RuntimeError</span></span><span class="p"><span class='idiff'>,</span></span><span class='idiff right'> </span><span class="s2">"System commands must be given as an array of strings"</span></span>\n} expect(subject[5].text).to eq(code) end @@ -53,21 +53,21 @@ describe Gitlab::Diff::Highlight, lib: true do end it 'marks unchanged lines' do - code = %Q{ def popen(cmd, path=nil)} + code = %q{ def popen(cmd, path=nil)} expect(subject[2].text).to eq(code) expect(subject[2].text).not_to be_html_safe end it 'marks removed lines' do - code = %Q{- raise "System commands must be given as an array of strings"} + code = %q{- raise "System commands must be given as an array of strings"} expect(subject[4].text).to eq(code) expect(subject[4].text).not_to be_html_safe end it 'marks added lines' do - code = %Q{+ raise <span class='idiff left right'>RuntimeError, </span>"System commands must be given as an array of strings"} + code = %q{+ raise <span class='idiff left right'>RuntimeError, </span>"System commands must be given as an array of strings"} expect(subject[5].text).to eq(code) expect(subject[5].text).to be_html_safe diff --git a/spec/lib/gitlab/diff/parser_spec.rb b/spec/lib/gitlab/diff/parser_spec.rb index b983d73f8be..e76128ecd87 100644 --- a/spec/lib/gitlab/diff/parser_spec.rb +++ b/spec/lib/gitlab/diff/parser_spec.rb @@ -91,6 +91,54 @@ eos end end + describe '\ No newline at end of file' do + it "parses nonewline in one file correctly" do + first_nonewline_diff = <<~END + --- a/test + +++ b/test + @@ -1,2 +1,2 @@ + +ipsum + lorem + -ipsum + \\ No newline at end of file + END + lines = parser.parse(first_nonewline_diff.lines).to_a + + expect(lines[0].type).to eq('new') + expect(lines[0].text).to eq('+ipsum') + expect(lines[2].type).to eq('old') + expect(lines[3].type).to eq('old-nonewline') + expect(lines[1].old_pos).to eq(1) + expect(lines[1].new_pos).to eq(2) + end + + it "parses nonewline in two files correctly" do + both_nonewline_diff = <<~END + --- a/test + +++ b/test + @@ -1,2 +1,2 @@ + -lorem + -ipsum + \\ No newline at end of file + +ipsum + +lorem + \\ No newline at end of file + END + lines = parser.parse(both_nonewline_diff.lines).to_a + + expect(lines[0].type).to eq('old') + expect(lines[1].type).to eq('old') + expect(lines[2].type).to eq('old-nonewline') + expect(lines[5].type).to eq('new-nonewline') + expect(lines[3].text).to eq('+ipsum') + expect(lines[3].old_pos).to eq(3) + expect(lines[3].new_pos).to eq(1) + expect(lines[4].text).to eq('+lorem') + expect(lines[4].old_pos).to eq(3) + expect(lines[4].new_pos).to eq(2) + end + end + context 'when lines is empty' do it { expect(parser.parse([])).to eq([]) } it { expect(parser.parse(nil)).to eq([]) } diff --git a/spec/lib/gitlab/etag_caching/middleware_spec.rb b/spec/lib/gitlab/etag_caching/middleware_spec.rb new file mode 100644 index 00000000000..8b5bfc4dbb0 --- /dev/null +++ b/spec/lib/gitlab/etag_caching/middleware_spec.rb @@ -0,0 +1,163 @@ +require 'spec_helper' + +describe Gitlab::EtagCaching::Middleware do + let(:app) { double(:app) } + let(:middleware) { described_class.new(app) } + let(:app_status_code) { 200 } + let(:if_none_match) { nil } + let(:enabled_path) { '/gitlab-org/gitlab-ce/noteable/issue/1/notes' } + + context 'when ETag caching is not enabled for current route' do + let(:path) { '/gitlab-org/gitlab-ce/tree/master/noteable/issue/1/notes' } + + before do + mock_app_response + end + + it 'does not add ETag header' do + _, headers, _ = middleware.call(build_env(path, if_none_match)) + + expect(headers['ETag']).to be_nil + end + + it 'passes status code from app' do + status, _, _ = middleware.call(build_env(path, if_none_match)) + + expect(status).to eq app_status_code + end + end + + context 'when there is no ETag in store for given resource' do + let(:path) { enabled_path } + + before do + mock_app_response + mock_value_in_store(nil) + end + + it 'generates ETag' do + expect_any_instance_of(Gitlab::EtagCaching::Store) + .to receive(:touch).and_return('123') + + middleware.call(build_env(path, if_none_match)) + end + + context 'when If-None-Match header was specified' do + let(:if_none_match) { 'W/"abc"' } + + it 'tracks "etag_caching_key_not_found" event' do + expect(Gitlab::Metrics).to receive(:add_event) + .with(:etag_caching_middleware_used) + expect(Gitlab::Metrics).to receive(:add_event) + .with(:etag_caching_key_not_found) + + middleware.call(build_env(path, if_none_match)) + end + end + end + + context 'when there is ETag in store for given resource' do + let(:path) { enabled_path } + + before do + mock_app_response + mock_value_in_store('123') + end + + it 'returns this value as header' do + _, headers, _ = middleware.call(build_env(path, if_none_match)) + + expect(headers['ETag']).to eq 'W/"123"' + end + end + + context 'when If-None-Match header matches ETag in store' do + let(:path) { enabled_path } + let(:if_none_match) { 'W/"123"' } + + before do + mock_value_in_store('123') + end + + it 'does not call app' do + expect(app).not_to receive(:call) + + middleware.call(build_env(path, if_none_match)) + end + + it 'returns status code 304' do + status, _, _ = middleware.call(build_env(path, if_none_match)) + + expect(status).to eq 304 + end + + it 'tracks "etag_caching_cache_hit" event' do + expect(Gitlab::Metrics).to receive(:add_event) + .with(:etag_caching_middleware_used) + expect(Gitlab::Metrics).to receive(:add_event) + .with(:etag_caching_cache_hit) + + middleware.call(build_env(path, if_none_match)) + end + end + + context 'when If-None-Match header does not match ETag in store' do + let(:path) { enabled_path } + let(:if_none_match) { 'W/"abc"' } + + before do + mock_value_in_store('123') + end + + it 'calls app' do + expect(app).to receive(:call).and_return([app_status_code, {}, ['body']]) + + middleware.call(build_env(path, if_none_match)) + end + + it 'tracks "etag_caching_resource_changed" event' do + mock_app_response + + expect(Gitlab::Metrics).to receive(:add_event) + .with(:etag_caching_middleware_used) + expect(Gitlab::Metrics).to receive(:add_event) + .with(:etag_caching_resource_changed) + + middleware.call(build_env(path, if_none_match)) + end + end + + context 'when If-None-Match header is not specified' do + let(:path) { enabled_path } + + before do + mock_value_in_store('123') + mock_app_response + end + + it 'tracks "etag_caching_header_missing" event' do + expect(Gitlab::Metrics).to receive(:add_event) + .with(:etag_caching_middleware_used) + expect(Gitlab::Metrics).to receive(:add_event) + .with(:etag_caching_header_missing) + + middleware.call(build_env(path, if_none_match)) + end + end + + def mock_app_response + allow(app).to receive(:call).and_return([app_status_code, {}, ['body']]) + end + + def mock_value_in_store(value) + allow_any_instance_of(Gitlab::EtagCaching::Store) + .to receive(:get).and_return(value) + end + + def build_env(path, if_none_match) + { + 'PATH_INFO' => path, + 'HTTP_IF_NONE_MATCH' => if_none_match + } + end +end diff --git a/spec/lib/gitlab/git/blob_snippet_spec.rb b/spec/lib/gitlab/git/blob_snippet_spec.rb index 79b1311ac91..17d6be470ac 100644 --- a/spec/lib/gitlab/git/blob_snippet_spec.rb +++ b/spec/lib/gitlab/git/blob_snippet_spec.rb @@ -11,7 +11,7 @@ describe Gitlab::Git::BlobSnippet, seed_helper: true do end context 'present lines' do - let(:snippet) { Gitlab::Git::BlobSnippet.new('master', ['wow', 'much'], 1, 'wow.rb') } + let(:snippet) { Gitlab::Git::BlobSnippet.new('master', %w(wow much), 1, 'wow.rb') } it { expect(snippet.data).to eq("wow\nmuch") } end diff --git a/spec/lib/gitlab/git/blob_spec.rb b/spec/lib/gitlab/git/blob_spec.rb index 84f79ec2391..8049e2c120d 100644 --- a/spec/lib/gitlab/git/blob_spec.rb +++ b/spec/lib/gitlab/git/blob_spec.rb @@ -222,191 +222,6 @@ describe Gitlab::Git::Blob, seed_helper: true do end end - describe :commit do - let(:repository) { Gitlab::Git::Repository.new(TEST_REPO_PATH) } - - let(:commit_options) do - { - file: { - content: 'Lorem ipsum...', - path: 'documents/story.txt' - }, - author: { - email: 'user@example.com', - name: 'Test User', - time: Time.now - }, - committer: { - email: 'user@example.com', - name: 'Test User', - time: Time.now - }, - commit: { - message: 'Wow such commit', - branch: 'fix-mode' - } - } - end - - let(:commit_sha) { Gitlab::Git::Blob.commit(repository, commit_options) } - let(:commit) { repository.lookup(commit_sha) } - - it 'should add file with commit' do - # Commit message valid - expect(commit.message).to eq('Wow such commit') - - tree = commit.tree.to_a.find { |tree| tree[:name] == 'documents' } - - # Directory was created - expect(tree[:type]).to eq(:tree) - - # File was created - expect(repository.lookup(tree[:oid]).first[:name]).to eq('story.txt') - end - - describe "ref updating" do - it 'creates a commit but does not udate a ref' do - commit_opts = commit_options.tap{ |opts| opts[:commit][:update_ref] = false} - commit_sha = Gitlab::Git::Blob.commit(repository, commit_opts) - commit = repository.lookup(commit_sha) - - # Commit message valid - expect(commit.message).to eq('Wow such commit') - - # Does not update any related ref - expect(repository.lookup("fix-mode").oid).not_to eq(commit.oid) - expect(repository.lookup("HEAD").oid).not_to eq(commit.oid) - end - end - - describe 'reject updates' do - it 'should reject updates' do - commit_options[:file][:update] = false - commit_options[:file][:path] = 'files/executables/ls' - - expect{ commit_sha }.to raise_error('Filename already exists; update not allowed') - end - end - - describe 'file modes' do - it 'should preserve file modes with commit' do - commit_options[:file][:path] = 'files/executables/ls' - - entry = Gitlab::Git::Blob::find_entry_by_path(repository, commit.tree.oid, commit_options[:file][:path]) - expect(entry[:filemode]).to eq(0100755) - end - end - end - - describe :rename do - let(:repository) { Gitlab::Git::Repository.new(TEST_NORMAL_REPO_PATH) } - let(:rename_options) do - { - file: { - path: 'NEWCONTRIBUTING.md', - previous_path: 'CONTRIBUTING.md', - content: 'Lorem ipsum...', - update: true - }, - author: { - email: 'user@example.com', - name: 'Test User', - time: Time.now - }, - committer: { - email: 'user@example.com', - name: 'Test User', - time: Time.now - }, - commit: { - message: 'Rename readme', - branch: 'master' - } - } - end - - let(:rename_options2) do - { - file: { - content: 'Lorem ipsum...', - path: 'bin/new_executable', - previous_path: 'bin/executable', - }, - author: { - email: 'user@example.com', - name: 'Test User', - time: Time.now - }, - committer: { - email: 'user@example.com', - name: 'Test User', - time: Time.now - }, - commit: { - message: 'Updates toberenamed.txt', - branch: 'master', - update_ref: false - } - } - end - - it 'maintains file permissions when renaming' do - mode = 0o100755 - - Gitlab::Git::Blob.rename(repository, rename_options2) - - expect(repository.rugged.index.get(rename_options2[:file][:path])[:mode]).to eq(mode) - end - - it 'renames the file with commit and not change file permissions' do - ref = rename_options[:commit][:branch] - - expect(repository.rugged.index.get('CONTRIBUTING.md')).not_to be_nil - expect { Gitlab::Git::Blob.rename(repository, rename_options) }.to change { repository.commit_count(ref) }.by(1) - - expect(repository.rugged.index.get('CONTRIBUTING.md')).to be_nil - expect(repository.rugged.index.get('NEWCONTRIBUTING.md')).not_to be_nil - end - end - - describe :remove do - let(:repository) { Gitlab::Git::Repository.new(TEST_REPO_PATH) } - - let(:commit_options) do - { - file: { - path: 'README.md' - }, - author: { - email: 'user@example.com', - name: 'Test User', - time: Time.now - }, - committer: { - email: 'user@example.com', - name: 'Test User', - time: Time.now - }, - commit: { - message: 'Remove readme', - branch: 'feature' - } - } - end - - let(:commit_sha) { Gitlab::Git::Blob.remove(repository, commit_options) } - let(:commit) { repository.lookup(commit_sha) } - let(:blob) { Gitlab::Git::Blob.find(repository, commit_sha, "README.md") } - - it 'should remove file with commit' do - # Commit message valid - expect(commit.message).to eq('Remove readme') - - # File was removed - expect(blob).to be_nil - end - end - describe :lfs_pointers do context 'file a valid lfs pointer' do let(:blob) do diff --git a/spec/lib/gitlab/git/diff_collection_spec.rb b/spec/lib/gitlab/git/diff_collection_spec.rb index 4fa72c565ae..47bdd7310d5 100644 --- a/spec/lib/gitlab/git/diff_collection_spec.rb +++ b/spec/lib/gitlab/git/diff_collection_spec.rb @@ -365,7 +365,7 @@ describe Gitlab::Git::DiffCollection, seed_helper: true do end context 'when go over safe limits on files' do - let(:iterator) { [ fake_diff(1, 1) ] * 4 } + let(:iterator) { [fake_diff(1, 1)] * 4 } before(:each) do stub_const('Gitlab::Git::DiffCollection::DEFAULT_LIMITS', { max_files: 2, max_lines: max_lines }) diff --git a/spec/lib/gitlab/git/diff_spec.rb b/spec/lib/gitlab/git/diff_spec.rb index 4c55532d165..992126ef153 100644 --- a/spec/lib/gitlab/git/diff_spec.rb +++ b/spec/lib/gitlab/git/diff_spec.rb @@ -109,6 +109,43 @@ EOT end end end + + context 'using a Gitaly::CommitDiffResponse' do + let(:diff) do + described_class.new( + Gitaly::CommitDiffResponse.new( + to_path: ".gitmodules", + from_path: ".gitmodules", + old_mode: 0100644, + new_mode: 0100644, + from_id: '357406f3075a57708d0163752905cc1576fceacc', + to_id: '8e5177d718c561d36efde08bad36b43687ee6bf0', + raw_chunks: raw_chunks, + ) + ) + end + + context 'with a small diff' do + let(:raw_chunks) { [@raw_diff_hash[:diff]] } + + it 'initializes the diff' do + expect(diff.to_hash).to eq(@raw_diff_hash) + end + + it 'does not prune the diff' do + expect(diff).not_to be_too_large + end + end + + context 'using a diff that is too large' do + let(:raw_chunks) { ['a' * 204800] } + + it 'prunes the diff' do + expect(diff.diff).to be_empty + expect(diff).to be_too_large + end + end + end end describe 'straight diffs' do diff --git a/spec/lib/gitlab/git/index_spec.rb b/spec/lib/gitlab/git/index_spec.rb new file mode 100644 index 00000000000..d0c7ca60ddc --- /dev/null +++ b/spec/lib/gitlab/git/index_spec.rb @@ -0,0 +1,220 @@ +require 'spec_helper' + +describe Gitlab::Git::Index, seed_helper: true do + let(:repository) { Gitlab::Git::Repository.new(TEST_REPO_PATH) } + let(:index) { described_class.new(repository) } + + before do + index.read_tree(repository.lookup('master').tree) + end + + describe '#create' do + let(:options) do + { + content: 'Lorem ipsum...', + file_path: 'documents/story.txt' + } + end + + context 'when no file at that path exists' do + it 'creates the file in the index' do + index.create(options) + + entry = index.get(options[:file_path]) + + expect(entry).not_to be_nil + expect(repository.lookup(entry[:oid]).content).to eq(options[:content]) + end + end + + context 'when a file at that path exists' do + before do + options[:file_path] = 'files/executables/ls' + end + + it 'raises an error' do + expect { index.create(options) }.to raise_error('Filename already exists') + end + end + + context 'when content is in base64' do + before do + options[:content] = Base64.encode64(options[:content]) + options[:encoding] = 'base64' + end + + it 'decodes base64' do + index.create(options) + + entry = index.get(options[:file_path]) + expect(repository.lookup(entry[:oid]).content).to eq(Base64.decode64(options[:content])) + end + end + + context 'when content contains CRLF' do + before do + repository.autocrlf = :input + options[:content] = "Hello,\r\nWorld" + end + + it 'converts to LF' do + index.create(options) + + entry = index.get(options[:file_path]) + expect(repository.lookup(entry[:oid]).content).to eq("Hello,\nWorld") + end + end + end + + describe '#create_dir' do + let(:options) do + { + file_path: 'newdir' + } + end + + context 'when no file or dir at that path exists' do + it 'creates the dir in the index' do + index.create_dir(options) + + entry = index.get(options[:file_path] + '/.gitkeep') + + expect(entry).not_to be_nil + end + end + + context 'when a file at that path exists' do + before do + options[:file_path] = 'files/executables/ls' + end + + it 'raises an error' do + expect { index.create_dir(options) }.to raise_error('Directory already exists as a file') + end + end + + context 'when a directory at that path exists' do + before do + options[:file_path] = 'files/executables' + end + + it 'raises an error' do + expect { index.create_dir(options) }.to raise_error('Directory already exists') + end + end + end + + describe '#update' do + let(:options) do + { + content: 'Lorem ipsum...', + file_path: 'README.md' + } + end + + context 'when no file at that path exists' do + before do + options[:file_path] = 'documents/story.txt' + end + + it 'raises an error' do + expect { index.update(options) }.to raise_error("File doesn't exist") + end + end + + context 'when a file at that path exists' do + it 'updates the file in the index' do + index.update(options) + + entry = index.get(options[:file_path]) + + expect(repository.lookup(entry[:oid]).content).to eq(options[:content]) + end + + it 'preserves file mode' do + options[:file_path] = 'files/executables/ls' + + index.update(options) + + entry = index.get(options[:file_path]) + + expect(entry[:mode]).to eq(0100755) + end + end + end + + describe '#move' do + let(:options) do + { + content: 'Lorem ipsum...', + previous_path: 'README.md', + file_path: 'NEWREADME.md' + } + end + + context 'when no file at that path exists' do + it 'raises an error' do + options[:previous_path] = 'documents/story.txt' + + expect { index.move(options) }.to raise_error("File doesn't exist") + end + end + + context 'when a file at that path exists' do + it 'removes the old file in the index' do + index.move(options) + + entry = index.get(options[:previous_path]) + + expect(entry).to be_nil + end + + it 'creates the new file in the index' do + index.move(options) + + entry = index.get(options[:file_path]) + + expect(entry).not_to be_nil + expect(repository.lookup(entry[:oid]).content).to eq(options[:content]) + end + + it 'preserves file mode' do + options[:previous_path] = 'files/executables/ls' + + index.move(options) + + entry = index.get(options[:file_path]) + + expect(entry[:mode]).to eq(0100755) + end + end + end + + describe '#delete' do + let(:options) do + { + file_path: 'README.md' + } + end + + context 'when no file at that path exists' do + before do + options[:file_path] = 'documents/story.txt' + end + + it 'raises an error' do + expect { index.delete(options) }.to raise_error("File doesn't exist") + end + end + + context 'when a file at that path exists' do + it 'removes the file in the index' do + index.delete(options) + + entry = index.get(options[:file_path]) + + expect(entry).to be_nil + end + end + end +end diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index 2a915bf426f..9ed43da1116 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -47,7 +47,7 @@ describe Gitlab::Git::Repository, seed_helper: true do end end - describe :branch_names do + describe '#branch_names' do subject { repository.branch_names } it 'has SeedRepo::Repo::BRANCHES.size elements' do @@ -57,7 +57,7 @@ describe Gitlab::Git::Repository, seed_helper: true do it { is_expected.not_to include("branch-from-space") } end - describe :tag_names do + describe '#tag_names' do subject { repository.tag_names } it { is_expected.to be_kind_of Array } @@ -78,49 +78,69 @@ describe Gitlab::Git::Repository, seed_helper: true do it { expect(metadata['ArchivePath']).to end_with extenstion } end - describe :archive do + describe '#archive_prefix' do + let(:project_name) { 'project-name'} + + before do + expect(repository).to receive(:name).once.and_return(project_name) + end + + it 'returns parameterised string for a ref containing slashes' do + prefix = repository.archive_prefix('test/branch', 'SHA') + + expect(prefix).to eq("#{project_name}-test-branch-SHA") + end + + it 'returns correct string for a ref containing dots' do + prefix = repository.archive_prefix('test.branch', 'SHA') + + expect(prefix).to eq("#{project_name}-test.branch-SHA") + end + end + + describe '#archive' do let(:metadata) { repository.archive_metadata('master', '/tmp') } it_should_behave_like 'archive check', '.tar.gz' end - describe :archive_zip do + describe '#archive_zip' do let(:metadata) { repository.archive_metadata('master', '/tmp', 'zip') } it_should_behave_like 'archive check', '.zip' end - describe :archive_bz2 do + describe '#archive_bz2' do let(:metadata) { repository.archive_metadata('master', '/tmp', 'tbz2') } it_should_behave_like 'archive check', '.tar.bz2' end - describe :archive_fallback do + describe '#archive_fallback' do let(:metadata) { repository.archive_metadata('master', '/tmp', 'madeup') } it_should_behave_like 'archive check', '.tar.gz' end - describe :size do + describe '#size' do subject { repository.size } it { is_expected.to be < 2 } end - describe :has_commits? do + describe '#has_commits?' do it { expect(repository.has_commits?).to be_truthy } end - describe :empty? do + describe '#empty?' do it { expect(repository.empty?).to be_falsey } end - describe :bare? do + describe '#bare?' do it { expect(repository.bare?).to be_truthy } end - describe :heads do + describe '#heads' do let(:heads) { repository.heads } subject { heads } @@ -147,7 +167,7 @@ describe Gitlab::Git::Repository, seed_helper: true do end end - describe :ref_names do + describe '#ref_names' do let(:ref_names) { repository.ref_names } subject { ref_names } @@ -164,7 +184,7 @@ describe Gitlab::Git::Repository, seed_helper: true do end end - describe :search_files do + describe '#search_files' do let(:results) { repository.search_files('rails', 'master') } subject { results } @@ -200,7 +220,7 @@ describe Gitlab::Git::Repository, seed_helper: true do end end - context :submodules do + context '#submodules' do let(:repository) { Gitlab::Git::Repository.new(TEST_REPO_PATH) } context 'where repo has submodules' do @@ -264,7 +284,7 @@ describe Gitlab::Git::Repository, seed_helper: true do end end - describe :commit_count do + describe '#commit_count' do it { expect(repository.commit_count("master")).to eq(25) } it { expect(repository.commit_count("feature")).to eq(9) } end @@ -493,7 +513,7 @@ describe Gitlab::Git::Repository, seed_helper: true do describe "#remote_add" do before(:all) do @repo = Gitlab::Git::Repository.new(TEST_MUTABLE_REPO_PATH) - @repo.remote_add("new_remote", SeedHelper::GITLAB_URL) + @repo.remote_add("new_remote", SeedHelper::GITLAB_GIT_TEST_REPO_URL) end it "should add the remote" do @@ -529,7 +549,7 @@ describe Gitlab::Git::Repository, seed_helper: true do commit_with_new_name = nil rename_commit = nil - before(:all) do + before(:context) do # Add new commits so that there's a renamed file in the commit history repo = Gitlab::Git::Repository.new(TEST_REPO_PATH).rugged @@ -538,49 +558,119 @@ describe Gitlab::Git::Repository, seed_helper: true do commit_with_new_name = new_commit_edit_new_file(repo) end + after(:context) do + # Erase our commits so other tests get the original repo + repo = Gitlab::Git::Repository.new(TEST_REPO_PATH).rugged + repo.references.update("refs/heads/master", SeedRepo::LastCommit::ID) + end + context "where 'follow' == true" do - options = { ref: "master", follow: true } + let(:options) { { ref: "master", follow: true } } context "and 'path' is a directory" do - let(:log_commits) do - repository.log(options.merge(path: "encoding")) - end + it "does not follow renames" do + log_commits = repository.log(options.merge(path: "encoding")) - it "should not follow renames" do - expect(log_commits).to include(commit_with_new_name) - expect(log_commits).to include(rename_commit) - expect(log_commits).not_to include(commit_with_old_name) + aggregate_failures do + expect(log_commits).to include(commit_with_new_name) + expect(log_commits).to include(rename_commit) + expect(log_commits).not_to include(commit_with_old_name) + end end end context "and 'path' is a file that matches the new filename" do - let(:log_commits) do - repository.log(options.merge(path: "encoding/CHANGELOG")) + context 'without offset' do + it "follows renames" do + log_commits = repository.log(options.merge(path: "encoding/CHANGELOG")) + + aggregate_failures do + expect(log_commits).to include(commit_with_new_name) + expect(log_commits).to include(rename_commit) + expect(log_commits).to include(commit_with_old_name) + end + end end - it "should follow renames" do - expect(log_commits).to include(commit_with_new_name) - expect(log_commits).to include(rename_commit) - expect(log_commits).to include(commit_with_old_name) + context 'with offset=1' do + it "follows renames and skip the latest commit" do + log_commits = repository.log(options.merge(path: "encoding/CHANGELOG", offset: 1)) + + aggregate_failures do + expect(log_commits).not_to include(commit_with_new_name) + expect(log_commits).to include(rename_commit) + expect(log_commits).to include(commit_with_old_name) + end + end + end + + context 'with offset=1', 'and limit=1' do + it "follows renames, skip the latest commit and return only one commit" do + log_commits = repository.log(options.merge(path: "encoding/CHANGELOG", offset: 1, limit: 1)) + + expect(log_commits).to contain_exactly(rename_commit) + end + end + + context 'with offset=1', 'and limit=2' do + it "follows renames, skip the latest commit and return only two commits" do + log_commits = repository.log(options.merge(path: "encoding/CHANGELOG", offset: 1, limit: 2)) + + aggregate_failures do + expect(log_commits).to contain_exactly(rename_commit, commit_with_old_name) + end + end + end + + context 'with offset=2' do + it "follows renames and skip the latest commit" do + log_commits = repository.log(options.merge(path: "encoding/CHANGELOG", offset: 2)) + + aggregate_failures do + expect(log_commits).not_to include(commit_with_new_name) + expect(log_commits).not_to include(rename_commit) + expect(log_commits).to include(commit_with_old_name) + end + end + end + + context 'with offset=2', 'and limit=1' do + it "follows renames, skip the two latest commit and return only one commit" do + log_commits = repository.log(options.merge(path: "encoding/CHANGELOG", offset: 2, limit: 1)) + + expect(log_commits).to contain_exactly(commit_with_old_name) + end + end + + context 'with offset=2', 'and limit=2' do + it "follows renames, skip the two latest commit and return only one commit" do + log_commits = repository.log(options.merge(path: "encoding/CHANGELOG", offset: 2, limit: 2)) + + aggregate_failures do + expect(log_commits).not_to include(commit_with_new_name) + expect(log_commits).not_to include(rename_commit) + expect(log_commits).to include(commit_with_old_name) + end + end end end context "and 'path' is a file that matches the old filename" do - let(:log_commits) do - repository.log(options.merge(path: "CHANGELOG")) - end + it "does not follow renames" do + log_commits = repository.log(options.merge(path: "CHANGELOG")) - it "should not follow renames" do - expect(log_commits).to include(commit_with_old_name) - expect(log_commits).to include(rename_commit) - expect(log_commits).not_to include(commit_with_new_name) + aggregate_failures do + expect(log_commits).not_to include(commit_with_new_name) + expect(log_commits).to include(rename_commit) + expect(log_commits).to include(commit_with_old_name) + end end end context "unknown ref" do - let(:log_commits) { repository.log(options.merge(ref: 'unknown')) } + it "returns an empty array" do + log_commits = repository.log(options.merge(ref: 'unknown')) - it "should return empty" do expect(log_commits).to eq([]) end end @@ -699,12 +789,6 @@ describe Gitlab::Git::Repository, seed_helper: true do end end end - - after(:all) do - # Erase our commits so other tests get the original repo - repo = Gitlab::Git::Repository.new(TEST_REPO_PATH).rugged - repo.references.update("refs/heads/master", SeedRepo::LastCommit::ID) - end end describe "#commits_between" do @@ -746,6 +830,32 @@ describe Gitlab::Git::Repository, seed_helper: true do it { is_expected.to eq(17) } end + describe '#count_commits' do + context 'with after timestamp' do + it 'returns the number of commits after timestamp' do + options = { ref: 'master', limit: nil, after: Time.iso8601('2013-03-03T20:15:01+00:00') } + + expect(repository.count_commits(options)).to eq(25) + end + end + + context 'with before timestamp' do + it 'returns the number of commits after timestamp' do + options = { ref: 'feature', limit: nil, before: Time.iso8601('2015-03-03T20:15:01+00:00') } + + expect(repository.count_commits(options)).to eq(9) + end + end + + context 'with path' do + it 'returns the number of commits with path ' do + options = { ref: 'master', limit: nil, path: "encoding" } + + expect(repository.count_commits(options)).to eq(2) + end + end + end + describe "branch_names_contains" do subject { repository.branch_names_contains(SeedRepo::LastCommit::ID) } @@ -844,81 +954,6 @@ describe Gitlab::Git::Repository, seed_helper: true do end end - describe '#mkdir' do - let(:commit_options) do - { - author: { - email: 'user@example.com', - name: 'Test User', - time: Time.now - }, - committer: { - email: 'user@example.com', - name: 'Test User', - time: Time.now - }, - commit: { - message: 'Test message', - branch: 'refs/heads/fix', - } - } - end - - def generate_diff_for_path(path) - "diff --git a/#{path}/.gitkeep b/#{path}/.gitkeep -new file mode 100644 -index 0000000..e69de29 ---- /dev/null -+++ b/#{path}/.gitkeep\n" - end - - shared_examples 'mkdir diff check' do |path, expected_path| - it 'creates a directory' do - result = repository.mkdir(path, commit_options) - expect(result).not_to eq(nil) - - # Verify another mkdir doesn't create a directory that already exists - expect{ repository.mkdir(path, commit_options) }.to raise_error('Directory already exists') - end - end - - describe 'creates a directory in root directory' do - it_should_behave_like 'mkdir diff check', 'new_dir', 'new_dir' - end - - describe 'creates a directory in subdirectory' do - it_should_behave_like 'mkdir diff check', 'files/ruby/test', 'files/ruby/test' - end - - describe 'creates a directory in subdirectory with a slash' do - it_should_behave_like 'mkdir diff check', '/files/ruby/test2', 'files/ruby/test2' - end - - describe 'creates a directory in subdirectory with multiple slashes' do - it_should_behave_like 'mkdir diff check', '//files/ruby/test3', 'files/ruby/test3' - end - - describe 'handles relative paths' do - it_should_behave_like 'mkdir diff check', 'files/ruby/../test_relative', 'files/test_relative' - end - - describe 'creates nested directories' do - it_should_behave_like 'mkdir diff check', 'files/missing/test', 'files/missing/test' - end - - it 'does not attempt to create a directory with invalid relative path' do - expect{ repository.mkdir('../files/missing/test', commit_options) }.to raise_error('Invalid path') - end - - it 'does not attempt to overwrite a file' do - expect{ repository.mkdir('README.md', commit_options) }.to raise_error('Directory already exists as a file') - end - - it 'does not attempt to overwrite a directory' do - expect{ repository.mkdir('files', commit_options) }.to raise_error('Directory already exists') - end - end - describe "#ls_files" do let(:master_file_paths) { repository.ls_files("master") } let(:not_existed_branch) { repository.ls_files("not_existed_branch") } diff --git a/spec/lib/gitlab/git_access_spec.rb b/spec/lib/gitlab/git_access_spec.rb index a55bd4387e0..48f7754bed8 100644 --- a/spec/lib/gitlab/git_access_spec.rb +++ b/spec/lib/gitlab/git_access_spec.rb @@ -199,7 +199,9 @@ describe Gitlab::GitAccess, lib: true do def stub_git_hooks # Running the `pre-receive` hook is expensive, and not necessary for this test. - allow_any_instance_of(GitHooksService).to receive(:execute).and_yield + allow_any_instance_of(GitHooksService).to receive(:execute) do |service, &block| + block.call(service) + end end def merge_into_protected_branch @@ -207,13 +209,12 @@ describe Gitlab::GitAccess, lib: true do stub_git_hooks project.repository.add_branch(user, unprotected_branch, 'feature') target_branch = project.repository.lookup('feature') - source_branch = project.repository.commit_file( + source_branch = project.repository.create_file( user, FFaker::InternetSE.login_user_name, FFaker::HipsterIpsum.paragraph, message: FFaker::HipsterIpsum.sentence, - branch_name: unprotected_branch, - update: false) + branch_name: unprotected_branch) rugged = project.repository.rugged author = { email: "email@example.com", time: Time.now, name: "Example Git User" } @@ -232,11 +233,18 @@ describe Gitlab::GitAccess, lib: true do else project.team << [user, role] end + end + + permissions_matrix[role].each do |action, allowed| + context action do + subject { access.send(:check_push_access!, changes[action]) } - permissions_matrix[role].each do |action, allowed| - context action do - subject { access.send(:check_push_access!, changes[action]) } - it { expect(subject.allowed?).to allowed ? be_truthy : be_falsey } + it do + if allowed + expect { subject }.not_to raise_error + else + expect { subject }.to raise_error(Gitlab::GitAccess::UnauthorizedError) + end end end end @@ -301,7 +309,7 @@ describe Gitlab::GitAccess, lib: true do } } - [['feature', 'exact'], ['feat*', 'wildcard']].each do |protected_branch_name, protected_branch_type| + [%w(feature exact), ['feat*', 'wildcard']].each do |protected_branch_name, protected_branch_type| context do before { create(:protected_branch, name: protected_branch_name, project: project) } diff --git a/spec/lib/gitlab/git_spec.rb b/spec/lib/gitlab/git_spec.rb index 219198eff60..8eaf7aac264 100644 --- a/spec/lib/gitlab/git_spec.rb +++ b/spec/lib/gitlab/git_spec.rb @@ -19,7 +19,7 @@ describe Gitlab::Git, lib: true do describe 'committer_hash' do it "returns a hash containing the given email and name" do - committer_hash = Gitlab::Git::committer_hash(email: committer_email, name: committer_name) + committer_hash = Gitlab::Git.committer_hash(email: committer_email, name: committer_name) expect(committer_hash[:email]).to eq(committer_email) expect(committer_hash[:name]).to eq(committer_name) @@ -28,7 +28,7 @@ describe Gitlab::Git, lib: true do context 'when email is nil' do it "returns nil" do - committer_hash = Gitlab::Git::committer_hash(email: nil, name: committer_name) + committer_hash = Gitlab::Git.committer_hash(email: nil, name: committer_name) expect(committer_hash).to be_nil end @@ -36,7 +36,7 @@ describe Gitlab::Git, lib: true do context 'when name is nil' do it "returns nil" do - committer_hash = Gitlab::Git::committer_hash(email: committer_email, name: nil) + committer_hash = Gitlab::Git.committer_hash(email: committer_email, name: nil) expect(committer_hash).to be_nil end diff --git a/spec/lib/gitlab/gitaly_client/commit_spec.rb b/spec/lib/gitlab/gitaly_client/commit_spec.rb new file mode 100644 index 00000000000..4684b1d1ac0 --- /dev/null +++ b/spec/lib/gitlab/gitaly_client/commit_spec.rb @@ -0,0 +1,58 @@ +require 'spec_helper' + +describe Gitlab::GitalyClient::Commit do + describe '.diff_from_parent' do + let(:diff_stub) { double('Gitaly::Diff::Stub') } + let(:project) { create(:project, :repository) } + let(:repository_message) { Gitaly::Repository.new(path: project.repository.path) } + let(:commit) { project.commit('913c66a37b4a45b9769037c55c2d238bd0942d2e') } + + before do + allow(Gitaly::Diff::Stub).to receive(:new).and_return(diff_stub) + allow(diff_stub).to receive(:commit_diff).and_return([]) + end + + context 'when a commit has a parent' do + it 'sends an RPC request with the parent ID as left commit' do + request = Gitaly::CommitDiffRequest.new( + repository: repository_message, + left_commit_id: 'cfe32cf61b73a0d5e9f13e774abde7ff789b1660', + right_commit_id: commit.id, + ) + + expect(diff_stub).to receive(:commit_diff).with(request) + + described_class.diff_from_parent(commit) + end + end + + context 'when a commit does not have a parent' do + it 'sends an RPC request with empty tree ref as left commit' do + initial_commit = project.commit('1a0b36b3cdad1d2ee32457c102a8c0b7056fa863') + request = Gitaly::CommitDiffRequest.new( + repository: repository_message, + left_commit_id: '4b825dc642cb6eb9a060e54bf8d69288fbee4904', + right_commit_id: initial_commit.id, + ) + + expect(diff_stub).to receive(:commit_diff).with(request) + + described_class.diff_from_parent(initial_commit) + end + end + + it 'returns a Gitlab::Git::DiffCollection' do + ret = described_class.diff_from_parent(commit) + + expect(ret).to be_kind_of(Gitlab::Git::DiffCollection) + end + + it 'passes options to Gitlab::Git::DiffCollection' do + options = { max_files: 31, max_lines: 13 } + + expect(Gitlab::Git::DiffCollection).to receive(:new).with([], options) + + described_class.diff_from_parent(commit, options) + end + end +end diff --git a/spec/lib/gitlab/gitaly_client/notifications_spec.rb b/spec/lib/gitlab/gitaly_client/notifications_spec.rb new file mode 100644 index 00000000000..a6252c99aa1 --- /dev/null +++ b/spec/lib/gitlab/gitaly_client/notifications_spec.rb @@ -0,0 +1,20 @@ +require 'spec_helper' + +describe Gitlab::GitalyClient::Notifications do + let(:client) { Gitlab::GitalyClient::Notifications.new } + + before do + allow(Gitlab.config.gitaly).to receive(:socket_path).and_return('path/to/gitaly.socket') + end + + describe '#post_receive' do + let(:repo_path) { '/path/to/my_repo.git' } + + it 'sends a post_receive message' do + expect_any_instance_of(Gitaly::Notifications::Stub). + to receive(:post_receive).with(post_receive_request_with_repo_path(repo_path)) + + client.post_receive(repo_path) + end + end +end diff --git a/spec/lib/gitlab/github_import/branch_formatter_spec.rb b/spec/lib/gitlab/github_import/branch_formatter_spec.rb index 36e7d739f7e..3a31f93efa5 100644 --- a/spec/lib/gitlab/github_import/branch_formatter_spec.rb +++ b/spec/lib/gitlab/github_import/branch_formatter_spec.rb @@ -6,27 +6,27 @@ describe Gitlab::GithubImport::BranchFormatter, lib: true do let(:repo) { double } let(:raw) do { - ref: 'feature', + ref: 'branch-merged', repo: repo, sha: commit.id } end describe '#exists?' do - it 'returns true when both branch, and commit exists' do + it 'returns true when branch exists and commit is part of the branch' do branch = described_class.new(project, double(raw)) expect(branch.exists?).to eq true end - it 'returns false when branch does not exist' do - branch = described_class.new(project, double(raw.merge(ref: 'removed-branch'))) + it 'returns false when branch exists and commit is not part of the branch' do + branch = described_class.new(project, double(raw.merge(ref: 'feature'))) expect(branch.exists?).to eq false end - it 'returns false when commit does not exist' do - branch = described_class.new(project, double(raw.merge(sha: '2e5d3239642f9161dcbbc4b70a211a68e5e45e2b'))) + it 'returns false when branch does not exist' do + branch = described_class.new(project, double(raw.merge(ref: 'removed-branch'))) expect(branch.exists?).to eq false end diff --git a/spec/lib/gitlab/github_import/importer_spec.rb b/spec/lib/gitlab/github_import/importer_spec.rb index 33d83d6d2f1..8b867fbe322 100644 --- a/spec/lib/gitlab/github_import/importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer_spec.rb @@ -55,9 +55,6 @@ describe Gitlab::GithubImport::Importer, lib: true do allow_any_instance_of(Octokit::Client).to receive(:releases).and_return([release1, release2]) end - let(:octocat) { double(id: 123456, login: 'octocat', email: 'octocat@example.com') } - let(:created_at) { DateTime.strptime('2011-01-26T19:01:12Z') } - let(:updated_at) { DateTime.strptime('2011-01-27T19:01:12Z') } let(:label1) do double( name: 'Bug', @@ -127,32 +124,6 @@ describe Gitlab::GithubImport::Importer, lib: true do ) end - let!(:user) { create(:user, email: octocat.email) } - let(:repository) { double(id: 1, fork: false) } - let(:source_sha) { create(:commit, project: project).id } - let(:source_branch) { double(ref: 'feature', repo: repository, sha: source_sha) } - let(:target_sha) { create(:commit, project: project, git_commit: RepoHelpers.another_sample_commit).id } - let(:target_branch) { double(ref: 'master', repo: repository, sha: target_sha) } - let(:pull_request) do - double( - number: 1347, - milestone: nil, - state: 'open', - title: 'New feature', - body: 'Please pull these awesome changes', - head: source_branch, - base: target_branch, - assignee: nil, - user: octocat, - created_at: created_at, - updated_at: updated_at, - closed_at: nil, - merged_at: nil, - url: "#{api_root}/repos/octocat/Hello-World/pulls/1347", - labels: [double(name: 'Label #2')] - ) - end - let(:release1) do double( tag_name: 'v1.0.0', @@ -177,12 +148,14 @@ describe Gitlab::GithubImport::Importer, lib: true do ) end + subject { described_class.new(project) } + it 'returns true' do - expect(described_class.new(project).execute).to eq true + expect(subject.execute).to eq true end it 'does not raise an error' do - expect { described_class.new(project).execute }.not_to raise_error + expect { subject.execute }.not_to raise_error end it 'stores error messages' do @@ -205,15 +178,93 @@ describe Gitlab::GithubImport::Importer, lib: true do end end + shared_examples 'Gitlab::GithubImport unit-testing' do + describe '#clean_up_restored_branches' do + subject { described_class.new(project) } + + before do + allow(gh_pull_request).to receive(:source_branch_exists?).at_least(:once) { false } + allow(gh_pull_request).to receive(:target_branch_exists?).at_least(:once) { false } + end + + context 'when pull request stills open' do + let(:gh_pull_request) { Gitlab::GithubImport::PullRequestFormatter.new(project, pull_request) } + + it 'does not remove branches' do + expect(subject).not_to receive(:remove_branch) + subject.send(:clean_up_restored_branches, gh_pull_request) + end + end + + context 'when pull request is closed' do + let(:gh_pull_request) { Gitlab::GithubImport::PullRequestFormatter.new(project, closed_pull_request) } + + it 'does remove branches' do + expect(subject).to receive(:remove_branch).at_least(2).times + subject.send(:clean_up_restored_branches, gh_pull_request) + end + end + end + end + let(:project) { create(:project, :wiki_disabled, import_url: "#{repo_root}/octocat/Hello-World.git") } + let(:octocat) { double(id: 123456, login: 'octocat', email: 'octocat@example.com') } let(:credentials) { { user: 'joe' } } + let(:created_at) { DateTime.strptime('2011-01-26T19:01:12Z') } + let(:updated_at) { DateTime.strptime('2011-01-27T19:01:12Z') } + let(:repository) { double(id: 1, fork: false) } + let(:source_sha) { create(:commit, project: project).id } + let(:source_branch) { double(ref: 'branch-merged', repo: repository, sha: source_sha) } + let(:target_sha) { create(:commit, project: project, git_commit: RepoHelpers.another_sample_commit).id } + let(:target_branch) { double(ref: 'master', repo: repository, sha: target_sha) } + let(:pull_request) do + double( + number: 1347, + milestone: nil, + state: 'open', + title: 'New feature', + body: 'Please pull these awesome changes', + head: source_branch, + base: target_branch, + assignee: nil, + user: octocat, + created_at: created_at, + updated_at: updated_at, + closed_at: nil, + merged_at: nil, + url: "#{api_root}/repos/octocat/Hello-World/pulls/1347", + labels: [double(name: 'Label #2')] + ) + end + let(:closed_pull_request) do + double( + number: 1347, + milestone: nil, + state: 'closed', + title: 'New feature', + body: 'Please pull these awesome changes', + head: source_branch, + base: target_branch, + assignee: nil, + user: octocat, + created_at: created_at, + updated_at: updated_at, + closed_at: updated_at, + merged_at: nil, + url: "#{api_root}/repos/octocat/Hello-World/pulls/1347", + labels: [double(name: 'Label #2')] + ) + end + context 'when importing a GitHub project' do let(:api_root) { 'https://api.github.com' } let(:repo_root) { 'https://github.com' } + subject { described_class.new(project) } it_behaves_like 'Gitlab::GithubImport::Importer#execute' it_behaves_like 'Gitlab::GithubImport::Importer#execute an error occurs' + it_behaves_like 'Gitlab::GithubImport unit-testing' describe '#client' do it 'instantiates a Client' do @@ -223,7 +274,7 @@ describe Gitlab::GithubImport::Importer, lib: true do {} ) - described_class.new(project).client + subject.client end end end @@ -231,6 +282,8 @@ describe Gitlab::GithubImport::Importer, lib: true do context 'when importing a Gitea project' do let(:api_root) { 'https://try.gitea.io/api/v1' } let(:repo_root) { 'https://try.gitea.io' } + subject { described_class.new(project) } + before do project.update(import_type: 'gitea', import_url: "#{repo_root}/foo/group/project.git") end @@ -239,6 +292,7 @@ describe Gitlab::GithubImport::Importer, lib: true do let(:expected_not_called) { [:import_releases] } end it_behaves_like 'Gitlab::GithubImport::Importer#execute an error occurs' + it_behaves_like 'Gitlab::GithubImport unit-testing' describe '#client' do it 'instantiates a Client' do @@ -248,7 +302,7 @@ describe Gitlab::GithubImport::Importer, lib: true do { host: "#{repo_root}:443/foo", api_version: 'v1' } ) - described_class.new(project).client + subject.client end end end diff --git a/spec/lib/gitlab/github_import/pull_request_formatter_spec.rb b/spec/lib/gitlab/github_import/pull_request_formatter_spec.rb index e46be18aa99..44423917944 100644 --- a/spec/lib/gitlab/github_import/pull_request_formatter_spec.rb +++ b/spec/lib/gitlab/github_import/pull_request_formatter_spec.rb @@ -7,10 +7,12 @@ describe Gitlab::GithubImport::PullRequestFormatter, lib: true do let(:target_sha) { create(:commit, project: project, git_commit: RepoHelpers.another_sample_commit).id } let(:repository) { double(id: 1, fork: false) } let(:source_repo) { repository } - let(:source_branch) { double(ref: 'feature', repo: source_repo, sha: source_sha) } + let(:source_branch) { double(ref: 'branch-merged', repo: source_repo, sha: source_sha) } + let(:forked_source_repo) { double(id: 2, fork: true, name: 'otherproject', full_name: 'company/otherproject') } let(:target_repo) { repository } let(:target_branch) { double(ref: 'master', repo: target_repo, sha: target_sha) } let(:removed_branch) { double(ref: 'removed-branch', repo: source_repo, sha: '2e5d3239642f9161dcbbc4b70a211a68e5e45e2b') } + let(:forked_branch) { double(ref: 'master', repo: forked_source_repo, sha: '2e5d3239642f9161dcbbc4b70a211a68e5e45e2b') } let(:octocat) { double(id: 123456, login: 'octocat', email: 'octocat@example.com') } let(:created_at) { DateTime.strptime('2011-01-26T19:01:12Z') } let(:updated_at) { DateTime.strptime('2011-01-27T19:01:12Z') } @@ -49,7 +51,7 @@ describe Gitlab::GithubImport::PullRequestFormatter, lib: true do title: 'New feature', description: "*Created by: octocat*\n\nPlease pull these awesome changes", source_project: project, - source_branch: 'feature', + source_branch: 'branch-merged', source_branch_sha: source_sha, target_project: project, target_branch: 'master', @@ -75,7 +77,7 @@ describe Gitlab::GithubImport::PullRequestFormatter, lib: true do title: 'New feature', description: "*Created by: octocat*\n\nPlease pull these awesome changes", source_project: project, - source_branch: 'feature', + source_branch: 'branch-merged', source_branch_sha: source_sha, target_project: project, target_branch: 'master', @@ -102,7 +104,7 @@ describe Gitlab::GithubImport::PullRequestFormatter, lib: true do title: 'New feature', description: "*Created by: octocat*\n\nPlease pull these awesome changes", source_project: project, - source_branch: 'feature', + source_branch: 'branch-merged', source_branch_sha: source_sha, target_project: project, target_branch: 'master', @@ -194,7 +196,7 @@ describe Gitlab::GithubImport::PullRequestFormatter, lib: true do let(:raw_data) { double(base_data) } it 'returns branch ref' do - expect(pull_request.source_branch_name).to eq 'feature' + expect(pull_request.source_branch_name).to eq 'branch-merged' end end @@ -205,10 +207,18 @@ describe Gitlab::GithubImport::PullRequestFormatter, lib: true do expect(pull_request.source_branch_name).to eq 'pull/1347/removed-branch' end end + + context 'when source branch is from a fork' do + let(:raw_data) { double(base_data.merge(head: forked_branch)) } + + it 'prefixes branch name with pull request number and project with namespace to avoid collision' do + expect(pull_request.source_branch_name).to eq 'pull/1347/company/otherproject/master' + end + end end shared_examples 'Gitlab::GithubImport::PullRequestFormatter#target_branch_name' do - context 'when source branch exists' do + context 'when target branch exists' do let(:raw_data) { double(base_data) } it 'returns branch ref' do @@ -271,6 +281,24 @@ describe Gitlab::GithubImport::PullRequestFormatter, lib: true do end end + describe '#cross_project?' do + context 'when source and target repositories are different' do + let(:raw_data) { double(base_data.merge(head: forked_branch)) } + + it 'returns true' do + expect(pull_request.cross_project?).to eq true + end + end + + context 'when source and target repositories are the same' do + let(:raw_data) { double(base_data.merge(head: source_branch)) } + + it 'returns false' do + expect(pull_request.cross_project?).to eq false + end + end + end + describe '#url' do let(:raw_data) { double(base_data) } @@ -278,4 +306,12 @@ describe Gitlab::GithubImport::PullRequestFormatter, lib: true do expect(pull_request.url).to eq 'https://api.github.com/repos/octocat/Hello-World/pulls/1347' end end + + describe '#opened?' do + let(:raw_data) { double(base_data.merge(state: 'open')) } + + it 'returns true when state is "open"' do + expect(pull_request.opened?).to be_truthy + end + end end diff --git a/spec/lib/gitlab/highlight_spec.rb b/spec/lib/gitlab/highlight_spec.rb index e177d883158..e49799ad105 100644 --- a/spec/lib/gitlab/highlight_spec.rb +++ b/spec/lib/gitlab/highlight_spec.rb @@ -13,9 +13,9 @@ describe Gitlab::Highlight, lib: true do end it 'highlights all the lines properly' do - expect(lines[4]).to eq(%Q{<span id="LC5" class="line"> <span class="kp">extend</span> <span class="nb">self</span></span>\n}) - expect(lines[21]).to eq(%Q{<span id="LC22" class="line"> <span class="k">unless</span> <span class="no">File</span><span class="p">.</span><span class="nf">directory?</span><span class="p">(</span><span class="n">path</span><span class="p">)</span></span>\n}) - expect(lines[26]).to eq(%Q{<span id="LC27" class="line"> <span class="vi">@cmd_status</span> <span class="o">=</span> <span class="mi">0</span></span>\n}) + expect(lines[4]).to eq(%Q{<span id="LC5" class="line" lang="ruby"> <span class="kp">extend</span> <span class="nb">self</span></span>\n}) + expect(lines[21]).to eq(%Q{<span id="LC22" class="line" lang="ruby"> <span class="k">unless</span> <span class="no">File</span><span class="p">.</span><span class="nf">directory?</span><span class="p">(</span><span class="n">path</span><span class="p">)</span></span>\n}) + expect(lines[26]).to eq(%Q{<span id="LC27" class="line" lang="ruby"> <span class="vi">@cmd_status</span> <span class="o">=</span> <span class="mi">0</span></span>\n}) end describe 'with CRLF' do @@ -26,7 +26,7 @@ describe Gitlab::Highlight, lib: true do end it 'strips extra LFs' do - expect(lines[0]).to eq("<span id=\"LC1\" class=\"line\">test </span>") + expect(lines[0]).to eq("<span id=\"LC1\" class=\"line\" lang=\"plaintext\">test </span>") end end end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 9c08f41fe82..c3ee743035a 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -97,6 +97,7 @@ variables: triggers: - project - trigger_requests +- owner deploy_keys: - user - deploy_keys_projects @@ -131,12 +132,12 @@ project: - campfire_service - drone_ci_service - emails_on_push_service -- builds_email_service - pipelines_email_service - mattermost_slash_commands_service - slack_slash_commands_service - irker_service - pivotaltracker_service +- prometheus_service - hipchat_service - flowdock_service - assembla_service @@ -155,6 +156,7 @@ project: - gitlab_issue_tracker_service - external_wiki_service - kubernetes_service +- mock_ci_service - forked_project_link - forked_from_project - forked_project_links @@ -200,6 +202,7 @@ project: - route - statistics - container_images +- uploads award_emoji: - awardable - user diff --git a/spec/lib/gitlab/import_export/attribute_configuration_spec.rb b/spec/lib/gitlab/import_export/attribute_configuration_spec.rb index ea65a5dfed1..e24d070706a 100644 --- a/spec/lib/gitlab/import_export/attribute_configuration_spec.rb +++ b/spec/lib/gitlab/import_export/attribute_configuration_spec.rb @@ -17,7 +17,7 @@ describe 'Import/Export attribute configuration', lib: true do # Remove duplicated or add missing models # - project is not part of the tree, so it has to be added manually. # - milestone, labels have both singular and plural versions in the tree, so remove the duplicates. - names.flatten.uniq - ['milestones', 'labels'] + ['project'] + names.flatten.uniq - %w(milestones labels) + ['project'] end let(:safe_attributes_file) { 'spec/lib/gitlab/import_export/safe_model_attributes.yml' } diff --git a/spec/lib/gitlab/import_export/avatar_saver_spec.rb b/spec/lib/gitlab/import_export/avatar_saver_spec.rb index d6ee94442cb..579a31ead58 100644 --- a/spec/lib/gitlab/import_export/avatar_saver_spec.rb +++ b/spec/lib/gitlab/import_export/avatar_saver_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' describe Gitlab::ImportExport::AvatarSaver, lib: true do let(:shared) { Gitlab::ImportExport::Shared.new(relative_path: 'test') } - let(:export_path) { "#{Dir::tmpdir}/project_tree_saver_spec" } + let(:export_path) { "#{Dir.tmpdir}/project_tree_saver_spec" } let(:project_with_avatar) { create(:empty_project, avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "image/png")) } let(:project) { create(:empty_project) } diff --git a/spec/lib/gitlab/import_export/file_importer_spec.rb b/spec/lib/gitlab/import_export/file_importer_spec.rb index a88ddd17aca..b88b9c18c15 100644 --- a/spec/lib/gitlab/import_export/file_importer_spec.rb +++ b/spec/lib/gitlab/import_export/file_importer_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' describe Gitlab::ImportExport::FileImporter, lib: true do let(:shared) { Gitlab::ImportExport::Shared.new(relative_path: 'test') } - let(:export_path) { "#{Dir::tmpdir}/file_importer_spec" } + let(:export_path) { "#{Dir.tmpdir}/file_importer_spec" } let(:valid_file) { "#{shared.export_path}/valid.json" } let(:symlink_file) { "#{shared.export_path}/invalid.json" } let(:subfolder_symlink_file) { "#{shared.export_path}/subfolder/invalid.json" } diff --git a/spec/lib/gitlab/import_export/import_export_spec.rb b/spec/lib/gitlab/import_export/import_export_spec.rb index 20743811dab..f3fd0d82875 100644 --- a/spec/lib/gitlab/import_export/import_export_spec.rb +++ b/spec/lib/gitlab/import_export/import_export_spec.rb @@ -10,7 +10,7 @@ describe Gitlab::ImportExport, services: true do end it 'contains the namespace path' do - expect(described_class.export_filename(project: project)).to include(project.namespace.full_path) + expect(described_class.export_filename(project: project)).to include(project.namespace.full_path.tr('/', '_')) end it 'does not go over a certain length' do diff --git a/spec/lib/gitlab/import_export/model_configuration_spec.rb b/spec/lib/gitlab/import_export/model_configuration_spec.rb index 9b492d1b9c7..2ede5cdd2ad 100644 --- a/spec/lib/gitlab/import_export/model_configuration_spec.rb +++ b/spec/lib/gitlab/import_export/model_configuration_spec.rb @@ -14,7 +14,7 @@ describe 'Import/Export model configuration', lib: true do # - project is not part of the tree, so it has to be added manually. # - milestone, labels have both singular and plural versions in the tree, so remove the duplicates. # - User, Author... Models we do not care about for checking models - names.flatten.uniq - ['milestones', 'labels', 'user', 'author'] + ['project'] + names.flatten.uniq - %w(milestones labels user author) + ['project'] end let(:all_models_yml) { 'spec/lib/gitlab/import_export/all_models.yml' } diff --git a/spec/lib/gitlab/import_export/project.json b/spec/lib/gitlab/import_export/project.json index 2e9f60432b4..d9b67426818 100644 --- a/spec/lib/gitlab/import_export/project.json +++ b/spec/lib/gitlab/import_export/project.json @@ -2539,7 +2539,7 @@ "merge_params": { "force_remove_source_branch": null }, - "merge_when_build_succeeds": true, + "merge_when_pipeline_succeeds": true, "merge_user_id": null, "merge_commit_sha": null, "deleted_at": null, @@ -2976,7 +2976,7 @@ "merge_params": { "force_remove_source_branch": null }, - "merge_when_build_succeeds": false, + "merge_when_pipeline_succeeds": false, "merge_user_id": null, "merge_commit_sha": null, "deleted_at": null, @@ -3260,7 +3260,7 @@ "merge_params": { "force_remove_source_branch": null }, - "merge_when_build_succeeds": false, + "merge_when_pipeline_succeeds": false, "merge_user_id": null, "merge_commit_sha": null, "deleted_at": null, @@ -3544,7 +3544,7 @@ "merge_params": { "force_remove_source_branch": null }, - "merge_when_build_succeeds": false, + "merge_when_pipeline_succeeds": false, "merge_user_id": null, "merge_commit_sha": null, "deleted_at": null, @@ -4234,7 +4234,7 @@ "merge_params": { "force_remove_source_branch": null }, - "merge_when_build_succeeds": false, + "merge_when_pipeline_succeeds": false, "merge_user_id": null, "merge_commit_sha": null, "deleted_at": null, @@ -4782,7 +4782,7 @@ "merge_params": { "force_remove_source_branch": null }, - "merge_when_build_succeeds": false, + "merge_when_pipeline_succeeds": false, "merge_user_id": null, "merge_commit_sha": null, "deleted_at": null, @@ -5281,7 +5281,7 @@ "merge_params": { "force_remove_source_branch": null }, - "merge_when_build_succeeds": false, + "merge_when_pipeline_succeeds": false, "merge_user_id": null, "merge_commit_sha": null, "deleted_at": null, @@ -5541,7 +5541,7 @@ "merge_params": { "force_remove_source_branch": null }, - "merge_when_build_succeeds": false, + "merge_when_pipeline_succeeds": false, "merge_user_id": null, "merge_commit_sha": null, "deleted_at": null, @@ -6231,7 +6231,7 @@ "merge_params": { "force_remove_source_branch": null }, - "merge_when_build_succeeds": false, + "merge_when_pipeline_succeeds": false, "merge_user_id": null, "merge_commit_sha": null, "deleted_at": null, @@ -6507,7 +6507,6 @@ "tag": null, "yaml_errors": null, "committed_at": null, - "gl_project_id": 5, "status": "failed", "started_at": null, "finished_at": null, @@ -6565,7 +6564,6 @@ "artifacts_file": { "url": null }, - "gl_project_id": 5, "artifacts_metadata": { "url": null }, @@ -6603,7 +6601,6 @@ "artifacts_file": { "url": "/Users/Test/Test/gitlab-development-kit/gitlab/shared/artifacts/2016_03/5/72/p5_build_artifacts.zip" }, - "gl_project_id": 5, "artifacts_metadata": { "url": "/Users/Test/Test/gitlab-development-kit/gitlab/shared/artifacts/2016_03/5/72/p5_build_artifacts_metadata.gz" }, @@ -6624,7 +6621,6 @@ "tag": null, "yaml_errors": null, "committed_at": null, - "gl_project_id": 5, "status": "failed", "started_at": null, "finished_at": null, @@ -6659,7 +6655,6 @@ "artifacts_file": { "url": "/Users/Test/Test/gitlab-development-kit/gitlab/shared/artifacts/2016_03/5/74/p5_build_artifacts.zip" }, - "gl_project_id": 5, "artifacts_metadata": { "url": "/Users/Test/Test/gitlab-development-kit/gitlab/shared/artifacts/2016_03/5/74/p5_build_artifacts_metadata.gz" }, @@ -6695,7 +6690,6 @@ "artifacts_file": { "url": null }, - "gl_project_id": 5, "artifacts_metadata": { "url": null }, @@ -6716,7 +6710,6 @@ "tag": null, "yaml_errors": null, "committed_at": null, - "gl_project_id": 5, "status": "failed", "started_at": null, "finished_at": null, @@ -6751,7 +6744,6 @@ "artifacts_file": { "url": "/Users/Test/Test/gitlab-development-kit/gitlab/shared/artifacts/2016_03/5/76/p5_build_artifacts.zip" }, - "gl_project_id": 5, "artifacts_metadata": { "url": "/Users/Test/Test/gitlab-development-kit/gitlab/shared/artifacts/2016_03/5/76/p5_build_artifacts_metadata.gz" }, @@ -6787,7 +6779,6 @@ "artifacts_file": { "url": "/Users/Test/Test/gitlab-development-kit/gitlab/shared/artifacts/2016_03/5/75/p5_build_artifacts.zip" }, - "gl_project_id": 5, "artifacts_metadata": { "url": "/Users/Test/Test/gitlab-development-kit/gitlab/shared/artifacts/2016_03/5/75/p5_build_artifacts_metadata.gz" }, @@ -6808,7 +6799,6 @@ "tag": null, "yaml_errors": null, "committed_at": null, - "gl_project_id": 5, "status": "failed", "started_at": null, "finished_at": null, @@ -6843,7 +6833,6 @@ "artifacts_file": { "url": "/Users/Test/Test/gitlab-development-kit/gitlab/shared/artifacts/2016_03/5/78/p5_build_artifacts.zip" }, - "gl_project_id": 5, "artifacts_metadata": { "url": "/Users/Test/Test/gitlab-development-kit/gitlab/shared/artifacts/2016_03/5/78/p5_build_artifacts_metadata.gz" }, @@ -6879,7 +6868,6 @@ "artifacts_file": { "url": "/Users/Test/Test/gitlab-development-kit/gitlab/shared/artifacts/2016_03/5/77/p5_build_artifacts.zip" }, - "gl_project_id": 5, "artifacts_metadata": { "url": "/Users/Test/Test/gitlab-development-kit/gitlab/shared/artifacts/2016_03/5/77/p5_build_artifacts_metadata.gz" }, @@ -6900,7 +6888,6 @@ "tag": null, "yaml_errors": null, "committed_at": null, - "gl_project_id": 5, "status": "failed", "started_at": null, "finished_at": null, @@ -6935,7 +6922,6 @@ "artifacts_file": { "url": null }, - "gl_project_id": 5, "artifacts_metadata": { "url": null }, @@ -6971,7 +6957,6 @@ "artifacts_file": { "url": "/Users/Test/Test/gitlab-development-kit/gitlab/shared/artifacts/2016_03/5/80/p5_build_artifacts.zip" }, - "gl_project_id": 5, "artifacts_metadata": { "url": "/Users/Test/Test/gitlab-development-kit/gitlab/shared/artifacts/2016_03/5/80/p5_build_artifacts_metadata.gz" }, @@ -6985,11 +6970,10 @@ { "id": 123, "token": "cdbfasdf44a5958c83654733449e585", - "project_id": null, + "project_id": 5, "deleted_at": null, "created_at": "2017-01-16T15:25:28.637Z", - "updated_at": "2017-01-16T15:25:28.637Z", - "gl_project_id": 123 + "updated_at": "2017-01-16T15:25:28.637Z" } ], "deploy_keys": [ @@ -7047,7 +7031,7 @@ "updated_at": "2016-06-14T15:01:51.303Z", "active": false, "properties": { - "notify_only_broken_builds": true + "notify_only_broken_pipelines": true }, "template": false, "push_events": true, @@ -7055,7 +7039,7 @@ "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "pipeline_events": true, "category": "common", "default": false, "wiki_page_events": true @@ -7174,7 +7158,7 @@ "updated_at": "2016-06-14T15:01:51.219Z", "active": false, "properties": { - "notify_only_broken_builds": true + "notify_only_broken_pipelines": true }, "template": false, "push_events": true, @@ -7182,7 +7166,7 @@ "merge_requests_events": true, "tag_push_events": true, "note_events": true, - "build_events": true, + "pipeline_events": true, "category": "common", "default": false, "wiki_page_events": true @@ -7335,27 +7319,6 @@ "wiki_page_events": true }, { - "id": 85, - "title": "Builds emails", - "project_id": 5, - "created_at": "2016-06-14T15:01:51.090Z", - "updated_at": "2016-06-14T15:01:51.090Z", - "active": false, - "properties": { - "notify_only_broken_builds": true - }, - "template": false, - "push_events": true, - "issues_events": true, - "merge_requests_events": true, - "tag_push_events": true, - "note_events": true, - "build_events": true, - "category": "common", - "default": false, - "wiki_page_events": true - }, - { "id": 84, "title": "Buildkite", "project_id": 5, @@ -7503,4 +7466,4 @@ "updated_at": "2016-09-23T11:58:28.000Z", "wiki_access_level": 20 } -}
\ No newline at end of file +} diff --git a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb index f4a21c24fa1..c36f12dbd82 100644 --- a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb +++ b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb @@ -129,6 +129,25 @@ describe Gitlab::ImportExport::ProjectTreeRestorer, services: true do expect(Ci::Build.where(token: 'abcd')).to be_empty end end + + context 'has restored the correct number of records' do + it 'has the correct number of merge requests' do + expect(@project.merge_requests.size).to eq(9) + end + + it 'has the correct number of triggers' do + expect(@project.triggers.size).to eq(1) + end + + it 'has the correct number of pipelines and statuses' do + expect(@project.pipelines.size).to eq(5) + + @project.pipelines.zip([2, 2, 2, 2, 2]) + .each do |(pipeline, expected_status_size)| + expect(pipeline.statuses.size).to eq(expected_status_size) + end + end + end end end diff --git a/spec/lib/gitlab/import_export/project_tree_saver_spec.rb b/spec/lib/gitlab/import_export/project_tree_saver_spec.rb index 3628adefc0c..012c22ec5ad 100644 --- a/spec/lib/gitlab/import_export/project_tree_saver_spec.rb +++ b/spec/lib/gitlab/import_export/project_tree_saver_spec.rb @@ -4,7 +4,7 @@ describe Gitlab::ImportExport::ProjectTreeSaver, services: true do describe 'saves the project tree into a json object' do let(:shared) { Gitlab::ImportExport::Shared.new(relative_path: project.path_with_namespace) } let(:project_tree_saver) { described_class.new(project: project, current_user: user, shared: shared) } - let(:export_path) { "#{Dir::tmpdir}/project_tree_saver_spec" } + let(:export_path) { "#{Dir.tmpdir}/project_tree_saver_spec" } let(:user) { create(:user) } let(:project) { setup_project } @@ -114,7 +114,7 @@ describe Gitlab::ImportExport::ProjectTreeSaver, services: true do it 'has project and group labels' do label_types = saved_project_json['issues'].first['label_links'].map { |link| link['label']['type'] } - expect(label_types).to match_array(['ProjectLabel', 'GroupLabel']) + expect(label_types).to match_array(%w(ProjectLabel GroupLabel)) end it 'has priorities associated to labels' do diff --git a/spec/lib/gitlab/import_export/repo_bundler_spec.rb b/spec/lib/gitlab/import_export/repo_bundler_spec.rb index d39ea60ff7f..a7f4e11271e 100644 --- a/spec/lib/gitlab/import_export/repo_bundler_spec.rb +++ b/spec/lib/gitlab/import_export/repo_bundler_spec.rb @@ -4,7 +4,7 @@ describe Gitlab::ImportExport::RepoSaver, services: true do describe 'bundle a project Git repo' do let(:user) { create(:user) } let!(:project) { create(:empty_project, :public, name: 'searchable_project') } - let(:export_path) { "#{Dir::tmpdir}/project_tree_saver_spec" } + let(:export_path) { "#{Dir.tmpdir}/project_tree_saver_spec" } let(:shared) { Gitlab::ImportExport::Shared.new(relative_path: project.path_with_namespace) } let(:bundler) { described_class.new(project: project, shared: shared) } diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index c5ac702d831..1ad16a9b57d 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -15,12 +15,14 @@ Issue: - updated_by_id - confidential - deleted_at +- closed_at - due_date - moved_to_id - lock_version - milestone_id - weight - time_estimate +- relative_position Event: - id - target_type @@ -142,7 +144,7 @@ MergeRequest: - updated_by_id - merge_error - merge_params -- merge_when_build_succeeds +- merge_when_pipeline_succeeds - merge_user_id - merge_commit_sha - deleted_at @@ -175,7 +177,6 @@ Ci::Pipeline: - tag - yaml_errors - committed_at -- gl_project_id - status - started_at - finished_at @@ -210,7 +211,6 @@ CommitStatus: - target_url - description - artifacts_file -- gl_project_id - artifacts_metadata - erased_by_id - erased_at @@ -231,7 +231,6 @@ Ci::Variable: - encrypted_value - encrypted_value_salt - encrypted_value_iv -- gl_project_id Ci::Trigger: - id - token @@ -239,7 +238,8 @@ Ci::Trigger: - deleted_at - created_at - updated_at -- gl_project_id +- owner_id +- description DeployKey: - id - user_id diff --git a/spec/lib/gitlab/import_export/wiki_repo_bundler_spec.rb b/spec/lib/gitlab/import_export/wiki_repo_bundler_spec.rb index 47d5d2fc150..071e5fac3f0 100644 --- a/spec/lib/gitlab/import_export/wiki_repo_bundler_spec.rb +++ b/spec/lib/gitlab/import_export/wiki_repo_bundler_spec.rb @@ -4,7 +4,7 @@ describe Gitlab::ImportExport::WikiRepoSaver, services: true do describe 'bundle a wiki Git repo' do let(:user) { create(:user) } let!(:project) { create(:empty_project, :public, name: 'searchable_project') } - let(:export_path) { "#{Dir::tmpdir}/project_tree_saver_spec" } + let(:export_path) { "#{Dir.tmpdir}/project_tree_saver_spec" } let(:shared) { Gitlab::ImportExport::Shared.new(relative_path: project.path_with_namespace) } let(:wiki_bundler) { described_class.new(project: project, shared: shared) } let!(:project_wiki) { ProjectWiki.new(project, user) } diff --git a/spec/lib/gitlab/import_sources_spec.rb b/spec/lib/gitlab/import_sources_spec.rb index 8cea38e9ff8..b3b5e5e7e33 100644 --- a/spec/lib/gitlab/import_sources_spec.rb +++ b/spec/lib/gitlab/import_sources_spec.rb @@ -22,16 +22,16 @@ describe Gitlab::ImportSources do describe '.values' do it 'returns an array' do expected = - [ - 'github', - 'bitbucket', - 'gitlab', - 'google_code', - 'fogbugz', - 'git', - 'gitlab_project', - 'gitea' - ] + %w( + github + bitbucket + gitlab + google_code + fogbugz + git + gitlab_project + gitea + ) expect(described_class.values).to eq(expected) end @@ -40,15 +40,15 @@ describe Gitlab::ImportSources do describe '.importer_names' do it 'returns an array of importer names' do expected = - [ - 'github', - 'bitbucket', - 'gitlab', - 'google_code', - 'fogbugz', - 'gitlab_project', - 'gitea' - ] + %w( + github + bitbucket + gitlab + google_code + fogbugz + gitlab_project + gitea + ) expect(described_class.importer_names).to eq(expected) end diff --git a/spec/lib/gitlab/kubernetes_spec.rb b/spec/lib/gitlab/kubernetes_spec.rb index c9bd52a3b8f..91f9d06b85a 100644 --- a/spec/lib/gitlab/kubernetes_spec.rb +++ b/spec/lib/gitlab/kubernetes_spec.rb @@ -9,7 +9,7 @@ describe Gitlab::Kubernetes do let(:pod_name) { 'pod1' } let(:container_name) { 'container1' } - subject(:result) { URI::parse(container_exec_url(api_url, namespace, pod_name, container_name)) } + subject(:result) { URI.parse(container_exec_url(api_url, namespace, pod_name, container_name)) } it { expect(result.scheme).to eq('wss') } it { expect(result.host).to eq('example.com') } diff --git a/spec/lib/gitlab/ldap/auth_hash_spec.rb b/spec/lib/gitlab/ldap/auth_hash_spec.rb index 69c49051156..7a2f774b948 100644 --- a/spec/lib/gitlab/ldap/auth_hash_spec.rb +++ b/spec/lib/gitlab/ldap/auth_hash_spec.rb @@ -44,7 +44,7 @@ describe Gitlab::LDAP::AuthHash, lib: true do context "with overridden attributes" do let(:attributes) do { - 'username' => ['mail', 'email'], + 'username' => %w(mail email), 'name' => 'fullName' } end diff --git a/spec/lib/gitlab/ldap/user_spec.rb b/spec/lib/gitlab/ldap/user_spec.rb index 89790c9e1af..2f3bd4393b7 100644 --- a/spec/lib/gitlab/ldap/user_spec.rb +++ b/spec/lib/gitlab/ldap/user_spec.rb @@ -95,10 +95,10 @@ describe Gitlab::LDAP::User, lib: true do it 'maintains an identity per provider' do existing_user = create(:omniauth_user, email: 'john@example.com', provider: 'twitter') - expect(existing_user.identities.count).to eql(1) + expect(existing_user.identities.count).to be(1) ldap_user.save - expect(ldap_user.gl_user.identities.count).to eql(2) + expect(ldap_user.gl_user.identities.count).to be(2) # Expect that find_by provider only returns a single instance of an identity and not an Enumerable expect(ldap_user.gl_user.identities.find_by(provider: 'twitter')).to be_instance_of Identity diff --git a/spec/lib/gitlab/metrics/instrumentation_spec.rb b/spec/lib/gitlab/metrics/instrumentation_spec.rb index d88bcae41fb..a986cb520fb 100644 --- a/spec/lib/gitlab/metrics/instrumentation_spec.rb +++ b/spec/lib/gitlab/metrics/instrumentation_spec.rb @@ -197,11 +197,13 @@ describe Gitlab::Metrics::Instrumentation do @child1 = Class.new(@dummy) do def self.child1_foo; end + def child1_bar; end end @child2 = Class.new(@child1) do def self.child2_foo; end + def child2_bar; end end end diff --git a/spec/lib/gitlab/metrics/method_call_spec.rb b/spec/lib/gitlab/metrics/method_call_spec.rb index 8d05081eecb..a247f03b2da 100644 --- a/spec/lib/gitlab/metrics/method_call_spec.rb +++ b/spec/lib/gitlab/metrics/method_call_spec.rb @@ -23,7 +23,7 @@ describe Gitlab::Metrics::MethodCall do expect(metric.values[:duration]).to be_a_kind_of(Numeric) expect(metric.values[:cpu_duration]).to be_a_kind_of(Numeric) - expect(metric.values[:call_count]).to an_instance_of(Fixnum) + expect(metric.values[:call_count]).to be_an(Integer) expect(metric.tags).to eq({ method: 'Foo#bar' }) end diff --git a/spec/lib/gitlab/metrics/metric_spec.rb b/spec/lib/gitlab/metrics/metric_spec.rb index f26fca52c50..d240b8a01fd 100644 --- a/spec/lib/gitlab/metrics/metric_spec.rb +++ b/spec/lib/gitlab/metrics/metric_spec.rb @@ -62,7 +62,7 @@ describe Gitlab::Metrics::Metric do end it 'includes the timestamp' do - expect(hash[:timestamp]).to be_an_instance_of(Fixnum) + expect(hash[:timestamp]).to be_an(Integer) end end end diff --git a/spec/lib/gitlab/metrics/system_spec.rb b/spec/lib/gitlab/metrics/system_spec.rb index 9e2ea89a712..4d94d8705fb 100644 --- a/spec/lib/gitlab/metrics/system_spec.rb +++ b/spec/lib/gitlab/metrics/system_spec.rb @@ -29,19 +29,19 @@ describe Gitlab::Metrics::System do describe '.cpu_time' do it 'returns a Fixnum' do - expect(described_class.cpu_time).to be_an_instance_of(Fixnum) + expect(described_class.cpu_time).to be_an(Integer) end end describe '.real_time' do it 'returns a Fixnum' do - expect(described_class.real_time).to be_an_instance_of(Fixnum) + expect(described_class.real_time).to be_an(Integer) end end describe '.monotonic_time' do it 'returns a Fixnum' do - expect(described_class.monotonic_time).to be_an_instance_of(Fixnum) + expect(described_class.monotonic_time).to be_an(Integer) end end end diff --git a/spec/lib/gitlab/metrics/transaction_spec.rb b/spec/lib/gitlab/metrics/transaction_spec.rb index 3887c04c832..0c5a6246d85 100644 --- a/spec/lib/gitlab/metrics/transaction_spec.rb +++ b/spec/lib/gitlab/metrics/transaction_spec.rb @@ -134,7 +134,7 @@ describe Gitlab::Metrics::Transaction do series: 'rails_transactions', tags: { action: 'Foo#bar' }, values: { duration: 0.0, allocated_memory: a_kind_of(Numeric) }, - timestamp: an_instance_of(Fixnum) + timestamp: a_kind_of(Integer) } expect(Gitlab::Metrics).to receive(:submit_metrics). @@ -151,7 +151,7 @@ describe Gitlab::Metrics::Transaction do series: 'events', tags: { event: :meow }, values: { count: 1 }, - timestamp: an_instance_of(Fixnum) + timestamp: a_kind_of(Integer) } expect(Gitlab::Metrics).to receive(:submit_metrics). diff --git a/spec/lib/gitlab/middleware/go_spec.rb b/spec/lib/gitlab/middleware/go_spec.rb index fd3769d75b5..c2ab015d5cb 100644 --- a/spec/lib/gitlab/middleware/go_spec.rb +++ b/spec/lib/gitlab/middleware/go_spec.rb @@ -15,16 +15,93 @@ describe Gitlab::Middleware::Go, lib: true do end describe 'when go-get=1' do - it 'returns a document' do - env = { 'rack.input' => '', - 'QUERY_STRING' => 'go-get=1', - 'PATH_INFO' => '/group/project/path' } - resp = middleware.call(env) - expect(resp[0]).to eq(200) - expect(resp[1]['Content-Type']).to eq('text/html') - expected_body = "<!DOCTYPE html><html><head><meta content='#{Gitlab.config.gitlab.host}/group/project git http://#{Gitlab.config.gitlab.host}/group/project.git' name='go-import'></head></html>\n" - expect(resp[2].body).to eq([expected_body]) + let(:current_user) { nil } + + context 'with simple 2-segment project path' do + let!(:project) { create(:project, :private) } + + context 'with subpackages' do + let(:path) { "#{project.full_path}/subpackage" } + + it 'returns the full project path' do + expect_response_with_path(go, project.full_path) + end + end + + context 'without subpackages' do + let(:path) { project.full_path } + + it 'returns the full project path' do + expect_response_with_path(go, project.full_path) + end + end + end + + context 'with a nested project path' do + let(:group) { create(:group, :nested) } + let!(:project) { create(:project, :public, namespace: group) } + + shared_examples 'a nested project' do + context 'when the project is public' do + it 'returns the full project path' do + expect_response_with_path(go, project.full_path) + end + end + + context 'when the project is private' do + before do + project.update_attribute(:visibility_level, Project::PRIVATE) + end + + context 'with access to the project' do + let(:current_user) { project.creator } + + before do + project.team.add_master(current_user) + end + + it 'returns the full project path' do + expect_response_with_path(go, project.full_path) + end + end + + context 'without access to the project' do + it 'returns the 2-segment group path' do + expect_response_with_path(go, group.full_path) + end + end + end + end + + context 'with subpackages' do + let(:path) { "#{project.full_path}/subpackage" } + + it_behaves_like 'a nested project' + end + + context 'without subpackages' do + let(:path) { project.full_path } + + it_behaves_like 'a nested project' + end end end + + def go + env = { + 'rack.input' => '', + 'QUERY_STRING' => 'go-get=1', + 'PATH_INFO' => "/#{path}", + 'warden' => double(authenticate: current_user) + } + middleware.call(env) + end + + def expect_response_with_path(response, path) + expect(response[0]).to eq(200) + expect(response[1]['Content-Type']).to eq('text/html') + expected_body = "<!DOCTYPE html><html><head><meta content='#{Gitlab.config.gitlab.host}/#{path} git http://#{Gitlab.config.gitlab.host}/#{path}.git' name='go-import'></head></html>\n" + expect(response[2].body).to eq([expected_body]) + end end end diff --git a/spec/lib/gitlab/o_auth/user_spec.rb b/spec/lib/gitlab/o_auth/user_spec.rb index fc9e1cb430a..6c84a4c8b73 100644 --- a/spec/lib/gitlab/o_auth/user_spec.rb +++ b/spec/lib/gitlab/o_auth/user_spec.rb @@ -148,12 +148,14 @@ describe Gitlab::OAuth::User, lib: true do expect(gl_user).to be_valid expect(gl_user.username).to eql uid expect(gl_user.email).to eql 'johndoe@example.com' - expect(gl_user.identities.length).to eql 2 + expect(gl_user.identities.length).to be 2 identities_as_hash = gl_user.identities.map { |id| { provider: id.provider, extern_uid: id.extern_uid } } expect(identities_as_hash).to match_array( - [ { provider: 'ldapmain', extern_uid: 'uid=user1,ou=People,dc=example' }, + [ + { provider: 'ldapmain', extern_uid: 'uid=user1,ou=People,dc=example' }, { provider: 'twitter', extern_uid: uid } - ]) + ] + ) end end @@ -167,12 +169,14 @@ describe Gitlab::OAuth::User, lib: true do expect(gl_user).to be_valid expect(gl_user.username).to eql 'john' expect(gl_user.email).to eql 'john@example.com' - expect(gl_user.identities.length).to eql 2 + expect(gl_user.identities.length).to be 2 identities_as_hash = gl_user.identities.map { |id| { provider: id.provider, extern_uid: id.extern_uid } } expect(identities_as_hash).to match_array( - [ { provider: 'ldapmain', extern_uid: 'uid=user1,ou=People,dc=example' }, + [ + { provider: 'ldapmain', extern_uid: 'uid=user1,ou=People,dc=example' }, { provider: 'twitter', extern_uid: uid } - ]) + ] + ) end end diff --git a/spec/lib/gitlab/optimistic_locking_spec.rb b/spec/lib/gitlab/optimistic_locking_spec.rb index 498dc514c8c..acce2be93f2 100644 --- a/spec/lib/gitlab/optimistic_locking_spec.rb +++ b/spec/lib/gitlab/optimistic_locking_spec.rb @@ -1,10 +1,10 @@ require 'spec_helper' describe Gitlab::OptimisticLocking, lib: true do - describe '#retry_lock' do - let!(:pipeline) { create(:ci_pipeline) } - let!(:pipeline2) { Ci::Pipeline.find(pipeline.id) } + let!(:pipeline) { create(:ci_pipeline) } + let!(:pipeline2) { Ci::Pipeline.find(pipeline.id) } + describe '#retry_lock' do it 'does not reload object if state changes' do expect(pipeline).not_to receive(:reload) expect(pipeline).to receive(:succeed).and_call_original @@ -36,4 +36,17 @@ describe Gitlab::OptimisticLocking, lib: true do end.to raise_error(ActiveRecord::StaleObjectError) end end + + describe '#retry_optimistic_lock' do + context 'when locking module is mixed in' do + let(:unlockable) do + Class.new.include(described_class).new + end + + it 'is an alias for retry_lock' do + expect(unlockable.method(:retry_optimistic_lock)) + .to eq unlockable.method(:retry_lock) + end + end + end end diff --git a/spec/lib/gitlab/prometheus_spec.rb b/spec/lib/gitlab/prometheus_spec.rb new file mode 100644 index 00000000000..280264188e2 --- /dev/null +++ b/spec/lib/gitlab/prometheus_spec.rb @@ -0,0 +1,143 @@ +require 'spec_helper' + +describe Gitlab::Prometheus, lib: true do + include PrometheusHelpers + + subject { described_class.new(api_url: 'https://prometheus.example.com') } + + describe '#ping' do + it 'issues a "query" request to the API endpoint' do + req_stub = stub_prometheus_request(prometheus_query_url('1'), body: prometheus_value_body('vector')) + + expect(subject.ping).to eq({ "resultType" => "vector", "result" => [{ "metric" => {}, "value" => [1488772511.004, "0.000041021495238095323"] }] }) + expect(req_stub).to have_been_requested + end + end + + # This shared examples expect: + # - query_url: A query URL + # - execute_query: A query call + shared_examples 'failure response' do + context 'when request returns 400 with an error message' do + it 'raises a Gitlab::PrometheusError error' do + req_stub = stub_prometheus_request(query_url, status: 400, body: { error: 'bar!' }) + + expect { execute_query } + .to raise_error(Gitlab::PrometheusError, 'bar!') + expect(req_stub).to have_been_requested + end + end + + context 'when request returns 400 without an error message' do + it 'raises a Gitlab::PrometheusError error' do + req_stub = stub_prometheus_request(query_url, status: 400) + + expect { execute_query } + .to raise_error(Gitlab::PrometheusError, 'Bad data received') + expect(req_stub).to have_been_requested + end + end + + context 'when request returns 500' do + it 'raises a Gitlab::PrometheusError error' do + req_stub = stub_prometheus_request(query_url, status: 500, body: { message: 'FAIL!' }) + + expect { execute_query } + .to raise_error(Gitlab::PrometheusError, '500 - {"message":"FAIL!"}') + expect(req_stub).to have_been_requested + end + end + end + + describe '#query' do + let(:prometheus_query) { prometheus_cpu_query('env-slug') } + let(:query_url) { prometheus_query_url(prometheus_query) } + + context 'when request returns vector results' do + it 'returns data from the API call' do + req_stub = stub_prometheus_request(query_url, body: prometheus_value_body('vector')) + + expect(subject.query(prometheus_query)).to eq [{ "metric" => {}, "value" => [1488772511.004, "0.000041021495238095323"] }] + expect(req_stub).to have_been_requested + end + end + + context 'when request returns matrix results' do + it 'returns nil' do + req_stub = stub_prometheus_request(query_url, body: prometheus_value_body('matrix')) + + expect(subject.query(prometheus_query)).to be_nil + expect(req_stub).to have_been_requested + end + end + + context 'when request returns no data' do + it 'returns []' do + req_stub = stub_prometheus_request(query_url, body: prometheus_empty_body('vector')) + + expect(subject.query(prometheus_query)).to be_empty + expect(req_stub).to have_been_requested + end + end + + it_behaves_like 'failure response' do + let(:execute_query) { subject.query(prometheus_query) } + end + end + + describe '#query_range' do + let(:prometheus_query) { prometheus_memory_query('env-slug') } + let(:query_url) { prometheus_query_range_url(prometheus_query) } + + around do |example| + Timecop.freeze { example.run } + end + + context 'when a start time is passed' do + let(:query_url) { prometheus_query_range_url(prometheus_query, start: 2.hours.ago) } + + it 'passed it in the requested URL' do + req_stub = stub_prometheus_request(query_url, body: prometheus_values_body('vector')) + + subject.query_range(prometheus_query, start: 2.hours.ago) + expect(req_stub).to have_been_requested + end + end + + context 'when request returns vector results' do + it 'returns nil' do + req_stub = stub_prometheus_request(query_url, body: prometheus_values_body('vector')) + + expect(subject.query_range(prometheus_query)).to be_nil + expect(req_stub).to have_been_requested + end + end + + context 'when request returns matrix results' do + it 'returns data from the API call' do + req_stub = stub_prometheus_request(query_url, body: prometheus_values_body('matrix')) + + expect(subject.query_range(prometheus_query)).to eq([ + { + "metric" => {}, + "values" => [[1488758662.506, "0.00002996364761904785"], [1488758722.506, "0.00003090239047619091"]] + } + ]) + expect(req_stub).to have_been_requested + end + end + + context 'when request returns no data' do + it 'returns []' do + req_stub = stub_prometheus_request(query_url, body: prometheus_empty_body('matrix')) + + expect(subject.query_range(prometheus_query)).to be_empty + expect(req_stub).to have_been_requested + end + end + + it_behaves_like 'failure response' do + let(:execute_query) { subject.query_range(prometheus_query) } + end + end +end diff --git a/spec/lib/gitlab/redis_spec.rb b/spec/lib/gitlab/redis_spec.rb index 917c5c46db1..8b77c925705 100644 --- a/spec/lib/gitlab/redis_spec.rb +++ b/spec/lib/gitlab/redis_spec.rb @@ -3,8 +3,16 @@ require 'spec_helper' describe Gitlab::Redis do include StubENV - before(:each) { clear_raw_config } - after(:each) { clear_raw_config } + let(:config) { 'config/resque.yml' } + + before(:each) do + stub_env('GITLAB_REDIS_CONFIG_FILE', Rails.root.join(config).to_s) + clear_raw_config + end + + after(:each) do + clear_raw_config + end describe '.params' do subject { described_class.params } @@ -18,22 +26,22 @@ describe Gitlab::Redis do end context 'when url contains unix socket reference' do - let(:config_old) { Rails.root.join('spec/fixtures/config/redis_old_format_socket.yml').to_s } - let(:config_new) { Rails.root.join('spec/fixtures/config/redis_new_format_socket.yml').to_s } + let(:config_old) { 'spec/fixtures/config/redis_old_format_socket.yml' } + let(:config_new) { 'spec/fixtures/config/redis_new_format_socket.yml' } context 'with old format' do - it 'returns path key instead' do - stub_const("#{described_class}::CONFIG_FILE", config_old) + let(:config) { config_old } + it 'returns path key instead' do is_expected.to include(path: '/path/to/old/redis.sock') is_expected.not_to have_key(:url) end end context 'with new format' do - it 'returns path key instead' do - stub_const("#{described_class}::CONFIG_FILE", config_new) + let(:config) { config_new } + it 'returns path key instead' do is_expected.to include(path: '/path/to/redis.sock') is_expected.not_to have_key(:url) end @@ -41,22 +49,22 @@ describe Gitlab::Redis do end context 'when url is host based' do - let(:config_old) { Rails.root.join('spec/fixtures/config/redis_old_format_host.yml') } - let(:config_new) { Rails.root.join('spec/fixtures/config/redis_new_format_host.yml') } + let(:config_old) { 'spec/fixtures/config/redis_old_format_host.yml' } + let(:config_new) { 'spec/fixtures/config/redis_new_format_host.yml' } context 'with old format' do - it 'returns hash with host, port, db, and password' do - stub_const("#{described_class}::CONFIG_FILE", config_old) + let(:config) { config_old } + it 'returns hash with host, port, db, and password' do is_expected.to include(host: 'localhost', password: 'mypassword', port: 6379, db: 99) is_expected.not_to have_key(:url) end end context 'with new format' do - it 'returns hash with host, port, db, and password' do - stub_const("#{described_class}::CONFIG_FILE", config_new) + let(:config) { config_new } + it 'returns hash with host, port, db, and password' do is_expected.to include(host: 'localhost', password: 'mynewpassword', port: 6379, db: 99) is_expected.not_to have_key(:url) end @@ -74,15 +82,13 @@ describe Gitlab::Redis do end context 'when yml file with env variable' do - let(:redis_config) { Rails.root.join('spec/fixtures/config/redis_config_with_env.yml') } + let(:config) { 'spec/fixtures/config/redis_config_with_env.yml' } before do stub_env('TEST_GITLAB_REDIS_URL', 'redis://redishost:6379') end it 'reads redis url from env variable' do - stub_const("#{described_class}::CONFIG_FILE", redis_config) - expect(described_class.url).to eq 'redis://redishost:6379' end end @@ -90,14 +96,13 @@ describe Gitlab::Redis do describe '._raw_config' do subject { described_class._raw_config } + let(:config) { '/var/empty/doesnotexist' } it 'should be frozen' do expect(subject).to be_frozen end it 'returns false when the file does not exist' do - stub_const("#{described_class}::CONFIG_FILE", '/var/empty/doesnotexist') - expect(subject).to eq(false) end end @@ -134,22 +139,18 @@ describe Gitlab::Redis do subject { described_class.new(Rails.env).sentinels } context 'when sentinels are defined' do - let(:config) { Rails.root.join('spec/fixtures/config/redis_new_format_host.yml') } + let(:config) { 'spec/fixtures/config/redis_new_format_host.yml' } it 'returns an array of hashes with host and port keys' do - stub_const("#{described_class}::CONFIG_FILE", config) - is_expected.to include(host: 'localhost', port: 26380) is_expected.to include(host: 'slave2', port: 26381) end end context 'when sentinels are not defined' do - let(:config) { Rails.root.join('spec/fixtures/config/redis_old_format_host.yml') } + let(:config) { 'spec/fixtures/config/redis_old_format_host.yml' } it 'returns nil' do - stub_const("#{described_class}::CONFIG_FILE", config) - is_expected.to be_nil end end @@ -159,21 +160,17 @@ describe Gitlab::Redis do subject { described_class.new(Rails.env).sentinels? } context 'when sentinels are defined' do - let(:config) { Rails.root.join('spec/fixtures/config/redis_new_format_host.yml') } + let(:config) { 'spec/fixtures/config/redis_new_format_host.yml' } it 'returns true' do - stub_const("#{described_class}::CONFIG_FILE", config) - is_expected.to be_truthy end end context 'when sentinels are not defined' do - let(:config) { Rails.root.join('spec/fixtures/config/redis_old_format_host.yml') } + let(:config) { 'spec/fixtures/config/redis_old_format_host.yml' } it 'returns false' do - stub_const("#{described_class}::CONFIG_FILE", config) - is_expected.to be_falsey end end diff --git a/spec/lib/gitlab/regex_spec.rb b/spec/lib/gitlab/regex_spec.rb index 089ec4e2737..ba45e2d758c 100644 --- a/spec/lib/gitlab/regex_spec.rb +++ b/spec/lib/gitlab/regex_spec.rb @@ -51,8 +51,8 @@ describe Gitlab::Regex, lib: true do it { is_expected.not_to match('foo-') } end - describe 'NAMESPACE_REF_REGEX_STR' do - subject { %r{\A#{Gitlab::Regex::NAMESPACE_REF_REGEX_STR}\z} } + describe 'FULL_NAMESPACE_REGEX_STR' do + subject { %r{\A#{Gitlab::Regex::FULL_NAMESPACE_REGEX_STR}\z} } it { is_expected.to match('gitlab.org') } it { is_expected.to match('gitlab.org/gitlab-git') } diff --git a/spec/lib/gitlab/request_context_spec.rb b/spec/lib/gitlab/request_context_spec.rb new file mode 100644 index 00000000000..a91c8655cdd --- /dev/null +++ b/spec/lib/gitlab/request_context_spec.rb @@ -0,0 +1,30 @@ +require 'spec_helper' + +describe Gitlab::RequestContext, lib: true do + describe '#client_ip' do + subject { Gitlab::RequestContext.client_ip } + let(:app) { -> (env) {} } + let(:env) { Hash.new } + + context 'when RequestStore::Middleware is used' do + around(:each) do |example| + RequestStore::Middleware.new(-> (env) { example.run }).call({}) + end + + context 'request' do + let(:ip) { '192.168.1.11' } + + before do + allow_any_instance_of(Rack::Request).to receive(:ip).and_return(ip) + Gitlab::RequestContext.new(app).call(env) + end + + it { is_expected.to eq(ip) } + end + + context 'before RequestContext middleware run' do + it { is_expected.to be_nil } + end + end + end +end diff --git a/spec/lib/gitlab/saml/user_spec.rb b/spec/lib/gitlab/saml/user_spec.rb index 02c139f1a0d..4f6ef3c10fc 100644 --- a/spec/lib/gitlab/saml/user_spec.rb +++ b/spec/lib/gitlab/saml/user_spec.rb @@ -155,11 +155,10 @@ describe Gitlab::Saml::User, lib: true do expect(gl_user).to be_valid expect(gl_user.username).to eql uid expect(gl_user.email).to eql 'john@mail.com' - expect(gl_user.identities.length).to eql 2 + expect(gl_user.identities.length).to be 2 identities_as_hash = gl_user.identities.map { |id| { provider: id.provider, extern_uid: id.extern_uid } } - expect(identities_as_hash).to match_array([ { provider: 'ldapmain', extern_uid: 'uid=user1,ou=People,dc=example' }, - { provider: 'saml', extern_uid: uid } - ]) + expect(identities_as_hash).to match_array([{ provider: 'ldapmain', extern_uid: 'uid=user1,ou=People,dc=example' }, + { provider: 'saml', extern_uid: uid }]) end end @@ -178,11 +177,10 @@ describe Gitlab::Saml::User, lib: true do expect(gl_user).to be_valid expect(gl_user.username).to eql 'john' expect(gl_user.email).to eql 'john@mail.com' - expect(gl_user.identities.length).to eql 2 + expect(gl_user.identities.length).to be 2 identities_as_hash = gl_user.identities.map { |id| { provider: id.provider, extern_uid: id.extern_uid } } - expect(identities_as_hash).to match_array([ { provider: 'ldapmain', extern_uid: 'uid=user1,ou=People,dc=example' }, - { provider: 'saml', extern_uid: uid } - ]) + expect(identities_as_hash).to match_array([{ provider: 'ldapmain', extern_uid: 'uid=user1,ou=People,dc=example' }, + { provider: 'saml', extern_uid: uid }]) end it 'saves successfully on subsequent tries, when both identities are present' do @@ -204,11 +202,10 @@ describe Gitlab::Saml::User, lib: true do local_gl_user = local_saml_user.gl_user expect(local_gl_user).to be_valid - expect(local_gl_user.identities.length).to eql 2 + expect(local_gl_user.identities.length).to be 2 identities_as_hash = local_gl_user.identities.map { |id| { provider: id.provider, extern_uid: id.extern_uid } } - expect(identities_as_hash).to match_array([ { provider: 'ldapmain', extern_uid: 'uid=user1,ou=People,dc=example' }, - { provider: 'saml', extern_uid: 'uid=user1,ou=People,dc=example' } - ]) + expect(identities_as_hash).to match_array([{ provider: 'ldapmain', extern_uid: 'uid=user1,ou=People,dc=example' }, + { provider: 'saml', extern_uid: 'uid=user1,ou=People,dc=example' }]) end end end diff --git a/spec/lib/gitlab/serializer/ci/variables_spec.rb b/spec/lib/gitlab/serializer/ci/variables_spec.rb index b810c68ea03..c4b7fda5dbb 100644 --- a/spec/lib/gitlab/serializer/ci/variables_spec.rb +++ b/spec/lib/gitlab/serializer/ci/variables_spec.rb @@ -13,6 +13,7 @@ describe Gitlab::Serializer::Ci::Variables do it 'converts keys into strings' do is_expected.to eq([ { key: 'key', value: 'value', public: true }, - { key: 'wee', value: 1, public: false }]) + { key: 'wee', value: 1, public: false } + ]) end end diff --git a/spec/lib/gitlab/sidekiq_status_spec.rb b/spec/lib/gitlab/sidekiq_status_spec.rb index 0aa36a3416b..56f06b61afb 100644 --- a/spec/lib/gitlab/sidekiq_status_spec.rb +++ b/spec/lib/gitlab/sidekiq_status_spec.rb @@ -39,6 +39,32 @@ describe Gitlab::SidekiqStatus do end end + describe '.num_running', :redis do + it 'returns 0 if all jobs have been completed' do + expect(described_class.num_running(%w(123))).to eq(0) + end + + it 'returns 2 if two jobs are still running' do + described_class.set('123') + described_class.set('456') + + expect(described_class.num_running(%w(123 456 789))).to eq(2) + end + end + + describe '.num_completed', :redis do + it 'returns 1 if all jobs have been completed' do + expect(described_class.num_completed(%w(123))).to eq(1) + end + + it 'returns 1 if a job has not yet been completed' do + described_class.set('123') + described_class.set('456') + + expect(described_class.num_completed(%w(123 456 789))).to eq(1) + end + end + describe '.key_for' do it 'returns the key for a job ID' do key = described_class.key_for('123') diff --git a/spec/lib/gitlab/template/issue_template_spec.rb b/spec/lib/gitlab/template/issue_template_spec.rb index 1335a2b8f35..9213ced7b19 100644 --- a/spec/lib/gitlab/template/issue_template_spec.rb +++ b/spec/lib/gitlab/template/issue_template_spec.rb @@ -11,7 +11,8 @@ describe Gitlab::Template::IssueTemplate do create_template: { user: user, access: Gitlab::Access::MASTER, - path: 'issue_templates' }) + path: 'issue_templates' + }) end describe '.all' do diff --git a/spec/lib/gitlab/template/merge_request_template_spec.rb b/spec/lib/gitlab/template/merge_request_template_spec.rb index 320b870309a..77dd3079e22 100644 --- a/spec/lib/gitlab/template/merge_request_template_spec.rb +++ b/spec/lib/gitlab/template/merge_request_template_spec.rb @@ -11,7 +11,8 @@ describe Gitlab::Template::MergeRequestTemplate do create_template: { user: user, access: Gitlab::Access::MASTER, - path: 'merge_request_templates' }) + path: 'merge_request_templates' + }) end describe '.all' do diff --git a/spec/lib/gitlab/upgrader_spec.rb b/spec/lib/gitlab/upgrader_spec.rb index edadab043d7..fcfd8d58b70 100644 --- a/spec/lib/gitlab/upgrader_spec.rb +++ b/spec/lib/gitlab/upgrader_spec.rb @@ -32,7 +32,8 @@ describe Gitlab::Upgrader, lib: true do '43af3e65a486a9237f29f56d96c3b3da59c24ae0 refs/tags/v7.11.2', 'dac18e7728013a77410e926a1e64225703754a2d refs/tags/v7.11.2^{}', '0bf21fd4b46c980c26fd8c90a14b86a4d90cc950 refs/tags/v7.9.4', - 'b10de29edbaff7219547dc506cb1468ee35065c3 refs/tags/v7.9.4^{}']) + 'b10de29edbaff7219547dc506cb1468ee35065c3 refs/tags/v7.9.4^{}' + ]) expect(upgrader.latest_version_raw).to eq("v7.11.2") end end diff --git a/spec/lib/gitlab/url_blocker_spec.rb b/spec/lib/gitlab/url_blocker_spec.rb new file mode 100644 index 00000000000..a504d299307 --- /dev/null +++ b/spec/lib/gitlab/url_blocker_spec.rb @@ -0,0 +1,31 @@ +require 'spec_helper' + +describe Gitlab::UrlBlocker, lib: true do + describe '#blocked_url?' do + it 'allows imports from configured web host and port' do + import_url = "http://#{Gitlab.config.gitlab.host}:#{Gitlab.config.gitlab.port}/t.git" + expect(described_class.blocked_url?(import_url)).to be false + end + + it 'allows imports from configured SSH host and port' do + import_url = "http://#{Gitlab.config.gitlab_shell.ssh_host}:#{Gitlab.config.gitlab_shell.ssh_port}/t.git" + expect(described_class.blocked_url?(import_url)).to be false + end + + it 'returns true for bad localhost hostname' do + expect(described_class.blocked_url?('https://localhost:65535/foo/foo.git')).to be true + end + + it 'returns true for bad port' do + expect(described_class.blocked_url?('https://gitlab.com:25/foo/foo.git')).to be true + end + + it 'returns true for invalid URL' do + expect(described_class.blocked_url?('http://:8080')).to be true + end + + it 'returns false for legitimate URL' do + expect(described_class.blocked_url?('https://gitlab.com/foo/foo.git')).to be false + end + end +end diff --git a/spec/lib/gitlab/url_sanitizer_spec.rb b/spec/lib/gitlab/url_sanitizer_spec.rb index 2cb74629da8..fc144a2556a 100644 --- a/spec/lib/gitlab/url_sanitizer_spec.rb +++ b/spec/lib/gitlab/url_sanitizer_spec.rb @@ -5,6 +5,7 @@ describe Gitlab::UrlSanitizer, lib: true do let(:url_sanitizer) do described_class.new("https://github.com/me/project.git", credentials: credentials) end + let(:user) { double(:user, username: 'john.doe') } describe '.sanitize' do def sanitize_url(url) @@ -53,12 +54,33 @@ describe Gitlab::UrlSanitizer, lib: true do end end + describe '.valid?' do + it 'validates url strings' do + expect(described_class.valid?(nil)).to be(false) + expect(described_class.valid?('valid@project:url.git')).to be(true) + expect(described_class.valid?('123://invalid:url')).to be(false) + end + end + + describe '.http_credentials_for_user' do + it { expect(described_class.http_credentials_for_user(user)).to eq({ user: 'john.doe' }) } + it { expect(described_class.http_credentials_for_user('foo')).to eq({}) } + end + describe '#sanitized_url' do it { expect(url_sanitizer.sanitized_url).to eq("https://github.com/me/project.git") } end describe '#credentials' do it { expect(url_sanitizer.credentials).to eq(credentials) } + + context 'when user is given to #initialize' do + let(:url_sanitizer) do + described_class.new("https://github.com/me/project.git", credentials: described_class.http_credentials_for_user(user)) + end + + it { expect(url_sanitizer.credentials).to eq({ user: 'john.doe' }) } + end end describe '#full_url' do @@ -69,5 +91,13 @@ describe Gitlab::UrlSanitizer, lib: true do expect(sanitizer.full_url).to eq('user@server:project.git') end + + context 'when user is given to #initialize' do + let(:url_sanitizer) do + described_class.new("https://github.com/me/project.git", credentials: described_class.http_credentials_for_user(user)) + end + + it { expect(url_sanitizer.full_url).to eq("https://john.doe@github.com/me/project.git") } + end end end diff --git a/spec/lib/gitlab/utils_spec.rb b/spec/lib/gitlab/utils_spec.rb index d5d87310874..56772409989 100644 --- a/spec/lib/gitlab/utils_spec.rb +++ b/spec/lib/gitlab/utils_spec.rb @@ -1,7 +1,5 @@ describe Gitlab::Utils, lib: true do - def to_boolean(value) - described_class.to_boolean(value) - end + delegate :to_boolean, to: :described_class describe '.to_boolean' do it 'accepts booleans' do diff --git a/spec/lib/gitlab/visibility_level_spec.rb b/spec/lib/gitlab/visibility_level_spec.rb new file mode 100644 index 00000000000..3255c6f1ef7 --- /dev/null +++ b/spec/lib/gitlab/visibility_level_spec.rb @@ -0,0 +1,21 @@ +require 'spec_helper' + +describe Gitlab::VisibilityLevel, lib: true do + describe '.level_value' do + it 'converts "public" to integer value' do + expect(described_class.level_value('public')).to eq(Gitlab::VisibilityLevel::PUBLIC) + end + + it 'converts string integer to integer value' do + expect(described_class.level_value('20')).to eq(20) + end + + it 'defaults to PRIVATE when string value is not valid' do + expect(described_class.level_value('invalid')).to eq(Gitlab::VisibilityLevel::PRIVATE) + end + + it 'defaults to PRIVATE when integer value is not valid' do + expect(described_class.level_value(100)).to eq(Gitlab::VisibilityLevel::PRIVATE) + end + end +end diff --git a/spec/lib/gitlab/workhorse_spec.rb b/spec/lib/gitlab/workhorse_spec.rb index a32c6131030..8e5e8288c49 100644 --- a/spec/lib/gitlab/workhorse_spec.rb +++ b/spec/lib/gitlab/workhorse_spec.rb @@ -199,4 +199,58 @@ describe Gitlab::Workhorse, lib: true do end end end + + describe '.set_key_and_notify' do + let(:key) { 'test-key' } + let(:value) { 'test-value' } + + subject { described_class.set_key_and_notify(key, value, overwrite: overwrite) } + + shared_examples 'set and notify' do + it 'set and return the same value' do + is_expected.to eq(value) + end + + it 'set and notify' do + expect_any_instance_of(Redis).to receive(:publish) + .with(described_class::NOTIFICATION_CHANNEL, "test-key=test-value") + + subject + end + end + + context 'when we set a new key' do + let(:overwrite) { true } + + it_behaves_like 'set and notify' + end + + context 'when we set an existing key' do + let(:old_value) { 'existing-key' } + + before do + described_class.set_key_and_notify(key, old_value, overwrite: true) + end + + context 'and overwrite' do + let(:overwrite) { true } + + it_behaves_like 'set and notify' + end + + context 'and do not overwrite' do + let(:overwrite) { false } + + it 'try to set but return the previous value' do + is_expected.to eq(old_value) + end + + it 'does not notify' do + expect_any_instance_of(Redis).not_to receive(:publish) + + subject + end + end + end + end end diff --git a/spec/lib/mattermost/command_spec.rb b/spec/lib/mattermost/command_spec.rb index 5ccf1100898..4b5938edeb9 100644 --- a/spec/lib/mattermost/command_spec.rb +++ b/spec/lib/mattermost/command_spec.rb @@ -13,8 +13,7 @@ describe Mattermost::Command do describe '#create' do let(:params) do { team_id: 'abc', - trigger: 'gitlab' - } + trigger: 'gitlab' } end subject { described_class.new(nil).create(params) } @@ -24,7 +23,8 @@ describe Mattermost::Command do stub_request(:post, 'http://mattermost.example.com/api/v3/teams/abc/commands/create'). with(body: { team_id: 'abc', - trigger: 'gitlab' }.to_json). + trigger: 'gitlab' + }.to_json). to_return( status: 200, headers: { 'Content-Type' => 'application/json' }, diff --git a/spec/lib/mattermost/team_spec.rb b/spec/lib/mattermost/team_spec.rb index 2d14be6bcc2..ac493fdb20f 100644 --- a/spec/lib/mattermost/team_spec.rb +++ b/spec/lib/mattermost/team_spec.rb @@ -13,19 +13,20 @@ describe Mattermost::Team do context 'for valid request' do let(:response) do - [{ - "id" => "xiyro8huptfhdndadpz8r3wnbo", - "create_at" => 1482174222155, - "update_at" => 1482174222155, - "delete_at" => 0, - "display_name" => "chatops", - "name" => "chatops", - "email" => "admin@example.com", - "type" => "O", - "company_name" => "", - "allowed_domains" => "", - "invite_id" => "o4utakb9jtb7imctdfzbf9r5ro", - "allow_open_invite" => false }] + { "xiyro8huptfhdndadpz8r3wnbo" => { + "id" => "xiyro8huptfhdndadpz8r3wnbo", + "create_at" => 1482174222155, + "update_at" => 1482174222155, + "delete_at" => 0, + "display_name" => "chatops", + "name" => "chatops", + "email" => "admin@example.com", + "type" => "O", + "company_name" => "", + "allowed_domains" => "", + "invite_id" => "o4utakb9jtb7imctdfzbf9r5ro", + "allow_open_invite" => false + } } end before do @@ -38,7 +39,7 @@ describe Mattermost::Team do end it 'returns a token' do - is_expected.to eq(response) + is_expected.to eq(response.values) end end diff --git a/spec/mailers/emails/builds_spec.rb b/spec/mailers/emails/builds_spec.rb deleted file mode 100644 index d968096783c..00000000000 --- a/spec/mailers/emails/builds_spec.rb +++ /dev/null @@ -1,64 +0,0 @@ -require 'spec_helper' -require 'email_spec' - -describe Notify do - include EmailSpec::Matchers - - include_context 'gitlab email notification' - - describe 'build notification email' do - let(:build) { create(:ci_build) } - let(:project) { build.project } - - shared_examples 'build email' do - it 'contains name of project' do - is_expected.to have_body_text build.project_name - end - - it 'contains link to project' do - is_expected.to have_body_text namespace_project_path(project.namespace, project) - end - end - - shared_examples 'an email with X-GitLab headers containing build details' do - it 'has X-GitLab-Build* headers' do - is_expected.to have_header 'X-GitLab-Build-Id', /#{build.id}/ - is_expected.to have_header 'X-GitLab-Build-Ref', /#{build.ref}/ - end - end - - describe 'build success' do - subject { Notify.build_success_email(build.id, 'wow@example.com') } - before { build.success } - - it_behaves_like 'build email' - it_behaves_like 'an email with X-GitLab headers containing build details' - it_behaves_like 'an email with X-GitLab headers containing project details' - - it 'has header indicating build status' do - is_expected.to have_header 'X-GitLab-Build-Status', 'success' - end - - it 'has the correct subject' do - is_expected.to have_subject /Build success for/ - end - end - - describe 'build fail' do - subject { Notify.build_fail_email(build.id, 'wow@example.com') } - before { build.drop } - - it_behaves_like 'build email' - it_behaves_like 'an email with X-GitLab headers containing build details' - it_behaves_like 'an email with X-GitLab headers containing project details' - - it 'has header indicating build status' do - is_expected.to have_header 'X-GitLab-Build-Status', 'failed' - end - - it 'has the correct subject' do - is_expected.to have_subject /Build failed for/ - end - end - end -end diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index b692142713f..6ee91576676 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -8,6 +8,15 @@ describe Notify do include_context 'gitlab email notification' + def have_referable_subject(referable, reply: false) + prefix = referable.project.name if referable.project + prefix = "Re: #{prefix}" if reply + + suffix = "#{referable.title} (#{referable.to_reference})" + + have_subject [prefix, suffix].compact.join(' | ') + end + context 'for a project' do describe 'items that are assignable, the email' do let(:current_user) { create(:user, email: "current@email.com") } @@ -41,11 +50,11 @@ describe Notify do it_behaves_like 'an unsubscribeable thread' it 'has the correct subject' do - is_expected.to have_subject /#{project.name} \| #{issue.title} \(##{issue.iid}\)/ + is_expected.to have_referable_subject(issue) end it 'contains a link to the new issue' do - is_expected.to have_body_text /#{namespace_project_issue_path project.namespace, project, issue}/ + is_expected.to have_body_text namespace_project_issue_path(project.namespace, project, issue) end context 'when enabled email_author_in_body' do @@ -54,8 +63,8 @@ describe Notify do end it 'contains a link to note author' do - is_expected.to have_body_text issue.author_name - is_expected.to have_body_text /wrote\:/ + is_expected.to have_html_escaped_body_text issue.author_name + is_expected.to have_body_text 'wrote:' end end end @@ -66,7 +75,7 @@ describe Notify do it_behaves_like 'it should show Gmail Actions View Issue link' it 'contains the description' do - is_expected.to have_body_text /#{issue_with_description.description}/ + is_expected.to have_html_escaped_body_text issue_with_description.description end end @@ -87,19 +96,19 @@ describe Notify do end it 'has the correct subject' do - is_expected.to have_subject /#{issue.title} \(##{issue.iid}\)/ + is_expected.to have_referable_subject(issue, reply: true) end it 'contains the name of the previous assignee' do - is_expected.to have_body_text /#{previous_assignee.name}/ + is_expected.to have_html_escaped_body_text previous_assignee.name end it 'contains the name of the new assignee' do - is_expected.to have_body_text /#{assignee.name}/ + is_expected.to have_html_escaped_body_text assignee.name end it 'contains a link to the issue' do - is_expected.to have_body_text /#{namespace_project_issue_path project.namespace, project, issue}/ + is_expected.to have_body_text namespace_project_issue_path(project.namespace, project, issue) end end @@ -121,15 +130,15 @@ describe Notify do end it 'has the correct subject' do - is_expected.to have_subject /#{issue.title} \(##{issue.iid}\)/ + is_expected.to have_referable_subject(issue, reply: true) end it 'contains the names of the added labels' do - is_expected.to have_body_text /foo, bar, and baz/ + is_expected.to have_body_text 'foo, bar, and baz' end it 'contains a link to the issue' do - is_expected.to have_body_text /#{namespace_project_issue_path project.namespace, project, issue}/ + is_expected.to have_body_text namespace_project_issue_path(project.namespace, project, issue) end end @@ -150,19 +159,19 @@ describe Notify do end it 'has the correct subject' do - is_expected.to have_subject /#{issue.title} \(##{issue.iid}\)/i + is_expected.to have_referable_subject(issue, reply: true) end it 'contains the new status' do - is_expected.to have_body_text /#{status}/i + is_expected.to have_body_text status end it 'contains the user name' do - is_expected.to have_body_text /#{current_user.name}/i + is_expected.to have_html_escaped_body_text current_user.name end it 'contains a link to the issue' do - is_expected.to have_body_text /#{namespace_project_issue_path project.namespace, project, issue}/ + is_expected.to have_body_text(namespace_project_issue_path project.namespace, project, issue) end end @@ -181,7 +190,7 @@ describe Notify do end it 'has the correct subject' do - is_expected.to have_subject /#{issue.title} \(##{issue.iid}\)/i + is_expected.to have_referable_subject(issue, reply: true) end it 'contains link to new issue' do @@ -191,7 +200,7 @@ describe Notify do end it 'contains a link to the original issue' do - is_expected.to have_body_text /#{namespace_project_issue_path project.namespace, project, issue}/ + is_expected.to have_body_text namespace_project_issue_path(project.namespace, project, issue) end end end @@ -212,19 +221,19 @@ describe Notify do it_behaves_like 'an unsubscribeable thread' it 'has the correct subject' do - is_expected.to have_subject /#{merge_request.title} \(#{merge_request.to_reference}\)/ + is_expected.to have_referable_subject(merge_request) end it 'contains a link to the new merge request' do - is_expected.to have_body_text /#{namespace_project_merge_request_path(project.namespace, project, merge_request)}/ + is_expected.to have_body_text namespace_project_merge_request_path(project.namespace, project, merge_request) end it 'contains the source branch for the merge request' do - is_expected.to have_body_text /#{merge_request.source_branch}/ + is_expected.to have_body_text merge_request.source_branch end it 'contains the target branch for the merge request' do - is_expected.to have_body_text /#{merge_request.target_branch}/ + is_expected.to have_body_text merge_request.target_branch end context 'when enabled email_author_in_body' do @@ -233,8 +242,8 @@ describe Notify do end it 'contains a link to note author' do - is_expected.to have_body_text merge_request.author_name - is_expected.to have_body_text /wrote\:/ + is_expected.to have_html_escaped_body_text merge_request.author_name + is_expected.to have_body_text 'wrote:' end end end @@ -246,7 +255,7 @@ describe Notify do it_behaves_like "an unsubscribeable thread" it 'contains the description' do - is_expected.to have_body_text /#{merge_request_with_description.description}/ + is_expected.to have_html_escaped_body_text merge_request_with_description.description end end @@ -267,19 +276,19 @@ describe Notify do end it 'has the correct subject' do - is_expected.to have_subject /#{merge_request.title} \(#{merge_request.to_reference}\)/ + is_expected.to have_referable_subject(merge_request, reply: true) end it 'contains the name of the previous assignee' do - is_expected.to have_body_text /#{previous_assignee.name}/ + is_expected.to have_html_escaped_body_text previous_assignee.name end it 'contains the name of the new assignee' do - is_expected.to have_body_text /#{assignee.name}/ + is_expected.to have_html_escaped_body_text assignee.name end it 'contains a link to the merge request' do - is_expected.to have_body_text /#{namespace_project_merge_request_path project.namespace, project, merge_request}/ + is_expected.to have_body_text namespace_project_merge_request_path(project.namespace, project, merge_request) end end @@ -301,15 +310,15 @@ describe Notify do end it 'has the correct subject' do - is_expected.to have_subject /#{merge_request.title} \(#{merge_request.to_reference}\)/ + is_expected.to have_referable_subject(merge_request, reply: true) end it 'contains the names of the added labels' do - is_expected.to have_body_text /foo, bar, and baz/ + is_expected.to have_body_text 'foo, bar, and baz' end it 'contains a link to the merge request' do - is_expected.to have_body_text /#{namespace_project_merge_request_path project.namespace, project, merge_request}/ + is_expected.to have_body_text namespace_project_merge_request_path(project.namespace, project, merge_request) end end @@ -330,19 +339,19 @@ describe Notify do end it 'has the correct subject' do - is_expected.to have_subject /#{merge_request.title} \(#{merge_request.to_reference}\)/i + is_expected.to have_referable_subject(merge_request, reply: true) end it 'contains the new status' do - is_expected.to have_body_text /#{status}/i + is_expected.to have_body_text status end it 'contains the user name' do - is_expected.to have_body_text /#{current_user.name}/i + is_expected.to have_html_escaped_body_text current_user.name end it 'contains a link to the merge request' do - is_expected.to have_body_text /#{namespace_project_merge_request_path project.namespace, project, merge_request}/ + is_expected.to have_body_text namespace_project_merge_request_path(project.namespace, project, merge_request) end end @@ -363,15 +372,15 @@ describe Notify do end it 'has the correct subject' do - is_expected.to have_subject /#{merge_request.title} \(#{merge_request.to_reference}\)/ + is_expected.to have_referable_subject(merge_request, reply: true) end it 'contains the new status' do - is_expected.to have_body_text /merged/i + is_expected.to have_body_text 'merged' end it 'contains a link to the merge request' do - is_expected.to have_body_text /#{namespace_project_merge_request_path project.namespace, project, merge_request}/ + is_expected.to have_body_text namespace_project_merge_request_path(project.namespace, project, merge_request) end end end @@ -387,15 +396,15 @@ describe Notify do it_behaves_like "a user cannot unsubscribe through footer link" it 'has the correct subject' do - is_expected.to have_subject /Project was moved/ + is_expected.to have_subject "#{project.name} | Project was moved" end it 'contains name of project' do - is_expected.to have_body_text /#{project.name_with_namespace}/ + is_expected.to have_html_escaped_body_text project.name_with_namespace end it 'contains new user role' do - is_expected.to have_body_text /#{project.ssh_url_to_repo}/ + is_expected.to have_body_text project.ssh_url_to_repo end end @@ -424,9 +433,9 @@ describe Notify do expect(to_emails[0].address).to eq(project.members.owners_and_masters.first.user.notification_email) is_expected.to have_subject "Request to join the #{project.name_with_namespace} project" - is_expected.to have_body_text /#{project.name_with_namespace}/ - is_expected.to have_body_text /#{namespace_project_project_members_url(project.namespace, project)}/ - is_expected.to have_body_text /#{project_member.human_access}/ + is_expected.to have_html_escaped_body_text project.name_with_namespace + is_expected.to have_body_text namespace_project_project_members_url(project.namespace, project) + is_expected.to have_body_text project_member.human_access end end @@ -451,9 +460,9 @@ describe Notify do expect(to_emails[0].address).to eq(group.members.owners_and_masters.first.user.notification_email) is_expected.to have_subject "Request to join the #{project.name_with_namespace} project" - is_expected.to have_body_text /#{project.name_with_namespace}/ - is_expected.to have_body_text /#{namespace_project_project_members_url(project.namespace, project)}/ - is_expected.to have_body_text /#{project_member.human_access}/ + is_expected.to have_html_escaped_body_text project.name_with_namespace + is_expected.to have_body_text namespace_project_project_members_url(project.namespace, project) + is_expected.to have_body_text project_member.human_access end end end @@ -473,13 +482,14 @@ describe Notify do it 'contains all the useful information' do is_expected.to have_subject "Access to the #{project.name_with_namespace} project was denied" - is_expected.to have_body_text /#{project.name_with_namespace}/ - is_expected.to have_body_text /#{project.web_url}/ + is_expected.to have_html_escaped_body_text project.name_with_namespace + is_expected.to have_body_text project.web_url end end describe 'project access changed' do - let(:project) { create(:empty_project, :public, :access_requestable) } + let(:owner) { create(:user, name: "Chang O'Keefe") } + let(:project) { create(:empty_project, :public, :access_requestable, namespace: owner.namespace) } let(:user) { create(:user) } let(:project_member) { create(:project_member, project: project, user: user) } subject { Notify.member_access_granted_email('project', project_member.id) } @@ -490,9 +500,9 @@ describe Notify do it 'contains all the useful information' do is_expected.to have_subject "Access to the #{project.name_with_namespace} project was granted" - is_expected.to have_body_text /#{project.name_with_namespace}/ - is_expected.to have_body_text /#{project.web_url}/ - is_expected.to have_body_text /#{project_member.human_access}/ + is_expected.to have_html_escaped_body_text project.name_with_namespace + is_expected.to have_body_text project.web_url + is_expected.to have_body_text project_member.human_access end end @@ -521,10 +531,10 @@ describe Notify do it 'contains all the useful information' do is_expected.to have_subject "Invitation to join the #{project.name_with_namespace} project" - is_expected.to have_body_text /#{project.name_with_namespace}/ - is_expected.to have_body_text /#{project.web_url}/ - is_expected.to have_body_text /#{project_member.human_access}/ - is_expected.to have_body_text /#{project_member.invite_token}/ + is_expected.to have_html_escaped_body_text project.name_with_namespace + is_expected.to have_body_text project.web_url + is_expected.to have_body_text project_member.human_access + is_expected.to have_body_text project_member.invite_token end end @@ -546,10 +556,10 @@ describe Notify do it 'contains all the useful information' do is_expected.to have_subject 'Invitation accepted' - is_expected.to have_body_text /#{project.name_with_namespace}/ - is_expected.to have_body_text /#{project.web_url}/ - is_expected.to have_body_text /#{project_member.invite_email}/ - is_expected.to have_body_text /#{invited_user.name}/ + is_expected.to have_html_escaped_body_text project.name_with_namespace + is_expected.to have_body_text project.web_url + is_expected.to have_body_text project_member.invite_email + is_expected.to have_html_escaped_body_text invited_user.name end end @@ -570,9 +580,9 @@ describe Notify do it 'contains all the useful information' do is_expected.to have_subject 'Invitation declined' - is_expected.to have_body_text /#{project.name_with_namespace}/ - is_expected.to have_body_text /#{project.web_url}/ - is_expected.to have_body_text /#{project_member.invite_email}/ + is_expected.to have_html_escaped_body_text project.name_with_namespace + is_expected.to have_body_text project.web_url + is_expected.to have_body_text project_member.invite_email end end @@ -598,11 +608,11 @@ describe Notify do end it 'contains the message from the note' do - is_expected.to have_body_text /#{note.note}/ + is_expected.to have_html_escaped_body_text note.note end it 'does not contain note author' do - is_expected.not_to have_body_text /wrote\:/ + is_expected.not_to have_body_text 'wrote:' end context 'when enabled email_author_in_body' do @@ -611,8 +621,8 @@ describe Notify do end it 'contains a link to note author' do - is_expected.to have_body_text note.author_name - is_expected.to have_body_text /wrote\:/ + is_expected.to have_html_escaped_body_text note.author_name + is_expected.to have_body_text 'wrote:' end end end @@ -632,7 +642,7 @@ describe Notify do it_behaves_like 'a user cannot unsubscribe through footer link' it 'has the correct subject' do - is_expected.to have_subject /Re: #{project.name} | #{commit.title} \(#{commit.short_id}\)/ + is_expected.to have_subject "Re: #{project.name} | #{commit.title.strip} (#{commit.short_id})" end it 'contains a link to the commit' do @@ -655,11 +665,11 @@ describe Notify do it_behaves_like 'an unsubscribeable thread' it 'has the correct subject' do - is_expected.to have_subject /#{merge_request.title} \(#{merge_request.to_reference}\)/ + is_expected.to have_referable_subject(merge_request, reply: true) end it 'contains a link to the merge request note' do - is_expected.to have_body_text /#{note_on_merge_request_path}/ + is_expected.to have_body_text note_on_merge_request_path end end @@ -678,11 +688,11 @@ describe Notify do it_behaves_like 'an unsubscribeable thread' it 'has the correct subject' do - is_expected.to have_subject /#{issue.title} \(##{issue.iid}\)/ + is_expected.to have_referable_subject(issue, reply: true) end it 'contains a link to the issue note' do - is_expected.to have_body_text /#{note_on_issue_path}/ + is_expected.to have_body_text note_on_issue_path end end end @@ -698,11 +708,11 @@ describe Notify do let(:note) { create(model, project: project, author: note_author) } it "includes diffs with character-level highlighting" do - is_expected.to have_body_text /<span class=\"p\">}<\/span><\/span>/ + is_expected.to have_body_text '<span class="p">}</span></span>' end it 'contains a link to the diff file' do - is_expected.to have_body_text /#{note.diff_file.file_path}/ + is_expected.to have_body_text note.diff_file.file_path end it_behaves_like 'it should have Gmail Actions links' @@ -718,11 +728,11 @@ describe Notify do end it 'contains the message from the note' do - is_expected.to have_body_text /#{note.note}/ + is_expected.to have_html_escaped_body_text note.note end it 'does not contain note author' do - is_expected.not_to have_body_text /wrote\:/ + is_expected.not_to have_body_text 'wrote:' end context 'when enabled email_author_in_body' do @@ -731,8 +741,8 @@ describe Notify do end it 'contains a link to note author' do - is_expected.to have_body_text note.author_name - is_expected.to have_body_text /wrote\:/ + is_expected.to have_html_escaped_body_text note.author_name + is_expected.to have_body_text 'wrote:' end end end @@ -777,9 +787,9 @@ describe Notify do it 'contains all the useful information' do is_expected.to have_subject "Request to join the #{group.name} group" - is_expected.to have_body_text /#{group.name}/ - is_expected.to have_body_text /#{group_group_members_url(group)}/ - is_expected.to have_body_text /#{group_member.human_access}/ + is_expected.to have_html_escaped_body_text group.name + is_expected.to have_body_text group_group_members_url(group) + is_expected.to have_body_text group_member.human_access end end @@ -798,8 +808,8 @@ describe Notify do it 'contains all the useful information' do is_expected.to have_subject "Access to the #{group.name} group was denied" - is_expected.to have_body_text /#{group.name}/ - is_expected.to have_body_text /#{group.web_url}/ + is_expected.to have_html_escaped_body_text group.name + is_expected.to have_body_text group.web_url end end @@ -816,9 +826,9 @@ describe Notify do it 'contains all the useful information' do is_expected.to have_subject "Access to the #{group.name} group was granted" - is_expected.to have_body_text /#{group.name}/ - is_expected.to have_body_text /#{group.web_url}/ - is_expected.to have_body_text /#{group_member.human_access}/ + is_expected.to have_html_escaped_body_text group.name + is_expected.to have_body_text group.web_url + is_expected.to have_body_text group_member.human_access end end @@ -847,10 +857,10 @@ describe Notify do it 'contains all the useful information' do is_expected.to have_subject "Invitation to join the #{group.name} group" - is_expected.to have_body_text /#{group.name}/ - is_expected.to have_body_text /#{group.web_url}/ - is_expected.to have_body_text /#{group_member.human_access}/ - is_expected.to have_body_text /#{group_member.invite_token}/ + is_expected.to have_html_escaped_body_text group.name + is_expected.to have_body_text group.web_url + is_expected.to have_body_text group_member.human_access + is_expected.to have_body_text group_member.invite_token end end @@ -872,10 +882,10 @@ describe Notify do it 'contains all the useful information' do is_expected.to have_subject 'Invitation accepted' - is_expected.to have_body_text /#{group.name}/ - is_expected.to have_body_text /#{group.web_url}/ - is_expected.to have_body_text /#{group_member.invite_email}/ - is_expected.to have_body_text /#{invited_user.name}/ + is_expected.to have_html_escaped_body_text group.name + is_expected.to have_body_text group.web_url + is_expected.to have_body_text group_member.invite_email + is_expected.to have_html_escaped_body_text invited_user.name end end @@ -896,9 +906,9 @@ describe Notify do it 'contains all the useful information' do is_expected.to have_subject 'Invitation declined' - is_expected.to have_body_text /#{group.name}/ - is_expected.to have_body_text /#{group.web_url}/ - is_expected.to have_body_text /#{group_member.invite_email}/ + is_expected.to have_html_escaped_body_text group.name + is_expected.to have_body_text group.web_url + is_expected.to have_body_text group_member.invite_email end end end @@ -925,11 +935,11 @@ describe Notify do end it 'has the correct subject' do - is_expected.to have_subject /^Confirmation instructions/ + is_expected.to have_subject 'Confirmation instructions | A Nice Suffix' end it 'includes a link to the site' do - is_expected.to have_body_text /#{example_site_path}/ + is_expected.to have_body_text example_site_path end end @@ -952,11 +962,11 @@ describe Notify do end it 'has the correct subject' do - is_expected.to have_subject /Pushed new branch master/ + is_expected.to have_subject "[Git][#{project.full_path}] Pushed new branch master" end it 'contains a link to the branch' do - is_expected.to have_body_text /#{tree_path}/ + is_expected.to have_body_text tree_path end end @@ -979,11 +989,11 @@ describe Notify do end it 'has the correct subject' do - is_expected.to have_subject /Pushed new tag v1\.0/ + is_expected.to have_subject "[Git][#{project.full_path}] Pushed new tag v1.0" end it 'contains a link to the tag' do - is_expected.to have_body_text /#{tree_path}/ + is_expected.to have_body_text tree_path end end @@ -1005,7 +1015,7 @@ describe Notify do end it 'has the correct subject' do - is_expected.to have_subject /Deleted branch master/ + is_expected.to have_subject "[Git][#{project.full_path}] Deleted branch master" end end @@ -1027,7 +1037,7 @@ describe Notify do end it 'has the correct subject' do - is_expected.to have_subject /Deleted tag v1\.0/ + is_expected.to have_subject "[Git][#{project.full_path}] Deleted tag v1.0" end end @@ -1055,23 +1065,23 @@ describe Notify do end it 'has the correct subject' do - is_expected.to have_subject /\[#{project.path_with_namespace}\]\[master\] #{commits.length} commits:/ + is_expected.to have_subject "[Git][#{project.full_path}][master] #{commits.length} commits: Ruby files modified" end it 'includes commits list' do - is_expected.to have_body_text /Change some files/ + is_expected.to have_body_text 'Change some files' end it 'includes diffs with character-level highlighting' do - is_expected.to have_body_text /def<\/span> <span class=\"nf\">archive_formats_regex/ + is_expected.to have_body_text 'def</span> <span class="nf">archive_formats_regex' end it 'contains a link to the diff' do - is_expected.to have_body_text /#{diff_path}/ + is_expected.to have_body_text diff_path end it 'does not contain the misleading footer' do - is_expected.not_to have_body_text /you are a member of/ + is_expected.not_to have_body_text 'you are a member of' end context "when set to send from committer email if domain matches" do @@ -1157,19 +1167,19 @@ describe Notify do end it 'has the correct subject' do - is_expected.to have_subject /#{commits.first.title}/ + is_expected.to have_subject "[Git][#{project.full_path}][master] #{commits.first.title}" end it 'includes commits list' do - is_expected.to have_body_text /Change some files/ + is_expected.to have_body_text 'Change some files' end it 'includes diffs with character-level highlighting' do - is_expected.to have_body_text /def<\/span> <span class=\"nf\">archive_formats_regex/ + is_expected.to have_body_text 'def</span> <span class="nf">archive_formats_regex' end it 'contains a link to the diff' do - is_expected.to have_body_text /#{diff_path}/ + is_expected.to have_body_text diff_path end end diff --git a/spec/migrations/migrate_build_events_to_pipeline_events_spec.rb b/spec/migrations/migrate_build_events_to_pipeline_events_spec.rb new file mode 100644 index 00000000000..57eb03e3c80 --- /dev/null +++ b/spec/migrations/migrate_build_events_to_pipeline_events_spec.rb @@ -0,0 +1,74 @@ +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20170301205640_migrate_build_events_to_pipeline_events.rb') + +# This migration uses multiple threads, and thus different transactions. This +# means data created in this spec may not be visible to some threads. To work +# around this we use the TRUNCATE cleaning strategy. +describe MigrateBuildEventsToPipelineEvents, truncate: true do + let(:migration) { described_class.new } + let(:project_with_pipeline_service) { create(:empty_project) } + let(:project_with_build_service) { create(:empty_project) } + + before do + ActiveRecord::Base.connection.execute <<-SQL + INSERT INTO services (properties, build_events, pipeline_events, type) + VALUES + ('{"notify_only_broken_builds":true}', true, false, 'SlackService') + , ('{"notify_only_broken_builds":true}', true, false, 'MattermostService') + , ('{"notify_only_broken_builds":true}', true, false, 'HipchatService') + ; + SQL + + ActiveRecord::Base.connection.execute <<-SQL + INSERT INTO services + (properties, build_events, pipeline_events, type, project_id) + VALUES + ('{"notify_only_broken_builds":true}', true, false, + 'BuildsEmailService', #{project_with_pipeline_service.id}) + , ('{"notify_only_broken_pipelines":true}', false, true, + 'PipelinesEmailService', #{project_with_pipeline_service.id}) + , ('{"notify_only_broken_builds":true}', true, false, + 'BuildsEmailService', #{project_with_build_service.id}) + ; + SQL + end + + describe '#up' do + before do + silence_migration = Module.new do + # rubocop:disable Rails/Delegate + def execute(query) + connection.execute(query) + end + end + + migration.extend(silence_migration) + migration.up + end + + it 'migrates chat service properly' do + [SlackService, MattermostService, HipchatService].each do |service| + expect(service.count).to eq(1) + + verify_service_record(service.first) + end + end + + it 'migrates pipelines email service only if it has none before' do + Project.find_each do |project| + pipeline_service_count = + project.services.where(type: 'PipelinesEmailService').count + + expect(pipeline_service_count).to eq(1) + + verify_service_record(project.pipelines_email_service) + end + end + + def verify_service_record(service) + expect(service.notify_only_broken_pipelines).to be(true) + expect(service.build_events).to be(false) + expect(service.pipeline_events).to be(true) + end + end +end diff --git a/spec/migrations/migrate_process_commit_worker_jobs_spec.rb b/spec/migrations/migrate_process_commit_worker_jobs_spec.rb index 6a93deb5412..b6d678bac18 100644 --- a/spec/migrations/migrate_process_commit_worker_jobs_spec.rb +++ b/spec/migrations/migrate_process_commit_worker_jobs_spec.rb @@ -62,7 +62,7 @@ describe MigrateProcessCommitWorkerJobs do end def pop_job - JSON.load(Sidekiq.redis { |r| r.lpop('queue:process_commit') }) + JSON.parse(Sidekiq.redis { |r| r.lpop('queue:process_commit') }) end before do @@ -198,7 +198,7 @@ describe MigrateProcessCommitWorkerJobs do let(:job) do migration.down - JSON.load(Sidekiq.redis { |r| r.lpop('queue:process_commit') }) + JSON.parse(Sidekiq.redis { |r| r.lpop('queue:process_commit') }) end it 'includes the project ID' do diff --git a/spec/migrations/rename_more_reserved_project_names_spec.rb b/spec/migrations/rename_more_reserved_project_names_spec.rb new file mode 100644 index 00000000000..36e82729c23 --- /dev/null +++ b/spec/migrations/rename_more_reserved_project_names_spec.rb @@ -0,0 +1,47 @@ +# encoding: utf-8 + +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20170313133418_rename_more_reserved_project_names.rb') + +# This migration uses multiple threads, and thus different transactions. This +# means data created in this spec may not be visible to some threads. To work +# around this we use the TRUNCATE cleaning strategy. +describe RenameMoreReservedProjectNames, truncate: true do + let(:migration) { described_class.new } + let!(:project) { create(:empty_project) } + + before do + project.path = 'artifacts' + project.save!(validate: false) + end + + describe '#up' do + context 'when project repository exists' do + before { project.create_repository } + + context 'when no exception is raised' do + it 'renames project with reserved names' do + migration.up + + expect(project.reload.path).to eq('artifacts0') + end + end + + context 'when exception is raised during rename' do + before do + allow(project).to receive(:rename_repo).and_raise(StandardError) + end + + it 'captures exception from project rename' do + expect { migration.up }.not_to raise_error + end + end + end + + context 'when project repository does not exist' do + it 'does not raise error' do + expect { migration.up }.not_to raise_error + end + end + end +end diff --git a/spec/models/ability_spec.rb b/spec/models/ability_spec.rb index 30f8fdf91b2..92d70cfc64c 100644 --- a/spec/models/ability_spec.rb +++ b/spec/models/ability_spec.rb @@ -1,6 +1,12 @@ require 'spec_helper' describe Ability, lib: true do + context 'using a nil subject' do + it 'is always empty' do + expect(Ability.allowed(nil, nil).to_set).to be_empty + end + end + describe '.can_edit_note?' do let(:project) { create(:empty_project) } let(:note) { create(:note_on_issue, project: project) } diff --git a/spec/models/abuse_report_spec.rb b/spec/models/abuse_report_spec.rb index c4486a32082..4e71597521d 100644 --- a/spec/models/abuse_report_spec.rb +++ b/spec/models/abuse_report_spec.rb @@ -2,7 +2,7 @@ require 'rails_helper' RSpec.describe AbuseReport, type: :model do subject { create(:abuse_report) } - let(:user) { create(:user) } + let(:user) { create(:admin) } it { expect(subject).to be_valid } diff --git a/spec/models/appearance_spec.rb b/spec/models/appearance_spec.rb index 0b72a2f979b..1060bf3cbf4 100644 --- a/spec/models/appearance_spec.rb +++ b/spec/models/appearance_spec.rb @@ -7,4 +7,6 @@ RSpec.describe Appearance, type: :model do it { is_expected.to validate_presence_of(:title) } it { is_expected.to validate_presence_of(:description) } + + it { is_expected.to have_many(:uploads).dependent(:destroy) } end diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb index b950fcdd81a..01ca1584ed2 100644 --- a/spec/models/application_setting_spec.rb +++ b/spec/models/application_setting_spec.rb @@ -29,6 +29,40 @@ describe ApplicationSetting, models: true do it { is_expected.not_to allow_value(['test']).for(:disabled_oauth_sign_in_sources) } end + describe 'default_artifacts_expire_in' do + it 'sets an error if it cannot parse' do + setting.update(default_artifacts_expire_in: 'a') + + expect_invalid + end + + it 'sets an error if it is blank' do + setting.update(default_artifacts_expire_in: ' ') + + expect_invalid + end + + it 'sets the value if it is valid' do + setting.update(default_artifacts_expire_in: '30 days') + + expect(setting).to be_valid + expect(setting.default_artifacts_expire_in).to eq('30 days') + end + + it 'sets the value if it is 0' do + setting.update(default_artifacts_expire_in: '0') + + expect(setting).to be_valid + expect(setting.default_artifacts_expire_in).to eq('0') + end + + def expect_invalid + expect(setting).to be_invalid + expect(setting.errors.messages) + .to have_key(:default_artifacts_expire_in) + end + end + it { is_expected.to validate_presence_of(:max_attachment_size) } it do @@ -62,9 +96,9 @@ describe ApplicationSetting, models: true do describe 'inclusion' do it { is_expected.to allow_value('custom1').for(:repository_storages) } - it { is_expected.to allow_value(['custom2', 'custom3']).for(:repository_storages) } + it { is_expected.to allow_value(%w(custom2 custom3)).for(:repository_storages) } it { is_expected.not_to allow_value('alternative').for(:repository_storages) } - it { is_expected.not_to allow_value(['alternative', 'custom1']).for(:repository_storages) } + it { is_expected.not_to allow_value(%w(alternative custom1)).for(:repository_storages) } end describe 'presence' do @@ -83,7 +117,7 @@ describe ApplicationSetting, models: true do describe '#repository_storage' do it 'returns the first storage' do - setting.repository_storages = ['good', 'bad'] + setting.repository_storages = %w(good bad) expect(setting.repository_storage).to eq('good') end diff --git a/spec/models/blob_spec.rb b/spec/models/blob_spec.rb index 03d02b4d382..94c25a454aa 100644 --- a/spec/models/blob_spec.rb +++ b/spec/models/blob_spec.rb @@ -70,6 +70,8 @@ describe Blob do end describe '#to_partial_path' do + let(:project) { double(lfs_enabled?: true) } + def stubbed_blob(overrides = {}) overrides.reverse_merge!( image?: false, @@ -84,34 +86,35 @@ describe Blob do end end - it 'handles LFS pointers' do - blob = stubbed_blob(lfs_pointer?: true) + it 'handles LFS pointers with LFS enabled' do + blob = stubbed_blob(lfs_pointer?: true, text?: true) + expect(blob.to_partial_path(project)).to eq 'download' + end - expect(blob.to_partial_path).to eq 'download' + it 'handles LFS pointers with LFS disabled' do + blob = stubbed_blob(lfs_pointer?: true, text?: true) + project = double(lfs_enabled?: false) + expect(blob.to_partial_path(project)).to eq 'text' end it 'handles SVGs' do blob = stubbed_blob(text?: true, svg?: true) - - expect(blob.to_partial_path).to eq 'image' + expect(blob.to_partial_path(project)).to eq 'image' end it 'handles images' do blob = stubbed_blob(image?: true) - - expect(blob.to_partial_path).to eq 'image' + expect(blob.to_partial_path(project)).to eq 'image' end it 'handles text' do blob = stubbed_blob(text?: true) - - expect(blob.to_partial_path).to eq 'text' + expect(blob.to_partial_path(project)).to eq 'text' end it 'defaults to download' do blob = stubbed_blob - - expect(blob.to_partial_path).to eq 'download' + expect(blob.to_partial_path(project)).to eq 'download' end end diff --git a/spec/models/chat_team_spec.rb b/spec/models/chat_team_spec.rb new file mode 100644 index 00000000000..5283561a83f --- /dev/null +++ b/spec/models/chat_team_spec.rb @@ -0,0 +1,15 @@ +require 'spec_helper' + +describe ChatTeam, type: :model do + subject { create(:chat_team) } + + # Associations + it { is_expected.to belong_to(:namespace) } + + # Validations + it { is_expected.to validate_uniqueness_of(:namespace) } + + # Fields + it { is_expected.to respond_to(:name) } + it { is_expected.to respond_to(:team_id) } +end diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 83a2efb55b9..ac47b34b6fc 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -20,6 +20,30 @@ describe Ci::Build, :models do it { is_expected.to validate_presence_of :ref } it { is_expected.to respond_to :trace_html } + describe '#actionize' do + context 'when build is a created' do + before do + build.update_column(:status, :created) + end + + it 'makes build a manual action' do + expect(build.actionize).to be true + expect(build.reload).to be_manual + end + end + + context 'when build is not created' do + before do + build.update_column(:status, :pending) + end + + it 'does not change build status' do + expect(build.actionize).to be false + expect(build.reload).to be_pending + end + end + end + describe '#any_runners_online?' do subject { build.any_runners_online? } @@ -162,11 +186,17 @@ describe Ci::Build, :models do is_expected.to be_nil end - it 'when resseting value' do + it 'when resetting value' do build.artifacts_expire_in = nil is_expected.to be_nil end + + it 'when setting to 0' do + build.artifacts_expire_in = '0' + + is_expected.to be_nil + end end describe '#commit' do @@ -175,20 +205,6 @@ describe Ci::Build, :models do end end - describe '#create_from' do - before do - build.status = 'success' - build.save - end - let(:create_from_build) { Ci::Build.create_from build } - - it 'exists a pending task' do - expect(Ci::Build.pending.count(:all)).to eq 0 - create_from_build - expect(Ci::Build.pending.count(:all)).to be > 0 - end - end - describe '#depends_on_builds' do let!(:build) { create(:ci_build, pipeline: pipeline, name: 'build', stage_idx: 0, stage: 'build') } let!(:rspec_test) { create(:ci_build, pipeline: pipeline, name: 'rspec', stage_idx: 1, stage: 'test') } @@ -329,11 +345,11 @@ describe Ci::Build, :models do describe '#expanded_environment_name' do subject { build.expanded_environment_name } - context 'when environment uses $CI_BUILD_REF_NAME' do + context 'when environment uses $CI_COMMIT_REF_NAME' do let(:build) do create(:ci_build, ref: 'master', - environment: 'review/$CI_BUILD_REF_NAME') + environment: 'review/$CI_COMMIT_REF_NAME') end it { is_expected.to eq('review/master') } @@ -595,13 +611,21 @@ describe Ci::Build, :models do it { is_expected.to be_falsey } end - context 'and build.status is failed' do + context 'and build status is failed' do before do build.status = 'failed' end it { is_expected.to be_truthy } end + + context 'when build is a manual action' do + before do + build.status = 'manual' + end + + it { is_expected.to be_falsey } + end end end @@ -690,12 +714,12 @@ describe Ci::Build, :models do end end - describe '#manual?' do + describe '#action?' do before do build.update(when: value) end - subject { build.manual? } + subject { build.action? } context 'when is set to manual' do let(:value) { 'manual' } @@ -711,14 +735,50 @@ describe Ci::Build, :models do end end + describe '#has_commands?' do + context 'when build has commands' do + let(:build) do + create(:ci_build, commands: 'rspec') + end + + it 'has commands' do + expect(build).to have_commands + end + end + + context 'when does not have commands' do + context 'when commands are an empty string' do + let(:build) do + create(:ci_build, commands: '') + end + + it 'has no commands' do + expect(build).not_to have_commands + end + end + + context 'when commands are not set at all' do + let(:build) do + create(:ci_build, commands: nil) + end + + it 'has no commands' do + expect(build).not_to have_commands + end + end + end + end + describe '#has_tags?' do context 'when build has tags' do subject { create(:ci_build, tag_list: ['tag']) } + it { is_expected.to have_tags } end context 'when build does not have tags' do subject { create(:ci_build, tag_list: []) } + it { is_expected.not_to have_tags } end end @@ -735,8 +795,8 @@ describe Ci::Build, :models do describe '#merge_request' do def create_mr(build, pipeline, factory: :merge_request, created_at: Time.now) - create(factory, source_project_id: pipeline.gl_project_id, - target_project_id: pipeline.gl_project_id, + create(factory, source_project: pipeline.project, + target_project: pipeline.project, source_branch: build.ref, created_at: created_at) end @@ -855,7 +915,7 @@ describe Ci::Build, :models do end context 'referenced with a variable' do - let(:build) { create(:ci_build, pipeline: pipeline, environment: "foo-$CI_BUILD_REF_NAME") } + let(:build) { create(:ci_build, pipeline: pipeline, environment: "foo-$CI_COMMIT_REF_NAME") } it { is_expected.to eq(@environment) } end @@ -1226,23 +1286,25 @@ describe Ci::Build, :models do [ { key: 'CI', value: 'true', public: true }, { key: 'GITLAB_CI', value: 'true', public: true }, - { key: 'CI_BUILD_ID', value: build.id.to_s, public: true }, - { key: 'CI_BUILD_TOKEN', value: build.token, public: false }, - { key: 'CI_BUILD_REF', value: build.sha, public: true }, - { key: 'CI_BUILD_BEFORE_SHA', value: build.before_sha, public: true }, - { key: 'CI_BUILD_REF_NAME', value: 'master', public: true }, - { key: 'CI_BUILD_REF_SLUG', value: 'master', public: true }, - { key: 'CI_BUILD_NAME', value: 'test', public: true }, - { key: 'CI_BUILD_STAGE', value: 'test', public: true }, { key: 'CI_SERVER_NAME', value: 'GitLab', public: true }, { key: 'CI_SERVER_VERSION', value: Gitlab::VERSION, public: true }, { key: 'CI_SERVER_REVISION', value: Gitlab::REVISION, public: true }, + { key: 'CI_JOB_ID', value: build.id.to_s, public: true }, + { key: 'CI_JOB_NAME', value: 'test', public: true }, + { key: 'CI_JOB_STAGE', value: 'test', public: true }, + { key: 'CI_JOB_TOKEN', value: build.token, public: false }, + { key: 'CI_COMMIT_SHA', value: build.sha, public: true }, + { key: 'CI_COMMIT_REF_NAME', value: build.ref, public: true }, + { key: 'CI_COMMIT_REF_SLUG', value: build.ref_slug, public: true }, { key: 'CI_PROJECT_ID', value: project.id.to_s, public: true }, { key: 'CI_PROJECT_NAME', value: project.path, public: true }, - { key: 'CI_PROJECT_PATH', value: project.path_with_namespace, public: true }, - { key: 'CI_PROJECT_NAMESPACE', value: project.namespace.path, public: true }, + { key: 'CI_PROJECT_PATH', value: project.full_path, public: true }, + { key: 'CI_PROJECT_NAMESPACE', value: project.namespace.full_path, public: true }, { key: 'CI_PROJECT_URL', value: project.web_url, public: true }, - { key: 'CI_PIPELINE_ID', value: pipeline.id.to_s, public: true } + { key: 'CI_PIPELINE_ID', value: pipeline.id.to_s, public: true }, + { key: 'CI_REGISTRY_USER', value: 'gitlab-ci-token', public: true }, + { key: 'CI_REGISTRY_PASSWORD', value: build.token, public: false }, + { key: 'CI_REPOSITORY_URL', value: build.repo_url, public: false }, ] end @@ -1257,13 +1319,13 @@ describe Ci::Build, :models do build.yaml_variables = [] end - it { is_expected.to eq(predefined_variables) } + it { is_expected.to include(*predefined_variables) } end context 'when build has user' do let(:user_variables) do - [ { key: 'GITLAB_USER_ID', value: user.id.to_s, public: true }, - { key: 'GITLAB_USER_EMAIL', value: user.email, public: true } ] + [{ key: 'GITLAB_USER_ID', value: user.id.to_s, public: true }, + { key: 'GITLAB_USER_EMAIL', value: user.email, public: true }] end before do @@ -1295,7 +1357,7 @@ describe Ci::Build, :models do end let(:manual_variable) do - { key: 'CI_BUILD_MANUAL', value: 'true', public: true } + { key: 'CI_JOB_MANUAL', value: 'true', public: true } end it { is_expected.to include(manual_variable) } @@ -1303,7 +1365,7 @@ describe Ci::Build, :models do context 'when build is for tag' do let(:tag_variable) do - { key: 'CI_BUILD_TAG', value: 'master', public: true } + { key: 'CI_COMMIT_TAG', value: 'master', public: true } end before do @@ -1332,7 +1394,7 @@ describe Ci::Build, :models do { key: :TRIGGER_KEY_1, value: 'TRIGGER_VALUE_1', public: false } end let(:predefined_trigger_variable) do - { key: 'CI_BUILD_TRIGGERED', value: 'true', public: true } + { key: 'CI_PIPELINE_TRIGGERED', value: 'true', public: true } end before do @@ -1356,7 +1418,7 @@ describe Ci::Build, :models do context 'when config is not found' do let(:config) { nil } - it { is_expected.to eq(predefined_variables) } + it { is_expected.to include(*predefined_variables) } end context 'when config does not have a questioned job' do @@ -1368,7 +1430,7 @@ describe Ci::Build, :models do }) end - it { is_expected.to eq(predefined_variables) } + it { is_expected.to include(*predefined_variables) } end context 'when config has variables' do @@ -1386,7 +1448,8 @@ describe Ci::Build, :models do [{ key: 'KEY', value: 'value', public: true }] end - it { is_expected.to eq(predefined_variables + variables) } + it { is_expected.to include(*predefined_variables) } + it { is_expected.to include(*variables) } end end end @@ -1420,7 +1483,7 @@ describe Ci::Build, :models do end context 'when runner is assigned to build' do - let(:runner) { create(:ci_runner, description: 'description', tag_list: ['docker', 'linux']) } + let(:runner) { create(:ci_runner, description: 'description', tag_list: %w(docker linux)) } before do build.update(runner: runner) diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 10c2bfbb400..53282b999dc 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -24,6 +24,14 @@ describe Ci::Pipeline, models: true do it { is_expected.to respond_to :git_author_email } it { is_expected.to respond_to :short_sha } + describe '#block' do + it 'changes pipeline status to manual' do + expect(pipeline.block).to be true + expect(pipeline.reload).to be_manual + expect(pipeline.reload).to be_blocked + end + end + describe '#valid_commit_sha' do context 'commit.sha can not start with 00000000' do before do @@ -168,9 +176,9 @@ describe Ci::Pipeline, models: true do end it 'returns list of stages with correct statuses' do - expect(statuses).to eq([['build', 'failed'], - ['test', 'success'], - ['deploy', 'running']]) + expect(statuses).to eq([%w(build failed), + %w(test success), + %w(deploy running)]) end context 'when commit status is retried' do @@ -183,12 +191,30 @@ describe Ci::Pipeline, models: true do end it 'ignores the previous state' do - expect(statuses).to eq([['build', 'success'], - ['test', 'success'], - ['deploy', 'running']]) + expect(statuses).to eq([%w(build success), + %w(test success), + %w(deploy running)]) end end end + + context 'when there is a stage with warnings' do + before do + create(:commit_status, pipeline: pipeline, + stage: 'deploy', + name: 'prod:2', + stage_idx: 2, + status: 'failed', + allow_failure: true) + end + + it 'populates stage with correct number of warnings' do + deploy_stage = pipeline.stages.third + + expect(deploy_stage).not_to receive(:statuses) + expect(deploy_stage).to have_warnings + end + end end describe '#stages_count' do @@ -199,7 +225,7 @@ describe Ci::Pipeline, models: true do describe '#stages_name' do it 'returns a valid names of stages' do - expect(pipeline.stages_name).to eq(['build', 'test', 'deploy']) + expect(pipeline.stages_name).to eq(%w(build test deploy)) end end end @@ -506,6 +532,19 @@ describe Ci::Pipeline, models: true do end end + describe '.latest_successful_for_refs' do + include_context 'with some outdated pipelines' + + let!(:latest_successful_pipeline1) { create_pipeline(:success, 'ref1', 'D') } + let!(:latest_successful_pipeline2) { create_pipeline(:success, 'ref2', 'D') } + + it 'returns the latest successful pipeline for both refs' do + refs = %w(ref1 ref2 ref3) + + expect(described_class.latest_successful_for_refs(refs)).to eq({ 'ref1' => latest_successful_pipeline1, 'ref2' => latest_successful_pipeline2 }) + end + end + describe '#status' do let(:build) do create(:ci_build, :created, pipeline: pipeline, name: 'test') @@ -635,6 +674,14 @@ describe Ci::Pipeline, models: true do end end + context 'when pipeline is blocked' do + let(:pipeline) { create(:ci_pipeline, status: :manual) } + + it 'returns detailed status for blocked pipeline' do + expect(subject.text).to eq 'blocked' + end + end + context 'when pipeline is successful but with warnings' do let(:pipeline) { create(:ci_pipeline, status: :success) } @@ -767,7 +814,7 @@ describe Ci::Pipeline, models: true do end it 'cancels created builds' do - expect(latest_status).to eq ['canceled', 'canceled'] + expect(latest_status).to eq %w(canceled canceled) end end end @@ -984,6 +1031,19 @@ describe Ci::Pipeline, models: true do end end + describe '#update_status' do + let(:pipeline) { create(:ci_pipeline, sha: '123456') } + + it 'updates the cached status' do + fake_status = double + # after updating the status, the status is set to `skipped` for this pipeline's builds + expect(Ci::PipelineStatus).to receive(:new).with(pipeline.project, sha: '123456', status: 'skipped').and_return(fake_status) + expect(fake_status).to receive(:store_in_cache_if_needed) + + pipeline.update_status + end + end + describe 'notifications when pipeline success or failed' do let(:project) { create(:project, :repository) } diff --git a/spec/models/ci/pipeline_status_spec.rb b/spec/models/ci/pipeline_status_spec.rb new file mode 100644 index 00000000000..bc5b71666c2 --- /dev/null +++ b/spec/models/ci/pipeline_status_spec.rb @@ -0,0 +1,173 @@ +require 'spec_helper' + +describe Ci::PipelineStatus do + let(:project) { create(:project) } + let(:pipeline_status) { described_class.new(project) } + + describe '.load_for_project' do + it "loads the status" do + expect_any_instance_of(described_class).to receive(:load_status) + + described_class.load_for_project(project) + end + end + + describe '#has_status?' do + it "is false when the status wasn't loaded yet" do + expect(pipeline_status.has_status?).to be_falsy + end + + it 'is true when all status information was loaded' do + fake_commit = double + allow(fake_commit).to receive(:status).and_return('failed') + allow(fake_commit).to receive(:sha).and_return('failed424d1b73bc0d3cb726eb7dc4ce17a4d48552f8c6') + allow(pipeline_status).to receive(:commit).and_return(fake_commit) + allow(pipeline_status).to receive(:has_cache?).and_return(false) + + pipeline_status.load_status + + expect(pipeline_status.has_status?).to be_truthy + end + end + + describe '#load_status' do + it 'loads the status from the cache when there is one' do + expect(pipeline_status).to receive(:has_cache?).and_return(true) + expect(pipeline_status).to receive(:load_from_cache) + + pipeline_status.load_status + end + + it 'loads the status from the project commit when there is no cache' do + allow(pipeline_status).to receive(:has_cache?).and_return(false) + + expect(pipeline_status).to receive(:load_from_commit) + + pipeline_status.load_status + end + + it 'stores the status in the cache when it loading it from the project' do + allow(pipeline_status).to receive(:has_cache?).and_return(false) + allow(pipeline_status).to receive(:load_from_commit) + + expect(pipeline_status).to receive(:store_in_cache) + + pipeline_status.load_status + end + + it 'sets the state to loaded' do + pipeline_status.load_status + + expect(pipeline_status).to be_loaded + end + + it 'only loads the status once' do + expect(pipeline_status).to receive(:has_cache?).and_return(true).exactly(1) + expect(pipeline_status).to receive(:load_from_cache).exactly(1) + + pipeline_status.load_status + pipeline_status.load_status + end + end + + describe "#load_from_commit" do + let!(:pipeline) { create(:ci_pipeline, :success, project: project, sha: project.commit.sha) } + + it 'reads the status from the pipeline for the commit' do + pipeline_status.load_from_commit + + expect(pipeline_status.status).to eq('success') + expect(pipeline_status.sha).to eq(project.commit.sha) + end + + it "doesn't fail for an empty project" do + status_for_empty_commit = described_class.new(create(:empty_project)) + + status_for_empty_commit.load_status + + expect(status_for_empty_commit).to be_loaded + end + end + + describe "#store_in_cache", :redis do + it "sets the object in redis" do + pipeline_status.sha = '123456' + pipeline_status.status = 'failed' + + pipeline_status.store_in_cache + read_sha, read_status = Gitlab::Redis.with { |redis| redis.hmget("projects/#{project.id}/build_status", :sha, :status) } + + expect(read_sha).to eq('123456') + expect(read_status).to eq('failed') + end + end + + describe '#store_in_cache_if_needed', :redis do + it 'stores the state in the cache when the sha is the HEAD of the project' do + create(:ci_pipeline, :success, project: project, sha: project.commit.sha) + build_status = described_class.load_for_project(project) + + build_status.store_in_cache_if_needed + sha, status = Gitlab::Redis.with { |redis| redis.hmget("projects/#{project.id}/build_status", :sha, :status) } + + expect(sha).not_to be_nil + expect(status).not_to be_nil + end + + it "doesn't store the status in redis when the sha is not the head of the project" do + other_status = described_class.new(project, sha: "123456", status: "failed") + + other_status.store_in_cache_if_needed + sha, status = Gitlab::Redis.with { |redis| redis.hmget("projects/#{project.id}/build_status", :sha, :status) } + + expect(sha).to be_nil + expect(status).to be_nil + end + + it "deletes the cache if the repository doesn't have a head commit" do + empty_project = create(:empty_project) + Gitlab::Redis.with { |redis| redis.mapped_hmset("projects/#{empty_project.id}/build_status", { sha: "sha", status: "pending" }) } + other_status = described_class.new(empty_project, sha: "123456", status: "failed") + + other_status.store_in_cache_if_needed + sha, status = Gitlab::Redis.with { |redis| redis.hmget("projects/#{empty_project.id}/build_status", :sha, :status) } + + expect(sha).to be_nil + expect(status).to be_nil + end + end + + describe "with a status in redis", :redis do + let(:status) { 'success' } + let(:sha) { '424d1b73bc0d3cb726eb7dc4ce17a4d48552f8c6' } + + before do + Gitlab::Redis.with { |redis| redis.mapped_hmset("projects/#{project.id}/build_status", { sha: sha, status: status }) } + end + + describe '#load_from_cache' do + it 'reads the status from redis' do + pipeline_status.load_from_cache + + expect(pipeline_status.sha).to eq(sha) + expect(pipeline_status.status).to eq(status) + end + end + + describe '#has_cache?' do + it 'knows the status is cached' do + expect(pipeline_status.has_cache?).to be_truthy + end + end + + describe '#delete_from_cache' do + it 'deletes values from redis' do + pipeline_status.delete_from_cache + + key_exists = Gitlab::Redis.with { |redis| redis.exists("projects/#{project.id}/build_status") } + + expect(key_exists).to be_falsy + end + end + end +end diff --git a/spec/models/ci/runner_spec.rb b/spec/models/ci/runner_spec.rb index f8513ac8b1c..76ce558eea0 100644 --- a/spec/models/ci/runner_spec.rb +++ b/spec/models/ci/runner_spec.rb @@ -113,7 +113,7 @@ describe Ci::Runner, models: true do context 'when runner has tags' do before do - runner.tag_list = ['bb', 'cc'] + runner.tag_list = %w(bb cc) end shared_examples 'tagged build picker' do @@ -169,7 +169,7 @@ describe Ci::Runner, models: true do context 'when having runner tags' do before do - runner.tag_list = ['bb', 'cc'] + runner.tag_list = %w(bb cc) end it 'cannot handle it for builds without matching tags' do @@ -189,7 +189,7 @@ describe Ci::Runner, models: true do context 'when having runner tags' do before do - runner.tag_list = ['bb', 'cc'] + runner.tag_list = %w(bb cc) build.tag_list = ['bb'] end @@ -212,7 +212,7 @@ describe Ci::Runner, models: true do context 'when having runner tags' do before do - runner.tag_list = ['bb', 'cc'] + runner.tag_list = %w(bb cc) build.tag_list = ['bb'] end diff --git a/spec/models/ci/stage_spec.rb b/spec/models/ci/stage_spec.rb index c4a9743a4e2..c38faf32f7d 100644 --- a/spec/models/ci/stage_spec.rb +++ b/spec/models/ci/stage_spec.rb @@ -170,22 +170,31 @@ describe Ci::Stage, models: true do context 'when stage has warnings' do context 'when using memoized warnings flag' do context 'when there are warnings' do - let(:stage) { build(:ci_stage, warnings: true) } + let(:stage) { build(:ci_stage, warnings: 2) } - it 'has memoized warnings' do + it 'returns true using memoized value' do expect(stage).not_to receive(:statuses) expect(stage).to have_warnings end end context 'when there are no warnings' do - let(:stage) { build(:ci_stage, warnings: false) } + let(:stage) { build(:ci_stage, warnings: 0) } - it 'has memoized warnings' do + it 'returns false using memoized value' do expect(stage).not_to receive(:statuses) expect(stage).not_to have_warnings end end + + context 'when number of warnings is not a valid value' do + let(:stage) { build(:ci_stage, warnings: true) } + + it 'calculates statuses using database queries' do + expect(stage).to receive(:statuses).and_call_original + expect(stage).not_to have_warnings + end + end end context 'when calculating warnings from statuses' do diff --git a/spec/models/ci/trigger_spec.rb b/spec/models/ci/trigger_spec.rb index 3ca9231f58e..1bcb673cb16 100644 --- a/spec/models/ci/trigger_spec.rb +++ b/spec/models/ci/trigger_spec.rb @@ -1,17 +1,83 @@ require 'spec_helper' describe Ci::Trigger, models: true do - let(:project) { FactoryGirl.create :empty_project } + let(:project) { create :empty_project } + + describe 'associations' do + it { is_expected.to belong_to(:project) } + it { is_expected.to belong_to(:owner) } + it { is_expected.to have_many(:trigger_requests) } + end describe 'before_validation' do it 'sets an random token if none provided' do - trigger = FactoryGirl.create :ci_trigger_without_token, project: project + trigger = create(:ci_trigger_without_token, project: project) + expect(trigger.token).not_to be_nil end it 'does not set an random token if one provided' do - trigger = FactoryGirl.create :ci_trigger, project: project + trigger = create(:ci_trigger, project: project) + expect(trigger.token).to eq('token') end end + + describe '#short_token' do + let(:trigger) { create(:ci_trigger, token: '12345678') } + + subject { trigger.short_token } + + it 'returns shortened token' do + is_expected.to eq('1234') + end + end + + describe '#legacy?' do + let(:trigger) { create(:ci_trigger, owner: owner, project: project) } + + subject { trigger } + + context 'when owner is blank' do + let(:owner) { nil } + + it { is_expected.to be_legacy } + end + + context 'when owner is set' do + let(:owner) { create(:user) } + + it { is_expected.not_to be_legacy } + end + end + + describe '#can_access_project?' do + let(:trigger) { create(:ci_trigger, owner: owner, project: project) } + + context 'when owner is blank' do + let(:owner) { nil } + + subject { trigger.can_access_project? } + + it { is_expected.to eq(true) } + end + + context 'when owner is set' do + let(:owner) { create(:user) } + + subject { trigger.can_access_project? } + + context 'and is member of the project' do + before do + project.team << [owner, :developer] + end + + it { is_expected.to eq(true) } + end + + context 'and is not member of the project' do + it { is_expected.to eq(false) } + end + end + end end diff --git a/spec/models/ci/variable_spec.rb b/spec/models/ci/variable_spec.rb index bee9f714849..048d25869bc 100644 --- a/spec/models/ci/variable_spec.rb +++ b/spec/models/ci/variable_spec.rb @@ -6,7 +6,7 @@ describe Ci::Variable, models: true do let(:secret_value) { 'secret' } it { is_expected.to validate_presence_of(:key) } - it { is_expected.to validate_uniqueness_of(:key).scoped_to(:gl_project_id) } + it { is_expected.to validate_uniqueness_of(:key).scoped_to(:project_id) } it { is_expected.to validate_length_of(:key).is_at_most(255) } it { is_expected.to allow_value('foo').for(:key) } it { is_expected.not_to allow_value('foo bar').for(:key) } diff --git a/spec/models/commit_spec.rb b/spec/models/commit_spec.rb index 32f9366a14c..980a1b70ef5 100644 --- a/spec/models/commit_spec.rb +++ b/spec/models/commit_spec.rb @@ -212,6 +212,25 @@ eos end end + describe '#latest_pipeline' do + let!(:first_pipeline) do + create(:ci_empty_pipeline, + project: project, + sha: commit.sha, + status: 'success') + end + let!(:second_pipeline) do + create(:ci_empty_pipeline, + project: project, + sha: commit.sha, + status: 'success') + end + + it 'returns latest pipeline' do + expect(commit.latest_pipeline).to eq second_pipeline + end + end + describe '#status' do context 'without ref argument' do before do @@ -369,4 +388,32 @@ eos expect(described_class.valid_hash?('a' * 41)).to be false end end + + describe '#raw_diffs' do + context 'Gitaly commit_raw_diffs feature enabled' do + before do + allow(Gitlab::GitalyClient).to receive(:feature_enabled?).with(:commit_raw_diffs).and_return(true) + end + + context 'when a truthy deltas_only is not passed to args' do + it 'fetches diffs from Gitaly server' do + expect(Gitlab::GitalyClient::Commit).to receive(:diff_from_parent). + with(commit) + + commit.raw_diffs + end + end + + context 'when a truthy deltas_only is passed to args' do + it 'fetches diffs using Rugged' do + opts = { deltas_only: true } + + expect(Gitlab::GitalyClient::Commit).not_to receive(:diff_from_parent) + expect(commit.raw).to receive(:diffs).with(opts) + + commit.raw_diffs(opts) + end + end + end + end end diff --git a/spec/models/commit_status_spec.rb b/spec/models/commit_status_spec.rb index 36533bdd11e..ea5e4e21039 100644 --- a/spec/models/commit_status_spec.rb +++ b/spec/models/commit_status_spec.rb @@ -158,7 +158,7 @@ describe CommitStatus, :models do end end - describe '.exclude_ignored' do + describe '.after_stage' do subject { described_class.after_stage(0) } let(:statuses) do @@ -185,11 +185,32 @@ describe CommitStatus, :models do create_status(allow_failure: true, status: 'success'), create_status(allow_failure: true, status: 'failed'), create_status(allow_failure: false, status: 'success'), - create_status(allow_failure: false, status: 'failed')] + create_status(allow_failure: false, status: 'failed'), + create_status(allow_failure: true, status: 'manual'), + create_status(allow_failure: false, status: 'manual')] + end + + it 'returns statuses without what we want to ignore' do + is_expected.to eq(statuses.values_at(0, 1, 2, 3, 4, 5, 6, 8, 9, 11)) + end + end + + describe '.failed_but_allowed' do + subject { described_class.failed_but_allowed.order(:id) } + + let(:statuses) do + [create_status(allow_failure: true, status: 'success'), + create_status(allow_failure: true, status: 'failed'), + create_status(allow_failure: false, status: 'success'), + create_status(allow_failure: false, status: 'failed'), + create_status(allow_failure: true, status: 'canceled'), + create_status(allow_failure: false, status: 'canceled'), + create_status(allow_failure: true, status: 'manual'), + create_status(allow_failure: false, status: 'manual')] end it 'returns statuses without what we want to ignore' do - is_expected.to eq(statuses.values_at(0, 1, 2, 3, 4, 5, 6, 8, 9)) + is_expected.to eq(statuses.values_at(1, 4)) end end diff --git a/spec/models/concerns/cache_markdown_field_spec.rb b/spec/models/concerns/cache_markdown_field_spec.rb index 2e3702f7520..6151d53cd91 100644 --- a/spec/models/concerns/cache_markdown_field_spec.rb +++ b/spec/models/concerns/cache_markdown_field_spec.rb @@ -1,7 +1,8 @@ require 'spec_helper' describe CacheMarkdownField do - CacheMarkdownField::CACHING_CLASSES << "ThingWithMarkdownFields" + caching_classes = CacheMarkdownField::CACHING_CLASSES + CacheMarkdownField::CACHING_CLASSES = ["ThingWithMarkdownFields"].freeze # The minimum necessary ActiveModel to test this concern class ThingWithMarkdownFields @@ -54,7 +55,7 @@ describe CacheMarkdownField do end end - CacheMarkdownField::CACHING_CLASSES.delete("ThingWithMarkdownFields") + CacheMarkdownField::CACHING_CLASSES = caching_classes def thing_subclass(new_attr) Class.new(ThingWithMarkdownFields) { add_attr(new_attr) } diff --git a/spec/models/concerns/has_status_spec.rb b/spec/models/concerns/has_status_spec.rb index dbfe3cd2d36..82abad0e2f6 100644 --- a/spec/models/concerns/has_status_spec.rb +++ b/spec/models/concerns/has_status_spec.rb @@ -109,6 +109,42 @@ describe HasStatus do it { is_expected.to eq 'running' } end + + context 'when one status finished and second is still created' do + let!(:statuses) do + [create(type, status: :success), create(type, status: :created)] + end + + it { is_expected.to eq 'running' } + end + + context 'when there is a manual status before created status' do + let!(:statuses) do + [create(type, status: :success), + create(type, status: :manual, allow_failure: false), + create(type, status: :created)] + end + + it { is_expected.to eq 'manual' } + end + + context 'when one status is a blocking manual action' do + let!(:statuses) do + [create(type, status: :failed), + create(type, status: :manual, allow_failure: false)] + end + + it { is_expected.to eq 'manual' } + end + + context 'when one status is a non-blocking manual action' do + let!(:statuses) do + [create(type, status: :failed), + create(type, status: :manual, allow_failure: true)] + end + + it { is_expected.to eq 'failed' } + end end context 'ci build statuses' do @@ -218,6 +254,18 @@ describe HasStatus do it_behaves_like 'not containing the job', status end end + + describe '.manual' do + subject { CommitStatus.manual } + + %i[manual].each do |status| + it_behaves_like 'containing the job', status + end + + %i[failed success skipped canceled].each do |status| + it_behaves_like 'not containing the job', status + end + end end describe '::DEFAULT_STATUS' do @@ -225,4 +273,10 @@ describe HasStatus do expect(described_class::DEFAULT_STATUS).to eq 'created' end end + + describe '::BLOCKED_STATUS' do + it 'is a status manual' do + expect(described_class::BLOCKED_STATUS).to eq 'manual' + end + end end diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb index 545a11912e3..9574796a945 100644 --- a/spec/models/concerns/issuable_spec.rb +++ b/spec/models/concerns/issuable_spec.rb @@ -278,6 +278,16 @@ describe Issue, "Issuable" do end end + context 'issue has labels' do + let(:labels) { [create(:label), create(:label)] } + + before { issue.update_attribute(:labels, labels)} + + it 'includes labels in the hook data' do + expect(data[:labels]).to eq(labels.map(&:hook_attrs)) + end + end + include_examples 'project hook data' include_examples 'deprecated repository hook data' end @@ -344,6 +354,46 @@ describe Issue, "Issuable" do end end + describe '.order_due_date_and_labels_priority' do + let(:project) { create(:empty_project) } + + def create_issue(milestone, labels) + create(:labeled_issue, milestone: milestone, labels: labels, project: project) + end + + it 'sorts issues in order of milestone due date, then label priority' do + first_priority = create(:label, project: project, priority: 1) + second_priority = create(:label, project: project, priority: 2) + no_priority = create(:label, project: project) + + first_milestone = create(:milestone, project: project, due_date: Time.now) + second_milestone = create(:milestone, project: project, due_date: Time.now + 1.month) + third_milestone = create(:milestone, project: project) + + # The issues here are ordered by label priority, to ensure that we don't + # accidentally just sort by creation date. + second_milestone_first_priority = create_issue(second_milestone, [first_priority, second_priority, no_priority]) + third_milestone_first_priority = create_issue(third_milestone, [first_priority, second_priority, no_priority]) + first_milestone_second_priority = create_issue(first_milestone, [second_priority, no_priority]) + second_milestone_second_priority = create_issue(second_milestone, [second_priority, no_priority]) + no_milestone_second_priority = create_issue(nil, [second_priority, no_priority]) + first_milestone_no_priority = create_issue(first_milestone, [no_priority]) + second_milestone_no_labels = create_issue(second_milestone, []) + third_milestone_no_priority = create_issue(third_milestone, [no_priority]) + + result = Issue.order_due_date_and_labels_priority + + expect(result).to eq([first_milestone_second_priority, + first_milestone_no_priority, + second_milestone_first_priority, + second_milestone_second_priority, + second_milestone_no_labels, + third_milestone_first_priority, + no_milestone_second_priority, + third_milestone_no_priority]) + end + end + describe '.order_labels_priority' do let(:label_1) { create(:label, title: 'label_1', project: issue.project, priority: 1) } let(:label_2) { create(:label, title: 'label_2', project: issue.project, priority: 2) } diff --git a/spec/models/concerns/milestoneish_spec.rb b/spec/models/concerns/milestoneish_spec.rb index ad703a6c8bb..68e4c0a522b 100644 --- a/spec/models/concerns/milestoneish_spec.rb +++ b/spec/models/concerns/milestoneish_spec.rb @@ -116,21 +116,41 @@ describe Milestone, 'Milestoneish' do end end + describe '#remaining_days' do + it 'shows 0 if no due date' do + milestone = build_stubbed(:milestone) + + expect(milestone.remaining_days).to eq(0) + end + + it 'shows 0 if expired' do + milestone = build_stubbed(:milestone, due_date: 2.days.ago) + + expect(milestone.remaining_days).to eq(0) + end + + it 'shows correct remaining days' do + milestone = build_stubbed(:milestone, due_date: 2.days.from_now) + + expect(milestone.remaining_days).to eq(2) + end + end + describe '#elapsed_days' do it 'shows 0 if no start_date set' do - milestone = build(:milestone) + milestone = build_stubbed(:milestone) expect(milestone.elapsed_days).to eq(0) end it 'shows 0 if start_date is a future' do - milestone = build(:milestone, start_date: Time.now + 2.days) + milestone = build_stubbed(:milestone, start_date: Time.now + 2.days) expect(milestone.elapsed_days).to eq(0) end it 'shows correct amount of days' do - milestone = build(:milestone, start_date: Time.now - 2.days) + milestone = build_stubbed(:milestone, start_date: Time.now - 2.days) expect(milestone.elapsed_days).to eq(2) end diff --git a/spec/models/concerns/relative_positioning_spec.rb b/spec/models/concerns/relative_positioning_spec.rb new file mode 100644 index 00000000000..255b584a85e --- /dev/null +++ b/spec/models/concerns/relative_positioning_spec.rb @@ -0,0 +1,204 @@ +require 'spec_helper' + +describe Issue, 'RelativePositioning' do + let(:project) { create(:empty_project) } + let(:issue) { create(:issue, project: project) } + let(:issue1) { create(:issue, project: project) } + let(:new_issue) { create(:issue, project: project) } + + before do + [issue, issue1].each do |issue| + issue.move_to_end && issue.save + end + end + + describe '#max_relative_position' do + it 'returns maximum position' do + expect(issue.max_relative_position).to eq issue1.relative_position + end + end + + describe '#prev_relative_position' do + it 'returns previous position if there is an issue above' do + expect(issue1.prev_relative_position).to eq issue.relative_position + end + + it 'returns nil if there is no issue above' do + expect(issue.prev_relative_position).to eq nil + end + end + + describe '#next_relative_position' do + it 'returns next position if there is an issue below' do + expect(issue.next_relative_position).to eq issue1.relative_position + end + + it 'returns nil if there is no issue below' do + expect(issue1.next_relative_position).to eq nil + end + end + + describe '#move_before' do + it 'moves issue before' do + [issue1, issue].each(&:move_to_end) + + issue.move_before(issue1) + + expect(issue.relative_position).to be < issue1.relative_position + end + end + + describe '#move_after' do + it 'moves issue after' do + [issue, issue1].each(&:move_to_end) + + issue.move_after(issue1) + + expect(issue.relative_position).to be > issue1.relative_position + end + end + + describe '#move_to_end' do + it 'moves issue to the end' do + new_issue.move_to_end + + expect(new_issue.relative_position).to be > issue1.relative_position + end + end + + describe '#shift_after?' do + it 'returns true' do + issue.update(relative_position: issue1.relative_position - 1) + + expect(issue.shift_after?).to be_truthy + end + + it 'returns false' do + issue.update(relative_position: issue1.relative_position - 2) + + expect(issue.shift_after?).to be_falsey + end + end + + describe '#shift_before?' do + it 'returns true' do + issue.update(relative_position: issue1.relative_position + 1) + + expect(issue.shift_before?).to be_truthy + end + + it 'returns false' do + issue.update(relative_position: issue1.relative_position + 2) + + expect(issue.shift_before?).to be_falsey + end + end + + describe '#move_between' do + it 'positions issue between two other' do + new_issue.move_between(issue, issue1) + + expect(new_issue.relative_position).to be > issue.relative_position + expect(new_issue.relative_position).to be < issue1.relative_position + end + + it 'positions issue between on top' do + new_issue.move_between(nil, issue) + + expect(new_issue.relative_position).to be < issue.relative_position + end + + it 'positions issue between to end' do + new_issue.move_between(issue1, nil) + + expect(new_issue.relative_position).to be > issue1.relative_position + end + + it 'positions issues even when after and before positions are the same' do + issue1.update relative_position: issue.relative_position + + new_issue.move_between(issue, issue1) + + expect(new_issue.relative_position).to be > issue.relative_position + expect(issue.relative_position).to be < issue1.relative_position + end + + it 'positions issues between other two if distance is 1' do + issue1.update relative_position: issue.relative_position + 1 + + new_issue.move_between(issue, issue1) + + expect(new_issue.relative_position).to be > issue.relative_position + expect(issue.relative_position).to be < issue1.relative_position + end + + it 'positions issue in the middle of other two if distance is big enough' do + issue.update relative_position: 6000 + issue1.update relative_position: 10000 + + new_issue.move_between(issue, issue1) + + expect(new_issue.relative_position).to eq(8000) + end + + it 'positions issue closer to the middle if we are at the very top' do + issue1.update relative_position: 6000 + + new_issue.move_between(nil, issue1) + + expect(new_issue.relative_position).to eq(6000 - RelativePositioning::IDEAL_DISTANCE) + end + + it 'positions issue closer to the middle if we are at the very bottom' do + issue.update relative_position: 6000 + issue1.update relative_position: nil + + new_issue.move_between(issue, nil) + + expect(new_issue.relative_position).to eq(6000 + RelativePositioning::IDEAL_DISTANCE) + end + + it 'positions issue in the middle of other two if distance is not big enough' do + issue.update relative_position: 100 + issue1.update relative_position: 400 + + new_issue.move_between(issue, issue1) + + expect(new_issue.relative_position).to eq(250) + end + + it 'positions issue in the middle of other two is there is no place' do + issue.update relative_position: 100 + issue1.update relative_position: 101 + + new_issue.move_between(issue, issue1) + + expect(new_issue.relative_position).to be_between(issue.relative_position, issue1.relative_position) + end + + it 'uses rebalancing if there is no place' do + issue.update relative_position: 100 + issue1.update relative_position: 101 + issue2 = create(:issue, relative_position: 102, project: project) + new_issue.update relative_position: 103 + + new_issue.move_between(issue1, issue2) + new_issue.save! + + expect(new_issue.relative_position).to be_between(issue1.relative_position, issue2.relative_position) + expect(issue.reload.relative_position).not_to eq(100) + end + + it 'positions issue right if we pass none-sequential parameters' do + issue.update relative_position: 99 + issue1.update relative_position: 101 + issue2 = create(:issue, relative_position: 102, project: project) + new_issue.update relative_position: 103 + + new_issue.move_between(issue, issue2) + new_issue.save! + + expect(new_issue.relative_position).to be(100) + end + end +end diff --git a/spec/models/concerns/routable_spec.rb b/spec/models/concerns/routable_spec.rb index e008ec28fa4..677e60e1282 100644 --- a/spec/models/concerns/routable_spec.rb +++ b/spec/models/concerns/routable_spec.rb @@ -86,7 +86,7 @@ describe Group, 'Routable' do let(:nested_group) { create(:group, parent: group) } it { expect(group.full_path).to eq(group.path) } - it { expect(nested_group.full_path).to eq("#{group.path}/#{nested_group.path}") } + it { expect(nested_group.full_path).to eq("#{group.full_path}/#{nested_group.path}") } end describe '#full_name' do @@ -102,7 +102,7 @@ describe Project, 'Routable' do describe '#full_path' do let(:project) { build_stubbed(:empty_project) } - it { expect(project.full_path).to eq "#{project.namespace.path}/#{project.path}" } + it { expect(project.full_path).to eq "#{project.namespace.full_path}/#{project.path}" } end describe '#full_name' do diff --git a/spec/models/concerns/uniquify_spec.rb b/spec/models/concerns/uniquify_spec.rb new file mode 100644 index 00000000000..83187d732e4 --- /dev/null +++ b/spec/models/concerns/uniquify_spec.rb @@ -0,0 +1,33 @@ +require 'spec_helper' + +describe Uniquify, models: true do + let(:uniquify) { described_class.new } + + describe "#string" do + it 'returns the given string if it does not exist' do + result = uniquify.string('test_string') { |s| false } + + expect(result).to eq('test_string') + end + + it 'returns the given string with a counter attached if the string exists' do + result = uniquify.string('test_string') { |s| s == 'test_string' } + + expect(result).to eq('test_string1') + end + + it 'increments the counter for each candidate string that also exists' do + result = uniquify.string('test_string') { |s| s == 'test_string' || s == 'test_string1' } + + expect(result).to eq('test_string2') + end + + it 'allows passing in a base function that defines the location of the counter' do + result = uniquify.string(-> (counter) { "test_#{counter}_string" }) do |s| + s == 'test__string' + end + + expect(result).to eq('test_1_string') + end + end +end diff --git a/spec/models/cycle_analytics/production_spec.rb b/spec/models/cycle_analytics/production_spec.rb index b9fe492fe2c..e6a826a9418 100644 --- a/spec/models/cycle_analytics/production_spec.rb +++ b/spec/models/cycle_analytics/production_spec.rb @@ -21,13 +21,12 @@ describe 'CycleAnalytics#production', feature: true do ["production deploy happens after merge request is merged (along with other changes)", lambda do |context, data| # Make other changes on master - sha = context.project.repository.commit_file( + sha = context.project.repository.create_file( context.user, context.random_git_name, 'content', message: 'commit message', - branch_name: 'master', - update: false) + branch_name: 'master') context.project.repository.commit(sha) context.deploy_master diff --git a/spec/models/cycle_analytics/staging_spec.rb b/spec/models/cycle_analytics/staging_spec.rb index 9a024d533a1..3a02ed81adb 100644 --- a/spec/models/cycle_analytics/staging_spec.rb +++ b/spec/models/cycle_analytics/staging_spec.rb @@ -18,7 +18,7 @@ describe 'CycleAnalytics#staging', feature: true do start_time_conditions: [["merge request that closes issue is merged", -> (context, data) do context.merge_merge_requests_closing_issue(data[:issue]) - end ]], + end]], end_time_conditions: [["merge request that closes issue is deployed to production", -> (context, data) do context.deploy_master @@ -26,13 +26,12 @@ describe 'CycleAnalytics#staging', feature: true do ["production deploy happens after merge request is merged (along with other changes)", lambda do |context, data| # Make other changes on master - sha = context.project.repository.commit_file( + sha = context.project.repository.create_file( context.user, context.random_git_name, 'content', message: 'commit message', - branch_name: 'master', - update: false) + branch_name: 'master') context.project.repository.commit(sha) context.deploy_master diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index f0ed0c679d5..9f0e7fbbe26 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -76,7 +76,8 @@ describe Environment, models: true do end describe '#update_merge_request_metrics?' do - { 'production' => true, + { + 'production' => true, 'production/eu' => true, 'production/www.gitlab.com' => true, 'productioneu' => false, @@ -238,7 +239,7 @@ describe Environment, models: true do describe '#actions_for' do let(:deployment) { create(:deployment, environment: environment) } let(:pipeline) { deployment.deployable.pipeline } - let!(:review_action) { create(:ci_build, :manual, name: 'review-apps', pipeline: pipeline, environment: 'review/$CI_BUILD_REF_NAME' )} + let!(:review_action) { create(:ci_build, :manual, name: 'review-apps', pipeline: pipeline, environment: 'review/$CI_COMMIT_REF_NAME' )} let!(:production_action) { create(:ci_build, :manual, name: 'production', pipeline: pipeline, environment: 'production' )} it 'returns a list of actions with matching environment' do @@ -270,7 +271,11 @@ describe Environment, models: true do context 'when the environment is unavailable' do let(:project) { create(:kubernetes_project) } - before { environment.stop } + + before do + environment.stop + end + it { is_expected.to be_falsy } end end @@ -280,20 +285,85 @@ describe Environment, models: true do subject { environment.terminals } context 'when the environment has terminals' do - before { allow(environment).to receive(:has_terminals?).and_return(true) } + before do + allow(environment).to receive(:has_terminals?).and_return(true) + end it 'returns the terminals from the deployment service' do - expect(project.deployment_service). - to receive(:terminals).with(environment). - and_return(:fake_terminals) + expect(project.deployment_service) + .to receive(:terminals).with(environment) + .and_return(:fake_terminals) is_expected.to eq(:fake_terminals) end end context 'when the environment does not have terminals' do - before { allow(environment).to receive(:has_terminals?).and_return(false) } - it { is_expected.to eq(nil) } + before do + allow(environment).to receive(:has_terminals?).and_return(false) + end + + it { is_expected.to be_nil } + end + end + + describe '#has_metrics?' do + subject { environment.has_metrics? } + + context 'when the enviroment is available' do + context 'with a deployment service' do + let(:project) { create(:prometheus_project) } + + context 'and a deployment' do + let!(:deployment) { create(:deployment, environment: environment) } + it { is_expected.to be_truthy } + end + + context 'but no deployments' do + it { is_expected.to be_falsy } + end + end + + context 'without a monitoring service' do + it { is_expected.to be_falsy } + end + end + + context 'when the environment is unavailable' do + let(:project) { create(:prometheus_project) } + + before do + environment.stop + end + + it { is_expected.to be_falsy } + end + end + + describe '#metrics' do + let(:project) { create(:prometheus_project) } + subject { environment.metrics } + + context 'when the environment has metrics' do + before do + allow(environment).to receive(:has_metrics?).and_return(true) + end + + it 'returns the metrics from the deployment service' do + expect(project.monitoring_service) + .to receive(:metrics).with(environment) + .and_return(:fake_metrics) + + is_expected.to eq(:fake_metrics) + end + end + + context 'when the environment does not have metrics' do + before do + allow(environment).to receive(:has_metrics?).and_return(false) + end + + it { is_expected.to be_nil } end end @@ -311,7 +381,7 @@ describe Environment, models: true do end describe '#generate_slug' do - SUFFIX = "-[a-z0-9]{6}" + SUFFIX = "-[a-z0-9]{6}".freeze { "staging-12345678901234567" => "staging-123456789" + SUFFIX, "9-staging-123456789012345" => "env-9-staging-123" + SUFFIX, diff --git a/spec/models/external_issue_spec.rb b/spec/models/external_issue_spec.rb index 2debe1289a3..cd50bda8996 100644 --- a/spec/models/external_issue_spec.rb +++ b/spec/models/external_issue_spec.rb @@ -42,4 +42,12 @@ describe ExternalIssue, models: true do expect(issue.project_id).to eq(project.id) end end + + describe '#hash' do + it 'returns the hash of its [class, to_s] pair' do + issue_2 = described_class.new(issue.to_s, project) + + expect(issue.hash).to eq(issue_2.hash) + end + end end diff --git a/spec/models/global_milestone_spec.rb b/spec/models/global_milestone_spec.rb index cacbab8bcb1..55b87d1c48a 100644 --- a/spec/models/global_milestone_spec.rb +++ b/spec/models/global_milestone_spec.rb @@ -92,6 +92,41 @@ describe GlobalMilestone, models: true do end end + describe '.states_count' do + context 'when the projects have milestones' do + before do + create(:closed_milestone, title: 'Active Group Milestone', project: project3) + create(:active_milestone, title: 'Active Group Milestone', project: project1) + create(:active_milestone, title: 'Active Group Milestone', project: project2) + create(:closed_milestone, title: 'Closed Group Milestone', project: project1) + create(:closed_milestone, title: 'Closed Group Milestone', project: project2) + create(:closed_milestone, title: 'Closed Group Milestone', project: project3) + end + + it 'returns the quantity of global milestones in each possible state' do + expected_count = { opened: 1, closed: 2, all: 2 } + + count = GlobalMilestone.states_count(Project.all) + + expect(count).to eq(expected_count) + end + end + + context 'when the projects do not have milestones' do + before do + project1 + end + + it 'returns 0 as the quantity of global milestones in each state' do + expected_count = { opened: 0, closed: 0, all: 0 } + + count = GlobalMilestone.states_count(Project.all) + + expect(count).to eq(expected_count) + end + end + end + describe '#initialize' do let(:milestone1_project1) { create(:milestone, title: "Milestone v1.2", project: project1) } let(:milestone1_project2) { create(:milestone, title: "Milestone v1.2", project: project2) } @@ -127,4 +162,32 @@ describe GlobalMilestone, models: true do expect(global_milestone.safe_title).to eq('git-test') end end + + describe '#state' do + context 'when at least one milestone is active' do + it 'returns active' do + title = 'Active Group Milestone' + milestones = [ + create(:active_milestone, title: title), + create(:closed_milestone, title: title) + ] + global_milestone = GlobalMilestone.new(title, milestones) + + expect(global_milestone.state).to eq('active') + end + end + + context 'when all milestones are closed' do + it 'returns closed' do + title = 'Closed Group Milestone' + milestones = [ + create(:closed_milestone, title: title), + create(:closed_milestone, title: title) + ] + global_milestone = GlobalMilestone.new(title, milestones) + + expect(global_milestone.state).to eq('closed') + end + end + end end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index a4e6eb4e3a6..5d87938235a 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -13,6 +13,8 @@ describe Group, models: true do it { is_expected.to have_many(:shared_projects).through(:project_group_links) } it { is_expected.to have_many(:notification_settings).dependent(:destroy) } it { is_expected.to have_many(:labels).class_name('GroupLabel') } + it { is_expected.to have_many(:uploads).dependent(:destroy) } + it { is_expected.to have_one(:chat_team) } describe '#members & #requesters' do let(:requester) { create(:user) } diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index bba9058f394..73977d031f9 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -22,6 +22,45 @@ describe Issue, models: true do it { is_expected.to have_db_index(:deleted_at) } end + describe '#order_by_position_and_priority' do + let(:project) { create :empty_project } + let(:p1) { create(:label, title: 'P1', project: project, priority: 1) } + let(:p2) { create(:label, title: 'P2', project: project, priority: 2) } + let!(:issue1) { create(:labeled_issue, project: project, labels: [p1]) } + let!(:issue2) { create(:labeled_issue, project: project, labels: [p2]) } + let!(:issue3) { create(:issue, project: project, relative_position: 100) } + let!(:issue4) { create(:issue, project: project, relative_position: 200) } + + it 'returns ordered list' do + expect(project.issues.order_by_position_and_priority). + to match [issue3, issue4, issue1, issue2] + end + end + + describe '#closed_at' do + after do + Timecop.return + end + + let!(:now) { Timecop.freeze(Time.now) } + + it 'sets closed_at to Time.now when issue is closed' do + issue = create(:issue, state: 'opened') + + issue.close + + expect(issue.closed_at).to eq(now) + end + + it 'sets closed_at to nil when issue is reopened' do + issue = create(:issue, state: 'closed') + + issue.reopen + + expect(issue.closed_at).to be_nil + end + end + describe '#to_reference' do let(:namespace) { build(:namespace, path: 'sample-namespace') } let(:project) { build(:empty_project, name: 'sample-project', namespace: namespace) } @@ -620,4 +659,15 @@ describe Issue, models: true do end end end + + describe '#hook_attrs' do + let(:attrs_hash) { subject.hook_attrs } + + it 'includes time tracking attrs' do + expect(attrs_hash).to include(:total_time_spent) + expect(attrs_hash).to include(:human_time_estimate) + expect(attrs_hash).to include(:human_total_time_spent) + expect(attrs_hash).to include('time_estimate') + end + end end diff --git a/spec/models/members/project_member_spec.rb b/spec/models/members/project_member_spec.rb index e4be0aba7a6..87ea2e70680 100644 --- a/spec/models/members/project_member_spec.rb +++ b/spec/models/members/project_member_spec.rb @@ -89,8 +89,8 @@ describe ProjectMember, models: true do @user_1 = create :user @user_2 = create :user - @project_1.team << [ @user_1, :developer ] - @project_2.team << [ @user_2, :reporter ] + @project_1.team << [@user_1, :developer] + @project_2.team << [@user_2, :reporter] @status = @project_2.team.import(@project_1) end @@ -137,8 +137,8 @@ describe ProjectMember, models: true do @user_1 = create :user @user_2 = create :user - @project_1.team << [ @user_1, :developer] - @project_2.team << [ @user_2, :reporter] + @project_1.team << [@user_1, :developer] + @project_2.team << [@user_2, :reporter] ProjectMember.truncate_teams([@project_1.id, @project_2.id]) end diff --git a/spec/models/merge_request_diff_spec.rb b/spec/models/merge_request_diff_spec.rb index 6d599e148a2..0a10ee01506 100644 --- a/spec/models/merge_request_diff_spec.rb +++ b/spec/models/merge_request_diff_spec.rb @@ -109,7 +109,7 @@ describe MergeRequestDiff, models: true do { id: 'sha2' } ] - expect(subject.commits_sha).to eq(['sha1', 'sha2']) + expect(subject.commits_sha).to eq(%w(sha1 sha2)) end end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index a01741a9971..24e7c1b17d9 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -37,12 +37,12 @@ describe MergeRequest, models: true do end it "is invalid without merge user" do - subject.merge_when_build_succeeds = true + subject.merge_when_pipeline_succeeds = true expect(subject).not_to be_valid end it "is valid with merge user" do - subject.merge_when_build_succeeds = true + subject.merge_when_pipeline_succeeds = true subject.merge_user = build(:user) expect(subject).to be_valid @@ -55,7 +55,7 @@ describe MergeRequest, models: true do it { is_expected.to respond_to(:can_be_merged?) } it { is_expected.to respond_to(:cannot_be_merged?) } it { is_expected.to respond_to(:merge_params) } - it { is_expected.to respond_to(:merge_when_build_succeeds) } + it { is_expected.to respond_to(:merge_when_pipeline_succeeds) } end describe '.in_projects' do @@ -209,6 +209,50 @@ describe MergeRequest, models: true do end end + describe '#diff_size' do + let(:merge_request) do + build(:merge_request, source_branch: 'expand-collapse-files', target_branch: 'master') + end + + context 'when there are MR diffs' do + before do + merge_request.save + end + + it 'returns the correct count' do + expect(merge_request.diff_size).to eq(105) + end + + it 'does not perform highlighting' do + expect(Gitlab::Diff::Highlight).not_to receive(:new) + + merge_request.diff_size + end + end + + context 'when there are no MR diffs' do + before do + merge_request.compare = CompareService.new( + merge_request.source_project, + merge_request.source_branch + ).execute( + merge_request.target_project, + merge_request.target_branch + ) + end + + it 'returns the correct count' do + expect(merge_request.diff_size).to eq(105) + end + + it 'does not perform highlighting' do + expect(Gitlab::Diff::Highlight).not_to receive(:new) + + merge_request.diff_size + end + end + end + describe "#related_notes" do let!(:merge_request) { create(:merge_request) } @@ -302,6 +346,23 @@ describe MergeRequest, models: true do expect(subject.issues_mentioned_but_not_closing(subject.author)).to match_array([mentioned_issue]) end + + context 'when the project has an external issue tracker' do + before do + subject.project.team << [subject.author, :developer] + commit = double(:commit, safe_message: 'Fixes TEST-3') + + create(:jira_service, project: subject.project) + + allow(subject).to receive(:commits).and_return([commit]) + allow(subject).to receive(:description).and_return('Is related to TEST-2 and TEST-3') + allow(subject.project).to receive(:default_branch).and_return(subject.target_branch) + end + + it 'detects issues mentioned in description but not closed' do + expect(subject.issues_mentioned_but_not_closing(subject.author).map(&:to_s)).to match_array(['TEST-2']) + end + end end describe "#work_in_progress?" do @@ -464,24 +525,24 @@ describe MergeRequest, models: true do end end - describe "#reset_merge_when_build_succeeds" do + describe "#reset_merge_when_pipeline_succeeds" do let(:merge_if_green) do - create :merge_request, merge_when_build_succeeds: true, merge_user: create(:user), + create :merge_request, merge_when_pipeline_succeeds: true, merge_user: create(:user), merge_params: { "should_remove_source_branch" => "1", "commit_message" => "msg" } end it "sets the item to false" do - merge_if_green.reset_merge_when_build_succeeds + merge_if_green.reset_merge_when_pipeline_succeeds merge_if_green.reload - expect(merge_if_green.merge_when_build_succeeds).to be_falsey + expect(merge_if_green.merge_when_pipeline_succeeds).to be_falsey expect(merge_if_green.merge_params["should_remove_source_branch"]).to be_nil expect(merge_if_green.merge_params["commit_message"]).to be_nil end end describe "#hook_attrs" do - let(:attrs_hash) { subject.hook_attrs.to_h } + let(:attrs_hash) { subject.hook_attrs } [:source, :target].each do |key| describe "#{key} key" do @@ -497,6 +558,10 @@ describe MergeRequest, models: true do expect(attrs_hash).to include(:target) expect(attrs_hash).to include(:last_commit) expect(attrs_hash).to include(:work_in_progress) + expect(attrs_hash).to include(:total_time_spent) + expect(attrs_hash).to include(:human_time_estimate) + expect(attrs_hash).to include(:human_total_time_spent) + expect(attrs_hash).to include('time_estimate') end end @@ -768,7 +833,7 @@ describe MergeRequest, models: true do end describe '#check_if_can_be_merged' do - let(:project) { create(:empty_project, only_allow_merge_if_build_succeeds: true) } + let(:project) { create(:empty_project, only_allow_merge_if_pipeline_succeeds: true) } subject { create(:merge_request, source_project: project, merge_status: :unchecked) } @@ -789,12 +854,6 @@ describe MergeRequest, models: true do it 'becomes unmergeable' do expect { subject.check_if_can_be_merged }.to change { subject.merge_status }.to('cannot_be_merged') end - - it 'creates Todo on unmergeability' do - expect_any_instance_of(TodoService).to receive(:merge_request_became_unmergeable).with(subject) - - subject.check_if_can_be_merged - end end context 'when it has conflicts' do @@ -889,7 +948,7 @@ describe MergeRequest, models: true do end describe '#mergeable_ci_state?' do - let(:project) { create(:empty_project, only_allow_merge_if_build_succeeds: true) } + let(:project) { create(:empty_project, only_allow_merge_if_pipeline_succeeds: true) } let(:pipeline) { create(:ci_empty_pipeline) } subject { build(:merge_request, target_project: project) } @@ -932,7 +991,7 @@ describe MergeRequest, models: true do end context 'when merges are not restricted to green builds' do - subject { build(:merge_request, target_project: build(:empty_project, only_allow_merge_if_build_succeeds: false)) } + subject { build(:merge_request, target_project: build(:empty_project, only_allow_merge_if_pipeline_succeeds: false)) } context 'and a failed pipeline is associated' do before do @@ -1537,7 +1596,7 @@ describe MergeRequest, models: true do status: status) end - let(:project) { create(:project, :public, :repository, only_allow_merge_if_build_succeeds: true) } + let(:project) { create(:project, :public, :repository, only_allow_merge_if_pipeline_succeeds: true) } let(:developer) { create(:user) } let(:user) { create(:user) } let(:merge_request) { create(:merge_request, source_project: project) } diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index aeb4eeb0b55..d22447c602f 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -28,6 +28,20 @@ describe Namespace, models: true do expect(nested).not_to be_valid expect(nested.errors[:parent_id].first).to eq('has too deep level of nesting') end + + describe 'reserved path validation' do + context 'nested group' do + let(:group) { build(:group, :nested, path: 'tree') } + + it { expect(group).not_to be_valid } + end + + context 'top-level group' do + let(:group) { build(:group, path: 'tree') } + + it { expect(group).to be_valid } + end + end end describe "Respond to" do @@ -36,7 +50,7 @@ describe Namespace, models: true do end describe '#to_param' do - it { expect(namespace.to_param).to eq(namespace.path) } + it { expect(namespace.to_param).to eq(namespace.full_path) } end describe '#human_name' do @@ -153,7 +167,7 @@ describe Namespace, models: true do describe :rm_dir do let!(:project) { create(:empty_project, namespace: namespace) } - let!(:path) { File.join(Gitlab.config.repositories.storages.default, namespace.path) } + let!(:path) { File.join(Gitlab.config.repositories.storages.default['path'], namespace.full_path) } it "removes its dirs when deleted" do namespace.destroy diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb index 1cde9e04951..33536487c41 100644 --- a/spec/models/note_spec.rb +++ b/spec/models/note_spec.rb @@ -387,4 +387,16 @@ describe Note, models: true do end end end + + describe 'expiring ETag cache' do + let(:note) { build(:note_on_issue) } + + it "expires cache for note's issue when note is saved" do + expect_any_instance_of(Gitlab::EtagCaching::Store) + .to receive(:touch) + .with("/#{note.project.namespace.to_param}/#{note.project.to_param}/noteable/issue/#{note.noteable.id}/notes") + + note.save! + end + end end diff --git a/spec/models/personal_access_token_spec.rb b/spec/models/personal_access_token_spec.rb index 46eb71cef14..823623d96fa 100644 --- a/spec/models/personal_access_token_spec.rb +++ b/spec/models/personal_access_token_spec.rb @@ -1,15 +1,61 @@ require 'spec_helper' describe PersonalAccessToken, models: true do - describe ".generate" do - it "generates a random token" do - personal_access_token = PersonalAccessToken.generate({}) - expect(personal_access_token.token).to be_present + describe '.build' do + let(:personal_access_token) { build(:personal_access_token) } + let(:invalid_personal_access_token) { build(:personal_access_token, :invalid) } + + it 'is a valid personal access token' do + expect(personal_access_token).to be_valid + end + + it 'ensures that the token is generated' do + invalid_personal_access_token.save! + + expect(invalid_personal_access_token).to be_valid + expect(invalid_personal_access_token.token).not_to be_nil end + end + + describe ".active?" do + let(:active_personal_access_token) { build(:personal_access_token) } + let(:revoked_personal_access_token) { build(:personal_access_token, :revoked) } + let(:expired_personal_access_token) { build(:personal_access_token, :expired) } + + it "returns false if the personal_access_token is revoked" do + expect(revoked_personal_access_token).not_to be_active + end + + it "returns false if the personal_access_token is expired" do + expect(expired_personal_access_token).not_to be_active + end + + it "returns true if the personal_access_token is not revoked and not expired" do + expect(active_personal_access_token).to be_active + end + end + + context "validations" do + let(:personal_access_token) { build(:personal_access_token) } + + it "requires at least one scope" do + personal_access_token.scopes = [] + + expect(personal_access_token).not_to be_valid + expect(personal_access_token.errors[:scopes].first).to eq "can't be blank" + end + + it "allows creating a token with API scopes" do + personal_access_token.scopes = [:api, :read_user] + + expect(personal_access_token).to be_valid + end + + it "rejects creating a token with non-API scopes" do + personal_access_token.scopes = [:openid, :api] - it "doesn't save the record" do - personal_access_token = PersonalAccessToken.generate({}) - expect(personal_access_token).not_to be_persisted + expect(personal_access_token).not_to be_valid + expect(personal_access_token.errors[:scopes].first).to eq "can only contain API scopes" end end end diff --git a/spec/models/project_group_link_spec.rb b/spec/models/project_group_link_spec.rb index 59a4ae1b799..9b711bfc007 100644 --- a/spec/models/project_group_link_spec.rb +++ b/spec/models/project_group_link_spec.rb @@ -7,12 +7,27 @@ describe ProjectGroupLink do end describe "Validation" do - let!(:project_group_link) { create(:project_group_link) } + let(:parent_group) { create(:group) } + let(:group) { create(:group, parent: parent_group) } + let(:project) { create(:project, group: group) } + let!(:project_group_link) { create(:project_group_link, project: project) } it { should validate_presence_of(:project_id) } it { should validate_uniqueness_of(:group_id).scoped_to(:project_id).with_message(/already shared/) } it { should validate_presence_of(:group) } it { should validate_presence_of(:group_access) } + + it "doesn't allow a project to be shared with the group it is in" do + project_group_link.group = group + + expect(project_group_link).not_to be_valid + end + + it "doesn't allow a project to be shared with an ancestor of the group it is in" do + project_group_link.group = parent_group + + expect(project_group_link).not_to be_valid + end end describe "destroying a record", truncate: true do diff --git a/spec/models/project_services/bamboo_service_spec.rb b/spec/models/project_services/bamboo_service_spec.rb index 497a626a418..4014d6129ee 100644 --- a/spec/models/project_services/bamboo_service_spec.rb +++ b/spec/models/project_services/bamboo_service_spec.rb @@ -181,7 +181,7 @@ describe BambooService, models: true, caching: true do end it 'sets commit status to "pending" when response has no results' do - stub_request(body: %Q({"results":{"results":{"size":"0"}}})) + stub_request(body: %q({"results":{"results":{"size":"0"}}})) is_expected.to eq('pending') end diff --git a/spec/models/project_services/buildkite_service_spec.rb b/spec/models/project_services/buildkite_service_spec.rb index dbd23ff5491..05b602d8106 100644 --- a/spec/models/project_services/buildkite_service_spec.rb +++ b/spec/models/project_services/buildkite_service_spec.rb @@ -92,7 +92,7 @@ describe BuildkiteService, models: true, caching: true do end it 'passes through build status untouched when status is 200' do - stub_request(body: %Q({"status":"Great Success"})) + stub_request(body: %q({"status":"Great Success"})) is_expected.to eq('Great Success') end @@ -101,7 +101,7 @@ describe BuildkiteService, models: true, caching: true do end def stub_request(status: 200, body: nil) - body ||= %Q({"status":"success"}) + body ||= %q({"status":"success"}) buildkite_full_url = 'https://gitlab.buildkite.com/status/secret-sauce-status-token.json?commit=123' WebMock.stub_request(:get, buildkite_full_url).to_return( diff --git a/spec/models/project_services/builds_email_service_spec.rb b/spec/models/project_services/builds_email_service_spec.rb deleted file mode 100644 index 0194f9e2563..00000000000 --- a/spec/models/project_services/builds_email_service_spec.rb +++ /dev/null @@ -1,111 +0,0 @@ -require 'spec_helper' - -describe BuildsEmailService do - let(:data) do - Gitlab::DataBuilder::Build.build(create(:ci_build)) - end - - describe 'Validations' do - context 'when service is active' do - before { subject.active = true } - - it { is_expected.to validate_presence_of(:recipients) } - - context 'when pusher is added' do - before { subject.add_pusher = true } - - it { is_expected.not_to validate_presence_of(:recipients) } - end - end - - context 'when service is inactive' do - before { subject.active = false } - - it { is_expected.not_to validate_presence_of(:recipients) } - end - end - - describe '#test_data' do - let(:build) { create(:ci_build) } - let(:project) { build.project } - let(:user) { create(:user) } - - before { project.team << [user, :developer] } - - it 'builds test data' do - data = subject.test_data(project) - - expect(data[:object_kind]).to eq("build") - end - end - - describe '#test' do - it 'sends email' do - data = Gitlab::DataBuilder::Build.build(create(:ci_build)) - subject.recipients = 'test@gitlab.com' - - expect(BuildEmailWorker).to receive(:perform_async) - - subject.test(data) - end - - context 'notify only failed builds is true' do - it 'sends email' do - data = Gitlab::DataBuilder::Build.build(create(:ci_build)) - data[:build_status] = "success" - subject.recipients = 'test@gitlab.com' - - expect(subject).not_to receive(:notify_only_broken_builds) - expect(BuildEmailWorker).to receive(:perform_async) - - subject.test(data) - end - end - end - - describe '#execute' do - it 'sends email' do - subject.recipients = 'test@gitlab.com' - data[:build_status] = 'failed' - - expect(BuildEmailWorker).to receive(:perform_async) - - subject.execute(data) - end - - it 'does not send email with succeeded build and notify_only_broken_builds on' do - expect(subject).to receive(:notify_only_broken_builds).and_return(true) - data[:build_status] = 'success' - - expect(BuildEmailWorker).not_to receive(:perform_async) - - subject.execute(data) - end - - it 'does not send email with failed build and build_allow_failure is true' do - data[:build_status] = 'failed' - data[:build_allow_failure] = true - - expect(BuildEmailWorker).not_to receive(:perform_async) - - subject.execute(data) - end - - it 'does not send email with unknown build status' do - data[:build_status] = 'foo' - - expect(BuildEmailWorker).not_to receive(:perform_async) - - subject.execute(data) - end - - it 'does not send email when recipients list is empty' do - subject.recipients = ' ,, ' - data[:build_status] = 'failed' - - expect(BuildEmailWorker).not_to receive(:perform_async) - - subject.execute(data) - end - end -end diff --git a/spec/models/project_services/chat_message/build_message_spec.rb b/spec/models/project_services/chat_message/build_message_spec.rb deleted file mode 100644 index 3bd7ec18ae0..00000000000 --- a/spec/models/project_services/chat_message/build_message_spec.rb +++ /dev/null @@ -1,77 +0,0 @@ -require 'spec_helper' - -describe ChatMessage::BuildMessage do - subject { described_class.new(args) } - - let(:args) do - { - sha: '97de212e80737a608d939f648d959671fb0a0142', - ref: 'develop', - tag: false, - - project_name: 'project_name', - project_url: 'http://example.gitlab.com', - build_id: 1, - build_name: build_name, - build_stage: stage, - - commit: { - status: status, - author_name: 'hacker', - author_url: 'http://example.gitlab.com/hacker', - duration: duration, - }, - } - end - - let(:message) { build_message } - let(:stage) { 'test' } - let(:status) { 'success' } - let(:build_name) { 'rspec' } - let(:duration) { 10 } - - context 'build succeeded' do - let(:status) { 'success' } - let(:color) { 'good' } - let(:message) { build_message('passed') } - - it 'returns a message with information about succeeded build' do - expect(subject.pretext).to be_empty - expect(subject.fallback).to eq(message) - expect(subject.attachments).to eq([text: message, color: color]) - end - end - - context 'build failed' do - let(:status) { 'failed' } - let(:color) { 'danger' } - - it 'returns a message with information about failed build' do - expect(subject.pretext).to be_empty - expect(subject.fallback).to eq(message) - expect(subject.attachments).to eq([text: message, color: color]) - end - end - - it 'returns a message with information on build' do - expect(subject.fallback).to include("on build <http://example.gitlab.com/builds/1|#{build_name}>") - end - - it 'returns a message with stage name' do - expect(subject.fallback).to include("of stage #{stage}") - end - - it 'returns a message with link to author' do - expect(subject.fallback).to include("by <http://example.gitlab.com/hacker|hacker>") - end - - def build_message(status_text = status, stage_text = stage, build_text = build_name) - "<http://example.gitlab.com|project_name>:" \ - " Commit <http://example.gitlab.com/commit/" \ - "97de212e80737a608d939f648d959671fb0a0142/builds|97de212e>" \ - " of <http://example.gitlab.com/commits/develop|develop> branch" \ - " by <http://example.gitlab.com/hacker|hacker> #{status_text}" \ - " on build <http://example.gitlab.com/builds/1|#{build_text}>" \ - " of stage #{stage_text} in #{duration} #{'second'.pluralize(duration)}" - end -end diff --git a/spec/models/project_services/drone_ci_service_spec.rb b/spec/models/project_services/drone_ci_service_spec.rb index f9307d6de7b..044737c6026 100644 --- a/spec/models/project_services/drone_ci_service_spec.rb +++ b/spec/models/project_services/drone_ci_service_spec.rb @@ -28,7 +28,7 @@ describe DroneCiService, models: true, caching: true do shared_context :drone_ci_service do let(:drone) { DroneCiService.new } let(:project) { create(:project, :repository, name: 'project') } - let(:path) { "#{project.namespace.path}/#{project.path}" } + let(:path) { project.full_path } let(:drone_url) { 'http://drone.example.com' } let(:sha) { '2ab7834c' } let(:branch) { 'dev' } @@ -50,7 +50,7 @@ describe DroneCiService, models: true, caching: true do end def stub_request(status: 200, body: nil) - body ||= %Q({"status":"success"}) + body ||= %q({"status":"success"}) WebMock.stub_request(:get, commit_status_path).to_return( status: status, @@ -95,12 +95,12 @@ describe DroneCiService, models: true, caching: true do is_expected.to eq(:error) end - { "killed" => :canceled, + { + "killed" => :canceled, "failure" => :failed, "error" => :failed, - "success" => "success", + "success" => "success" }.each do |drone_status, our_status| - it "sets commit status to #{our_status.inspect} when returned status is #{drone_status.inspect}" do stub_request(body: %Q({"status":"#{drone_status}"})) diff --git a/spec/models/project_services/hipchat_service_spec.rb b/spec/models/project_services/hipchat_service_spec.rb index bf422ac7ce1..1200ae7eb22 100644 --- a/spec/models/project_services/hipchat_service_spec.rb +++ b/spec/models/project_services/hipchat_service_spec.rb @@ -280,13 +280,14 @@ describe HipchatService, models: true do end end - context 'build events' do - let(:pipeline) { create(:ci_empty_pipeline) } - let(:build) { create(:ci_build, pipeline: pipeline) } - let(:data) { Gitlab::DataBuilder::Build.build(build.reload) } + context 'pipeline events' do + let(:pipeline) { create(:ci_empty_pipeline, user: create(:user)) } + let(:data) { Gitlab::DataBuilder::Pipeline.build(pipeline) } context 'for failed' do - before { build.drop } + before do + pipeline.drop + end it "calls Hipchat API" do hipchat.execute(data) @@ -295,35 +296,36 @@ describe HipchatService, models: true do end it "creates a build message" do - message = hipchat.send(:create_build_message, data) + message = hipchat.__send__(:create_pipeline_message, data) project_url = project.web_url project_name = project.name_with_namespace.gsub(/\s/, '') - sha = data[:sha] - ref = data[:ref] - ref_type = data[:tag] ? 'tag' : 'branch' - duration = data[:commit][:duration] + pipeline_attributes = data[:object_attributes] + ref = pipeline_attributes[:ref] + ref_type = pipeline_attributes[:tag] ? 'tag' : 'branch' + duration = pipeline_attributes[:duration] + user_name = data[:user][:name] expect(message).to eq("<a href=\"#{project_url}\">#{project_name}</a>: " \ - "Commit <a href=\"#{project_url}/commit/#{sha}/builds\">#{Commit.truncate_sha(sha)}</a> " \ + "Pipeline <a href=\"#{project_url}/pipelines/#{pipeline.id}\">##{pipeline.id}</a> " \ "of <a href=\"#{project_url}/commits/#{ref}\">#{ref}</a> #{ref_type} " \ - "by #{data[:commit][:author_name]} failed in #{duration} second(s)") + "by #{user_name} failed in #{duration} second(s)") end end context 'for succeeded' do before do - build.success + pipeline.succeed end it "calls Hipchat API" do - hipchat.notify_only_broken_builds = false + hipchat.notify_only_broken_pipelines = false hipchat.execute(data) expect(WebMock).to have_requested(:post, api_url).once end it "notifies only broken" do - hipchat.notify_only_broken_builds = true + hipchat.notify_only_broken_pipelines = true hipchat.execute(data) expect(WebMock).not_to have_requested(:post, api_url).once end @@ -349,17 +351,19 @@ describe HipchatService, models: true do context 'with a successful build' do it 'uses the green color' do - build_data = { object_kind: 'build', commit: { status: 'success' } } + data = { object_kind: 'pipeline', + object_attributes: { status: 'success' } } - expect(hipchat.__send__(:message_options, build_data)).to eq({ notify: false, color: 'green' }) + expect(hipchat.__send__(:message_options, data)).to eq({ notify: false, color: 'green' }) end end context 'with a failed build' do it 'uses the red color' do - build_data = { object_kind: 'build', commit: { status: 'failed' } } + data = { object_kind: 'pipeline', + object_attributes: { status: 'failed' } } - expect(hipchat.__send__(:message_options, build_data)).to eq({ notify: false, color: 'red' }) + expect(hipchat.__send__(:message_options, data)).to eq({ notify: false, color: 'red' }) end end end diff --git a/spec/models/project_services/irker_service_spec.rb b/spec/models/project_services/irker_service_spec.rb index b9fb6f3f6f4..d5a16226d9d 100644 --- a/spec/models/project_services/irker_service_spec.rb +++ b/spec/models/project_services/irker_service_spec.rb @@ -59,8 +59,8 @@ describe IrkerService, models: true do conn = @irker_server.accept conn.readlines.each do |line| - msg = JSON.load(line.chomp("\n")) - expect(msg.keys).to match_array(['to', 'privmsg']) + msg = JSON.parse(line.chomp("\n")) + expect(msg.keys).to match_array(%w(to privmsg)) expect(msg['to']).to match_array(["irc://chat.freenode.net/#commits", "irc://test.net/#test"]) end diff --git a/spec/models/project_services/issue_tracker_service_spec.rb b/spec/models/project_services/issue_tracker_service_spec.rb new file mode 100644 index 00000000000..fbe6f344a98 --- /dev/null +++ b/spec/models/project_services/issue_tracker_service_spec.rb @@ -0,0 +1,32 @@ +require 'spec_helper' + +describe IssueTrackerService, models: true do + describe 'Validations' do + let(:project) { create :project } + + describe 'only one issue tracker per project' do + let(:service) { RedmineService.new(project: project, active: true) } + + before do + create(:service, project: project, active: true, category: 'issue_tracker') + end + + context 'when service is changed manually by user' do + it 'executes the validation' do + valid = service.valid?(:manual_change) + + expect(valid).to be_falsey + expect(service.errors[:base]).to include( + 'Another issue tracker is already in use. Only one issue tracker service can be active at a time' + ) + end + end + + context 'when service is changed internally' do + it 'does not execute the validation' do + expect(service.valid?).to be_truthy + end + end + end + end +end diff --git a/spec/models/project_services/kubernetes_service_spec.rb b/spec/models/project_services/kubernetes_service_spec.rb index 9052479d35e..bf7950ef1c9 100644 --- a/spec/models/project_services/kubernetes_service_spec.rb +++ b/spec/models/project_services/kubernetes_service_spec.rb @@ -74,8 +74,10 @@ describe KubernetesService, models: true, caching: true do describe '#initialize_properties' do context 'with a project' do - it 'defaults to the project name' do - expect(described_class.new(project: project).namespace).to eq(project.name) + let(:namespace_name) { "#{project.path}-#{project.id}" } + + it 'defaults to the project name with ID' do + expect(described_class.new(project: project).namespace).to eq(namespace_name) end end @@ -163,6 +165,12 @@ describe KubernetesService, models: true, caching: true do { key: 'KUBE_CA_PEM', value: 'CA PEM DATA', public: true } ) end + + it 'sets KUBE_CA_PEM_FILE' do + expect(subject.predefined_variables).to include( + { key: 'KUBE_CA_PEM_FILE', value: 'CA PEM DATA', public: true, file: true } + ) + end end describe '#terminals' do @@ -171,7 +179,7 @@ describe KubernetesService, models: true, caching: true do context 'with invalid pods' do it 'returns no terminals' do - stub_reactive_cache(service, pods: [ { "bad" => "pod" } ]) + stub_reactive_cache(service, pods: [{ "bad" => "pod" }]) is_expected.to be_empty end @@ -184,7 +192,7 @@ describe KubernetesService, models: true, caching: true do before do stub_reactive_cache( service, - pods: [ pod, pod, kube_pod(app: "should-be-filtered-out") ] + pods: [pod, pod, kube_pod(app: "should-be-filtered-out")] ) end diff --git a/spec/models/project_services/mattermost_slash_commands_service_spec.rb b/spec/models/project_services/mattermost_slash_commands_service_spec.rb index 98f3d420c8a..f9531be5d25 100644 --- a/spec/models/project_services/mattermost_slash_commands_service_spec.rb +++ b/spec/models/project_services/mattermost_slash_commands_service_spec.rb @@ -36,7 +36,8 @@ describe MattermostSlashCommandsService, :models do description: "Perform common operations on: #{project.name_with_namespace}", display_name: "GitLab / #{project.name_with_namespace}", method: 'P', - username: 'GitLab' }.to_json). + username: 'GitLab' + }.to_json). to_return( status: 200, headers: { 'Content-Type' => 'application/json' }, @@ -91,7 +92,7 @@ describe MattermostSlashCommandsService, :models do to_return( status: 200, headers: { 'Content-Type' => 'application/json' }, - body: ['list'].to_json + body: { 'list' => true }.to_json ) end diff --git a/spec/models/project_services/prometheus_service_spec.rb b/spec/models/project_services/prometheus_service_spec.rb new file mode 100644 index 00000000000..d15079b686b --- /dev/null +++ b/spec/models/project_services/prometheus_service_spec.rb @@ -0,0 +1,104 @@ +require 'spec_helper' + +describe PrometheusService, models: true, caching: true do + include PrometheusHelpers + include ReactiveCachingHelpers + + let(:project) { create(:prometheus_project) } + let(:service) { project.prometheus_service } + + describe "Associations" do + it { is_expected.to belong_to :project } + end + + describe 'Validations' do + context 'when service is active' do + before { subject.active = true } + + it { is_expected.to validate_presence_of(:api_url) } + end + + context 'when service is inactive' do + before { subject.active = false } + + it { is_expected.not_to validate_presence_of(:api_url) } + end + end + + describe '#test' do + let!(:req_stub) { stub_prometheus_request(prometheus_query_url('1'), body: prometheus_value_body('vector')) } + + context 'success' do + it 'reads the discovery endpoint' do + expect(service.test[:success]).to be_truthy + expect(req_stub).to have_been_requested + end + end + + context 'failure' do + let!(:req_stub) { stub_prometheus_request(prometheus_query_url('1'), status: 404) } + + it 'fails to read the discovery endpoint' do + expect(service.test[:success]).to be_falsy + expect(req_stub).to have_been_requested + end + end + end + + describe '#metrics' do + let(:environment) { build_stubbed(:environment, slug: 'env-slug') } + subject { service.metrics(environment) } + + around do |example| + Timecop.freeze { example.run } + end + + context 'with valid data' do + before do + stub_reactive_cache(service, prometheus_data, 'env-slug') + end + + it 'returns reactive data' do + is_expected.to eq(prometheus_data) + end + end + end + + describe '#calculate_reactive_cache' do + let(:environment) { build_stubbed(:environment, slug: 'env-slug') } + + around do |example| + Timecop.freeze { example.run } + end + + subject do + service.calculate_reactive_cache(environment.slug) + end + + context 'when service is inactive' do + before do + service.active = false + end + + it { is_expected.to be_nil } + end + + context 'when Prometheus responds with valid data' do + before do + stub_all_prometheus_requests(environment.slug) + end + + it { expect(subject.to_json).to eq(prometheus_data.to_json) } + end + + [404, 500].each do |status| + context "when Prometheus responds with #{status}" do + before do + stub_all_prometheus_requests(environment.slug, status: status, body: 'QUERY FAILED!') + end + + it { is_expected.to eq(success: false, result: %(#{status} - "QUERY FAILED!")) } + end + end + end +end diff --git a/spec/models/project_services/teamcity_service_spec.rb b/spec/models/project_services/teamcity_service_spec.rb index a1edd083aa1..77b18e1c7d0 100644 --- a/spec/models/project_services/teamcity_service_spec.rb +++ b/spec/models/project_services/teamcity_service_spec.rb @@ -143,7 +143,7 @@ describe TeamcityService, models: true, caching: true do end it 'returns a build URL when teamcity_url has no trailing slash' do - stub_request(body: %Q({"build":{"id":"666"}})) + stub_request(body: %q({"build":{"id":"666"}})) is_expected.to eq('http://gitlab.com/teamcity/viewLog.html?buildId=666&buildTypeId=foo') end @@ -152,7 +152,7 @@ describe TeamcityService, models: true, caching: true do let(:teamcity_url) { 'http://gitlab.com/teamcity/' } it 'returns a build URL' do - stub_request(body: %Q({"build":{"id":"666"}})) + stub_request(body: %q({"build":{"id":"666"}})) is_expected.to eq('http://gitlab.com/teamcity/viewLog.html?buildId=666&buildTypeId=foo') end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 77f2ff3d17b..aefbedf0b93 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -29,8 +29,7 @@ describe Project, models: true do it { is_expected.to have_one(:campfire_service).dependent(:destroy) } it { is_expected.to have_one(:drone_ci_service).dependent(:destroy) } it { is_expected.to have_one(:emails_on_push_service).dependent(:destroy) } - it { is_expected.to have_one(:builds_email_service).dependent(:destroy) } - it { is_expected.to have_one(:emails_on_push_service).dependent(:destroy) } + it { is_expected.to have_one(:pipelines_email_service).dependent(:destroy) } it { is_expected.to have_one(:irker_service).dependent(:destroy) } it { is_expected.to have_one(:pivotaltracker_service).dependent(:destroy) } it { is_expected.to have_one(:hipchat_service).dependent(:destroy) } @@ -71,6 +70,7 @@ describe Project, models: true do it { is_expected.to have_many(:project_group_links).dependent(:destroy) } it { is_expected.to have_many(:notification_settings).dependent(:destroy) } it { is_expected.to have_many(:forks).through(:forked_project_links) } + it { is_expected.to have_many(:uploads).dependent(:destroy) } context 'after initialized' do it "has a project_feature" do @@ -178,7 +178,7 @@ describe Project, models: true do let(:project2) { build(:empty_project, repository_storage: 'missing') } before do - storages = { 'custom' => 'tmp/tests/custom_repositories' } + storages = { 'custom' => { 'path' => 'tmp/tests/custom_repositories' } } allow(Gitlab.config.repositories).to receive(:storages).and_return(storages) end @@ -218,6 +218,20 @@ describe Project, models: true do expect(project2.import_data).to be_nil end + it "does not allow blocked import_url localhost" do + project2 = build(:empty_project, import_url: 'http://localhost:9000/t.git') + + expect(project2).to be_invalid + expect(project2.errors[:import_url]).to include('imports are not allowed from that URL') + end + + it "does not allow blocked import_url port" do + project2 = build(:empty_project, import_url: 'http://github.com:25/t.git') + + expect(project2).to be_invalid + expect(project2.errors[:import_url]).to include('imports are not allowed from that URL') + end + describe 'project pending deletion' do let!(:project_pending_deletion) do create(:empty_project, @@ -380,7 +394,7 @@ describe Project, models: true do before do FileUtils.mkdir('tmp/tests/custom_repositories') - storages = { 'custom' => 'tmp/tests/custom_repositories' } + storages = { 'custom' => { 'path' => 'tmp/tests/custom_repositories' } } allow(Gitlab.config.repositories).to receive(:storages).and_return(storages) end @@ -402,7 +416,7 @@ describe Project, models: true do let(:project) { create(:empty_project, path: "somewhere") } it 'returns the full web URL for this repo' do - expect(project.web_url).to eq("#{Gitlab.config.gitlab.url}/#{project.namespace.path}/somewhere") + expect(project.web_url).to eq("#{Gitlab.config.gitlab.url}/#{project.namespace.full_path}/somewhere") end end @@ -803,7 +817,7 @@ describe Project, models: true do end let(:avatar_path) do - "/#{project.namespace.name}/#{project.path}/avatar" + "/#{project.full_path}/avatar" end it { should eq "http://#{Gitlab.config.gitlab.host}#{avatar_path}" } @@ -946,8 +960,8 @@ describe Project, models: true do before do storages = { - 'default' => 'tmp/tests/repositories', - 'picked' => 'tmp/tests/repositories', + 'default' => { 'path' => 'tmp/tests/repositories' }, + 'picked' => { 'path' => 'tmp/tests/repositories' }, } allow(Gitlab.config.repositories).to receive(:storages).and_return(storages) end @@ -1148,16 +1162,14 @@ describe Project, models: true do end it 'renames a repository' do - ns = project.namespace_dir - expect(gitlab_shell).to receive(:mv_repository). ordered. - with(project.repository_storage_path, "#{ns}/foo", "#{ns}/#{project.path}"). + with(project.repository_storage_path, "#{project.namespace.full_path}/foo", "#{project.full_path}"). and_return(true) expect(gitlab_shell).to receive(:mv_repository). ordered. - with(project.repository_storage_path, "#{ns}/foo.wiki", "#{ns}/#{project.path}.wiki"). + with(project.repository_storage_path, "#{project.namespace.full_path}/foo.wiki", "#{project.full_path}.wiki"). and_return(true) expect_any_instance_of(SystemHooksService). @@ -1166,7 +1178,7 @@ describe Project, models: true do expect_any_instance_of(Gitlab::UploadsTransfer). to receive(:rename_project). - with('foo', project.path, ns) + with('foo', project.path, project.namespace.full_path) expect(project).to receive(:expire_caches_before_rename) @@ -1513,7 +1525,7 @@ describe Project, models: true do it 'schedules a RepositoryForkWorker job' do expect(RepositoryForkWorker).to receive(:perform_async). with(project.id, forked_from_project.repository_storage_path, - forked_from_project.path_with_namespace, project.namespace.path) + forked_from_project.path_with_namespace, project.namespace.full_path) project.add_import_job end @@ -1727,7 +1739,7 @@ describe Project, models: true do describe 'inside_path' do let!(:project1) { create(:empty_project) } let!(:project2) { create(:empty_project) } - let!(:path) { project1.namespace.path } + let!(:path) { project1.namespace.full_path } it { expect(Project.inside_path(path)).to eq([project1]) } end @@ -1742,7 +1754,7 @@ describe Project, models: true do end before do - project.repository.commit_file(User.last, '.gitlab/route-map.yml', route_map, message: 'Add .gitlab/route-map.yml', branch_name: 'master', update: false) + project.repository.create_file(User.last, '.gitlab/route-map.yml', route_map, message: 'Add .gitlab/route-map.yml', branch_name: 'master') end context 'when there is a .gitlab/route-map.yml at the commit' do @@ -1871,4 +1883,34 @@ describe Project, models: true do end end end + + describe '#http_url_to_repo' do + let(:project) { create :empty_project } + + context 'when no user is given' do + it 'returns the url to the repo without a username' do + expect(project.http_url_to_repo).to eq("#{project.web_url}.git") + expect(project.http_url_to_repo).not_to include('@') + end + end + + context 'when user is given' do + it 'returns the url to the repo with the username' do + user = build_stubbed(:user) + + expect(project.http_url_to_repo(user)).to start_with("http://#{user.username}@") + end + end + end + + describe '#pipeline_status' do + let(:project) { create(:project) } + it 'builds a pipeline status' do + expect(project.pipeline_status).to be_a(Ci::PipelineStatus) + end + + it 'hase a loaded pipeline status' do + expect(project.pipeline_status).to be_loaded + end + end end diff --git a/spec/models/project_wiki_spec.rb b/spec/models/project_wiki_spec.rb index 58b57bd4fef..b5b9cd024b0 100644 --- a/spec/models/project_wiki_spec.rb +++ b/spec/models/project_wiki_spec.rb @@ -35,10 +35,23 @@ describe ProjectWiki, models: true do end describe "#http_url_to_repo" do - it "provides the full http url to the repo" do - gitlab_url = Gitlab.config.gitlab.url - repo_http_url = "#{gitlab_url}/#{subject.path_with_namespace}.git" - expect(subject.http_url_to_repo).to eq(repo_http_url) + let(:project) { create :empty_project } + + context 'when no user is given' do + it 'returns the url to the repo without a username' do + expected_url = "#{Gitlab.config.gitlab.url}/#{subject.path_with_namespace}.git" + + expect(project_wiki.http_url_to_repo).to eq(expected_url) + expect(project_wiki.http_url_to_repo).not_to include('@') + end + end + + context 'when user is given' do + it 'returns the url to the repo with the username' do + user = build_stubbed(:user) + + expect(project_wiki.http_url_to_repo(user)).to start_with("http://#{user.username}@") + end end end diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index 838fd3754b2..274e4f00a0a 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -291,10 +291,10 @@ describe Repository, models: true do end end - describe "#commit_dir" do + describe "#create_dir" do it "commits a change that creates a new directory" do expect do - repository.commit_dir(user, 'newdir', + repository.create_dir(user, 'newdir', message: 'Create newdir', branch_name: 'master') end.to change { repository.commits('master').count }.by(1) @@ -307,7 +307,7 @@ describe Repository, models: true do it "creates a fork and commit to the forked project" do expect do - repository.commit_dir(user, 'newdir', + repository.create_dir(user, 'newdir', message: 'Create newdir', branch_name: 'patch', start_branch_name: 'master', start_project: forked_project) end.to change { repository.commits('master').count }.by(0) @@ -323,7 +323,7 @@ describe Repository, models: true do context "when an author is specified" do it "uses the given email/name to set the commit's author" do expect do - repository.commit_dir(user, 'newdir', + repository.create_dir(user, 'newdir', message: 'Add newdir', branch_name: 'master', author_email: author_email, author_name: author_name) @@ -337,25 +337,23 @@ describe Repository, models: true do end end - describe "#commit_file" do - it 'commits change to a file successfully' do + describe "#create_file" do + it 'commits new file successfully' do expect do - repository.commit_file(user, 'CHANGELOG', 'Changelog!', - message: 'Updates file content', - branch_name: 'master', - update: true) + repository.create_file(user, 'NEWCHANGELOG', 'Changelog!', + message: 'Create changelog', + branch_name: 'master') end.to change { repository.commits('master').count }.by(1) - blob = repository.blob_at('master', 'CHANGELOG') + blob = repository.blob_at('master', 'NEWCHANGELOG') expect(blob.data).to eq('Changelog!') end it 'respects the autocrlf setting' do - repository.commit_file(user, 'hello.txt', "Hello,\r\nWorld", + repository.create_file(user, 'hello.txt', "Hello,\r\nWorld", message: 'Add hello world', - branch_name: 'master', - update: true) + branch_name: 'master') blob = repository.blob_at('master', 'hello.txt') @@ -365,10 +363,9 @@ describe Repository, models: true do context "when an author is specified" do it "uses the given email/name to set the commit's author" do expect do - repository.commit_file(user, 'README', 'README!', + repository.create_file(user, 'NEWREADME', 'README!', message: 'Add README', branch_name: 'master', - update: true, author_email: author_email, author_name: author_name) end.to change { repository.commits('master').count }.by(1) @@ -382,6 +379,18 @@ describe Repository, models: true do end describe "#update_file" do + it 'updates file successfully' do + expect do + repository.update_file(user, 'CHANGELOG', 'Changelog!', + message: 'Update changelog', + branch_name: 'master') + end.to change { repository.commits('master').count }.by(1) + + blob = repository.blob_at('master', 'CHANGELOG') + + expect(blob.data).to eq('Changelog!') + end + it 'updates filename successfully' do expect do repository.update_file(user, 'NEWLICENSE', 'Copyright!', @@ -398,9 +407,6 @@ describe Repository, models: true do context "when an author is specified" do it "uses the given email/name to set the commit's author" do - repository.commit_file(user, 'README', 'README!', - message: 'Add README', branch_name: 'master', update: true) - expect do repository.update_file(user, 'README', 'Updated README!', branch_name: 'master', @@ -418,13 +424,10 @@ describe Repository, models: true do end end - describe "#remove_file" do + describe "#delete_file" do it 'removes file successfully' do - repository.commit_file(user, 'README', 'README!', - message: 'Add README', branch_name: 'master', update: true) - expect do - repository.remove_file(user, 'README', + repository.delete_file(user, 'README', message: 'Remove README', branch_name: 'master') end.to change { repository.commits('master').count }.by(1) @@ -433,11 +436,8 @@ describe Repository, models: true do context "when an author is specified" do it "uses the given email/name to set the commit's author" do - repository.commit_file(user, 'README', 'README!', - message: 'Add README', branch_name: 'master', update: true) - expect do - repository.remove_file(user, 'README', + repository.delete_file(user, 'README', message: 'Remove README', branch_name: 'master', author_email: author_email, author_name: author_name) end.to change { repository.commits('master').count }.by(1) @@ -587,14 +587,14 @@ describe Repository, models: true do describe "#license_blob", caching: true do before do - repository.remove_file( + repository.delete_file( user, 'LICENSE', message: 'Remove LICENSE', branch_name: 'master') end it 'handles when HEAD points to non-existent ref' do - repository.commit_file( + repository.create_file( user, 'LICENSE', 'Copyright!', - message: 'Add LICENSE', branch_name: 'master', update: false) + message: 'Add LICENSE', branch_name: 'master') allow(repository).to receive(:file_on_head). and_raise(Rugged::ReferenceError) @@ -603,27 +603,27 @@ describe Repository, models: true do end it 'looks in the root_ref only' do - repository.remove_file(user, 'LICENSE', + repository.delete_file(user, 'LICENSE', message: 'Remove LICENSE', branch_name: 'markdown') - repository.commit_file(user, 'LICENSE', + repository.create_file(user, 'LICENSE', Licensee::License.new('mit').content, - message: 'Add LICENSE', branch_name: 'markdown', update: false) + message: 'Add LICENSE', branch_name: 'markdown') expect(repository.license_blob).to be_nil end it 'detects license file with no recognizable open-source license content' do - repository.commit_file(user, 'LICENSE', 'Copyright!', - message: 'Add LICENSE', branch_name: 'master', update: false) + repository.create_file(user, 'LICENSE', 'Copyright!', + message: 'Add LICENSE', branch_name: 'master') expect(repository.license_blob.name).to eq('LICENSE') end %w[LICENSE LICENCE LiCensE LICENSE.md LICENSE.foo COPYING COPYING.md].each do |filename| it "detects '#{filename}'" do - repository.commit_file(user, filename, + repository.create_file(user, filename, Licensee::License.new('mit').content, - message: "Add #{filename}", branch_name: 'master', update: false) + message: "Add #{filename}", branch_name: 'master') expect(repository.license_blob.name).to eq(filename) end @@ -632,7 +632,7 @@ describe Repository, models: true do describe '#license_key', caching: true do before do - repository.remove_file(user, 'LICENSE', + repository.delete_file(user, 'LICENSE', message: 'Remove LICENSE', branch_name: 'master') end @@ -647,16 +647,16 @@ describe Repository, models: true do end it 'detects license file with no recognizable open-source license content' do - repository.commit_file(user, 'LICENSE', 'Copyright!', - message: 'Add LICENSE', branch_name: 'master', update: false) + repository.create_file(user, 'LICENSE', 'Copyright!', + message: 'Add LICENSE', branch_name: 'master') expect(repository.license_key).to be_nil end it 'returns the license key' do - repository.commit_file(user, 'LICENSE', + repository.create_file(user, 'LICENSE', Licensee::License.new('mit').content, - message: 'Add LICENSE', branch_name: 'master', update: false) + message: 'Add LICENSE', branch_name: 'master') expect(repository.license_key).to eq('mit') end @@ -913,10 +913,9 @@ describe Repository, models: true do expect(empty_repository).to receive(:expire_emptiness_caches) expect(empty_repository).to receive(:expire_branches_cache) - empty_repository.commit_file(user, 'CHANGELOG', 'Changelog!', + empty_repository.create_file(user, 'CHANGELOG', 'Changelog!', message: 'Updates file content', - branch_name: 'master', - update: false) + branch_name: 'master') end end end @@ -1043,7 +1042,7 @@ describe Repository, models: true do it 'expires the cache for all branches' do expect(cache).to receive(:expire). - at_least(repository.branches.length). + at_least(repository.branches.length * 2). times repository.expire_branch_cache @@ -1051,14 +1050,14 @@ describe Repository, models: true do it 'expires the cache for all branches when the root branch is given' do expect(cache).to receive(:expire). - at_least(repository.branches.length). + at_least(repository.branches.length * 2). times repository.expire_branch_cache(repository.root_ref) end it 'expires the cache for a specific branch' do - expect(cache).to receive(:expire).once + expect(cache).to receive(:expire).twice repository.expire_branch_cache('foo') end @@ -1113,16 +1112,16 @@ describe Repository, models: true do let(:update_image_commit) { repository.commit('2f63565e7aac07bcdadb654e253078b727143ec4') } context 'when there is a conflict' do - it 'aborts the operation' do - expect(repository.revert(user, new_image_commit, 'master')).to eq(false) + it 'raises an error' do + expect { repository.revert(user, new_image_commit, 'master') }.to raise_error(/Failed to/) end end context 'when commit was already reverted' do - it 'aborts the operation' do + it 'raises an error' do repository.revert(user, update_image_commit, 'master') - expect(repository.revert(user, update_image_commit, 'master')).to eq(false) + expect { repository.revert(user, update_image_commit, 'master') }.to raise_error(/Failed to/) end end @@ -1149,16 +1148,16 @@ describe Repository, models: true do let(:pickable_merge) { repository.commit('e56497bb5f03a90a51293fc6d516788730953899') } context 'when there is a conflict' do - it 'aborts the operation' do - expect(repository.cherry_pick(user, conflict_commit, 'master')).to eq(false) + it 'raises an error' do + expect { repository.cherry_pick(user, conflict_commit, 'master') }.to raise_error(/Failed to/) end end context 'when commit was already cherry-picked' do - it 'aborts the operation' do + it 'raises an error' do repository.cherry_pick(user, pickable_commit, 'master') - expect(repository.cherry_pick(user, pickable_commit, 'master')).to eq(false) + expect { repository.cherry_pick(user, pickable_commit, 'master') }.to raise_error(/Failed to/) end end @@ -1382,13 +1381,13 @@ describe Repository, models: true do describe '#branch_count' do it 'returns the number of branches' do - expect(repository.branch_count).to be_an_instance_of(Fixnum) + expect(repository.branch_count).to be_an(Integer) end end describe '#tag_count' do it 'returns the number of tags' do - expect(repository.tag_count).to be_an_instance_of(Fixnum) + expect(repository.tag_count).to be_an(Integer) end end @@ -1738,7 +1737,30 @@ describe Repository, models: true do context 'with an existing repository' do it 'returns the commit count' do - expect(repository.commit_count).to be_an_instance_of(Fixnum) + expect(repository.commit_count).to be_an(Integer) + end + end + end + + describe '#commit_count_for_ref' do + let(:project) { create :empty_project } + + context 'with a non-existing repository' do + it 'returns 0' do + expect(project.repository.commit_count_for_ref('master')).to eq(0) + end + end + + context 'with empty repository' do + it 'returns 0' do + project.create_repository + expect(project.repository.commit_count_for_ref('master')).to eq(0) + end + end + + context 'when searching for the root ref' do + it 'returns the same count as #commit_count' do + expect(repository.commit_count_for_ref(repository.root_ref)).to eq(repository.commit_count) end end end @@ -1796,7 +1818,7 @@ describe Repository, models: true do describe '#gitlab_ci_yml_for' do before do - repository.commit_file(User.last, '.gitlab-ci.yml', 'CONTENT', message: 'Add .gitlab-ci.yml', branch_name: 'master', update: false) + repository.create_file(User.last, '.gitlab-ci.yml', 'CONTENT', message: 'Add .gitlab-ci.yml', branch_name: 'master') end context 'when there is a .gitlab-ci.yml at the commit' do @@ -1814,7 +1836,7 @@ describe Repository, models: true do describe '#route_map_for' do before do - repository.commit_file(User.last, '.gitlab/route-map.yml', 'CONTENT', message: 'Add .gitlab/route-map.yml', branch_name: 'master', update: false) + repository.create_file(User.last, '.gitlab/route-map.yml', 'CONTENT', message: 'Add .gitlab/route-map.yml', branch_name: 'master') end context 'when there is a .gitlab/route-map.yml at the commit' do diff --git a/spec/models/route_spec.rb b/spec/models/route_spec.rb index 0b222022e62..bc8ae4ae5a8 100644 --- a/spec/models/route_spec.rb +++ b/spec/models/route_spec.rb @@ -43,14 +43,22 @@ describe Route, models: true do end context 'name update' do - before { route.update_attributes(name: 'bar') } - it "updates children routes with new path" do + route.update_attributes(name: 'bar') + expect(described_class.exists?(name: 'bar')).to be_truthy expect(described_class.exists?(name: 'bar / test')).to be_truthy expect(described_class.exists?(name: 'bar / test / foo')).to be_truthy expect(described_class.exists?(name: 'gitlab-org')).to be_truthy end + + it 'handles a rename from nil' do + # Note: using `update_columns` to skip all validation and callbacks + route.update_columns(name: nil) + + expect { route.update_attributes(name: 'bar') } + .to change { route.name }.from(nil).to('bar') + end end end end diff --git a/spec/models/upload_spec.rb b/spec/models/upload_spec.rb new file mode 100644 index 00000000000..4c832c87d6a --- /dev/null +++ b/spec/models/upload_spec.rb @@ -0,0 +1,151 @@ +require 'rails_helper' + +describe Upload, type: :model do + describe 'assocations' do + it { is_expected.to belong_to(:model) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:size) } + it { is_expected.to validate_presence_of(:path) } + it { is_expected.to validate_presence_of(:model) } + it { is_expected.to validate_presence_of(:uploader) } + end + + describe 'callbacks' do + context 'for a file above the checksum threshold' do + it 'schedules checksum calculation' do + stub_const('UploadChecksumWorker', spy) + + upload = described_class.create( + path: __FILE__, + size: described_class::CHECKSUM_THRESHOLD + 1.kilobyte, + model: build_stubbed(:user), + uploader: double('ExampleUploader') + ) + + expect(UploadChecksumWorker) + .to have_received(:perform_async).with(upload.id) + end + end + + context 'for a file at or below the checksum threshold' do + it 'calculates checksum immediately before save' do + upload = described_class.new( + path: __FILE__, + size: described_class::CHECKSUM_THRESHOLD, + model: build_stubbed(:user), + uploader: double('ExampleUploader') + ) + + expect { upload.save } + .to change { upload.checksum }.from(nil) + .to(a_string_matching(/\A\h{64}\z/)) + end + end + end + + describe '.remove_path' do + it 'removes all records at the given path' do + described_class.create!( + size: File.size(__FILE__), + path: __FILE__, + model: build_stubbed(:user), + uploader: 'AvatarUploader' + ) + + expect { described_class.remove_path(__FILE__) }. + to change { described_class.count }.from(1).to(0) + end + end + + describe '.record' do + let(:fake_uploader) do + double( + file: double(size: 12_345), + relative_path: 'foo/bar.jpg', + model: build_stubbed(:user), + class: 'AvatarUploader' + ) + end + + it 'removes existing paths before creation' do + expect(described_class).to receive(:remove_path) + .with(fake_uploader.relative_path) + + described_class.record(fake_uploader) + end + + it 'creates a new record and assigns size, path, model, and uploader' do + upload = described_class.record(fake_uploader) + + aggregate_failures do + expect(upload).to be_persisted + expect(upload.size).to eq fake_uploader.file.size + expect(upload.path).to eq fake_uploader.relative_path + expect(upload.model_id).to eq fake_uploader.model.id + expect(upload.model_type).to eq fake_uploader.model.class.to_s + expect(upload.uploader).to eq fake_uploader.class + end + end + end + + describe '#absolute_path' do + it 'returns the path directly when already absolute' do + path = '/path/to/namespace/project/secret/file.jpg' + upload = described_class.new(path: path) + + expect(upload).not_to receive(:uploader_class) + + expect(upload.absolute_path).to eq path + end + + it "delegates to the uploader's absolute_path method" do + uploader = spy('FakeUploader') + upload = described_class.new(path: 'secret/file.jpg') + expect(upload).to receive(:uploader_class).and_return(uploader) + + upload.absolute_path + + expect(uploader).to have_received(:absolute_path).with(upload) + end + end + + describe '#calculate_checksum' do + it 'calculates the SHA256 sum' do + upload = described_class.new( + path: __FILE__, + size: described_class::CHECKSUM_THRESHOLD - 1.megabyte + ) + expected = Digest::SHA256.file(__FILE__).hexdigest + + expect { upload.calculate_checksum } + .to change { upload.checksum }.from(nil).to(expected) + end + + it 'returns nil for a non-existant file' do + upload = described_class.new( + path: __FILE__, + size: described_class::CHECKSUM_THRESHOLD - 1.megabyte + ) + + expect(upload).to receive(:exist?).and_return(false) + + expect(upload.calculate_checksum).to be_nil + end + end + + describe '#exist?' do + it 'returns true when the file exists' do + upload = described_class.new(path: __FILE__) + + expect(upload).to exist + end + + it 'returns false when the file does not exist' do + upload = described_class.new(path: "#{__FILE__}-nope") + + expect(upload).not_to exist + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 584a4facd94..90378179e32 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -22,7 +22,7 @@ describe User, models: true do it { is_expected.to have_many(:deploy_keys).dependent(:destroy) } it { is_expected.to have_many(:events).dependent(:destroy) } it { is_expected.to have_many(:recent_events).class_name('Event') } - it { is_expected.to have_many(:issues).dependent(:destroy) } + it { is_expected.to have_many(:issues).dependent(:restrict_with_exception) } it { is_expected.to have_many(:notes).dependent(:destroy) } it { is_expected.to have_many(:assigned_issues).dependent(:nullify) } it { is_expected.to have_many(:merge_requests).dependent(:destroy) } @@ -32,9 +32,11 @@ describe User, models: true do it { is_expected.to have_many(:spam_logs).dependent(:destroy) } it { is_expected.to have_many(:todos).dependent(:destroy) } it { is_expected.to have_many(:award_emoji).dependent(:destroy) } + it { is_expected.to have_many(:triggers).dependent(:destroy) } it { is_expected.to have_many(:builds).dependent(:nullify) } it { is_expected.to have_many(:pipelines).dependent(:nullify) } it { is_expected.to have_many(:chat_names).dependent(:destroy) } + it { is_expected.to have_many(:uploads).dependent(:destroy) } describe '#group_members' do it 'does not include group memberships for which user is a requester' do @@ -693,12 +695,13 @@ describe User, models: true do end describe '.search_with_secondary_emails' do - def search_with_secondary_emails(query) - described_class.search_with_secondary_emails(query) - end + delegate :search_with_secondary_emails, to: :described_class - let!(:user) { create(:user) } - let!(:email) { create(:email) } + let!(:user) { create(:user, name: 'John Doe', username: 'john.doe', email: 'john.doe@example.com' ) } + let!(:another_user) { create(:user, name: 'Albert Smith', username: 'albert.smith', email: 'albert.smith@example.com' ) } + let!(:email) do + create(:email, user: another_user, email: 'alias@example.com') + end it 'returns users with a matching name' do expect(search_with_secondary_emails(user.name)).to eq([user]) @@ -1415,7 +1418,7 @@ describe User, models: true do it { expect(user.nested_groups).to eq([nested_group]) } end - describe '#nested_projects' do + describe '#nested_groups_projects' do let!(:user) { create(:user) } let!(:group) { create(:group) } let!(:nested_group) { create(:group, parent: group) } @@ -1430,7 +1433,7 @@ describe User, models: true do other_project.add_developer(create(:user)) end - it { expect(user.nested_projects).to eq([nested_project]) } + it { expect(user.nested_groups_projects).to eq([nested_project]) } end describe '#refresh_authorized_projects', redis: true do @@ -1492,4 +1495,41 @@ describe User, models: true do expect(user.admin).to be true end end + + describe '.ghost' do + it "creates a ghost user if one isn't already present" do + ghost = User.ghost + + expect(ghost).to be_ghost + expect(ghost).to be_persisted + end + + it "does not create a second ghost user if one is already present" do + expect do + User.ghost + User.ghost + end.to change { User.count }.by(1) + expect(User.ghost).to eq(User.ghost) + end + + context "when a regular user exists with the username 'ghost'" do + it "creates a ghost user with a non-conflicting username" do + create(:user, username: 'ghost') + ghost = User.ghost + + expect(ghost).to be_persisted + expect(ghost.username).to eq('ghost1') + end + end + + context "when a regular user exists with the email 'ghost@example.com'" do + it "creates a ghost user with a non-conflicting email" do + create(:user, email: 'ghost@example.com') + ghost = User.ghost + + expect(ghost).to be_persisted + expect(ghost.email).to eq('ghost1@example.com') + end + end + end end diff --git a/spec/policies/base_policy_spec.rb b/spec/policies/base_policy_spec.rb index 63acc0b68cd..02acdcb36df 100644 --- a/spec/policies/base_policy_spec.rb +++ b/spec/policies/base_policy_spec.rb @@ -1,17 +1,19 @@ require 'spec_helper' describe BasePolicy, models: true do - let(:build) { Ci::Build.new } - describe '.class_for' do it 'detects policy class based on the subject ancestors' do - expect(described_class.class_for(build)).to eq(Ci::BuildPolicy) + expect(described_class.class_for(GenericCommitStatus.new)).to eq(CommitStatusPolicy) end it 'detects policy class for a presented subject' do - presentee = Ci::BuildPresenter.new(build) + presentee = Ci::BuildPresenter.new(Ci::Build.new) expect(described_class.class_for(presentee)).to eq(Ci::BuildPolicy) end + + it 'uses GlobalPolicy when :global is given' do + expect(described_class.class_for(:global)).to eq(GlobalPolicy) + end end end diff --git a/spec/policies/ci/trigger_policy_spec.rb b/spec/policies/ci/trigger_policy_spec.rb new file mode 100644 index 00000000000..63ad5eb7322 --- /dev/null +++ b/spec/policies/ci/trigger_policy_spec.rb @@ -0,0 +1,103 @@ +require 'spec_helper' + +describe Ci::TriggerPolicy, :models do + let(:user) { create(:user) } + let(:project) { create(:empty_project) } + let(:trigger) { create(:ci_trigger, project: project, owner: owner) } + + let(:policies) do + described_class.abilities(user, trigger).to_set + end + + shared_examples 'allows to admin and manage trigger' do + it 'does include ability to admin trigger' do + expect(policies).to include :admin_trigger + end + + it 'does include ability to manage trigger' do + expect(policies).to include :manage_trigger + end + end + + shared_examples 'allows to manage trigger' do + it 'does not include ability to admin trigger' do + expect(policies).not_to include :admin_trigger + end + + it 'does include ability to manage trigger' do + expect(policies).to include :manage_trigger + end + end + + shared_examples 'disallows to admin and manage trigger' do + it 'does not include ability to admin trigger' do + expect(policies).not_to include :admin_trigger + end + + it 'does not include ability to manage trigger' do + expect(policies).not_to include :manage_trigger + end + end + + describe '#rules' do + context 'when owner is undefined' do + let(:owner) { nil } + + context 'when user is master of the project' do + before do + project.team << [user, :master] + end + + it_behaves_like 'allows to admin and manage trigger' + end + + context 'when user is developer of the project' do + before do + project.team << [user, :developer] + end + + it_behaves_like 'disallows to admin and manage trigger' + end + + context 'when user is not member of the project' do + it_behaves_like 'disallows to admin and manage trigger' + end + end + + context 'when owner is an user' do + let(:owner) { user } + + context 'when user is master of the project' do + before do + project.team << [user, :master] + end + + it_behaves_like 'allows to admin and manage trigger' + end + end + + context 'when owner is another user' do + let(:owner) { create(:user) } + + context 'when user is master of the project' do + before do + project.team << [user, :master] + end + + it_behaves_like 'allows to manage trigger' + end + + context 'when user is developer of the project' do + before do + project.team << [user, :developer] + end + + it_behaves_like 'disallows to admin and manage trigger' + end + + context 'when user is not member of the project' do + it_behaves_like 'disallows to admin and manage trigger' + end + end + end +end diff --git a/spec/policies/user_policy_spec.rb b/spec/policies/user_policy_spec.rb new file mode 100644 index 00000000000..d5761390d39 --- /dev/null +++ b/spec/policies/user_policy_spec.rb @@ -0,0 +1,37 @@ +require 'spec_helper' + +describe UserPolicy, models: true do + let(:current_user) { create(:user) } + let(:user) { create(:user) } + + subject { described_class.abilities(current_user, user).to_set } + + describe "reading a user's information" do + it { is_expected.to include(:read_user) } + end + + describe "destroying a user" do + context "when a regular user tries to destroy another regular user" do + it { is_expected.not_to include(:destroy_user) } + end + + context "when a regular user tries to destroy themselves" do + let(:current_user) { user } + + it { is_expected.to include(:destroy_user) } + end + + context "when an admin user tries to destroy a regular user" do + let(:current_user) { create(:user, :admin) } + + it { is_expected.to include(:destroy_user) } + end + + context "when an admin user tries to destroy a ghost user" do + let(:current_user) { create(:user, :admin) } + let(:user) { create(:user, :ghost) } + + it { is_expected.not_to include(:destroy_user) } + end + end +end diff --git a/spec/presenters/projects/settings/deploy_keys_presenter_spec.rb b/spec/presenters/projects/settings/deploy_keys_presenter_spec.rb new file mode 100644 index 00000000000..6443f86b6a1 --- /dev/null +++ b/spec/presenters/projects/settings/deploy_keys_presenter_spec.rb @@ -0,0 +1,66 @@ +require 'spec_helper' + +describe Projects::Settings::DeployKeysPresenter do + let(:project) { create(:empty_project) } + let(:user) { create(:user) } + let(:deploy_key) { create(:deploy_key, public: true) } + + let!(:deploy_keys_project) do + create(:deploy_keys_project, project: project, deploy_key: deploy_key) + end + + subject(:presenter) do + described_class.new(project, current_user: user) + end + + it 'inherits from Gitlab::View::Presenter::Simple' do + expect(described_class.superclass).to eq(Gitlab::View::Presenter::Simple) + end + + describe '#enabled_keys' do + it 'returns currently enabled keys' do + expect(presenter.enabled_keys).to eq [deploy_keys_project.deploy_key] + end + + it 'does not contain enabled_keys inside available_keys' do + expect(presenter.available_keys).not_to include deploy_key + end + + it 'returns the enabled_keys size' do + expect(presenter.enabled_keys_size).to eq(1) + end + + it 'returns true if there is any enabled_keys' do + expect(presenter.any_keys_enabled?).to eq(true) + end + end + + describe '#available_keys/#available_project_keys' do + let(:other_deploy_key) { create(:another_deploy_key) } + + before do + project_key = create(:deploy_keys_project, deploy_key: other_deploy_key) + project_key.project.add_developer(user) + end + + it 'returns the current available_keys' do + expect(presenter.available_keys).not_to be_empty + end + + it 'returns the current available_project_keys' do + expect(presenter.available_project_keys).not_to be_empty + end + + it 'returns false if any available_project_keys are enabled' do + expect(presenter.any_available_project_keys_enabled?).to eq(true) + end + + it 'returns the available_project_keys size' do + expect(presenter.available_project_keys_size).to eq(1) + end + + it 'shows if there is an available key' do + expect(presenter.key_available?(deploy_key)).to eq(false) + end + end +end diff --git a/spec/requests/api/access_requests_spec.rb b/spec/requests/api/access_requests_spec.rb index 919c98d6437..46edbd49b28 100644 --- a/spec/requests/api/access_requests_spec.rb +++ b/spec/requests/api/access_requests_spec.rb @@ -200,7 +200,7 @@ describe API::AccessRequests, api: true do expect do delete api("/#{source_type.pluralize}/#{source.id}/access_requests/#{access_requester.id}", access_requester) - expect(response).to have_http_status(200) + expect(response).to have_http_status(204) end.to change { source.requesters.count }.by(-1) end end @@ -210,7 +210,7 @@ describe API::AccessRequests, api: true do expect do delete api("/#{source_type.pluralize}/#{source.id}/access_requests/#{access_requester.id}", master) - expect(response).to have_http_status(200) + expect(response).to have_http_status(204) end.to change { source.requesters.count }.by(-1) end diff --git a/spec/requests/api/api_internal_helpers_spec.rb b/spec/requests/api/api_internal_helpers_spec.rb index be4bc39ada2..f5265ea60ff 100644 --- a/spec/requests/api/api_internal_helpers_spec.rb +++ b/spec/requests/api/api_internal_helpers_spec.rb @@ -21,7 +21,7 @@ describe ::API::Helpers::InternalHelpers do # Relative and absolute storage paths, with and without trailing / ['.', './', Dir.pwd, Dir.pwd + '/'].each do |storage_path| context "storage path is #{storage_path}" do - subject { clean_project_path(project_path, [storage_path]) } + subject { clean_project_path(project_path, [{ 'path' => storage_path }]) } it { is_expected.to eq(expected) } end diff --git a/spec/requests/api/award_emoji_spec.rb b/spec/requests/api/award_emoji_spec.rb index 6cc1ef315db..f4d4a8a2cc7 100644 --- a/spec/requests/api/award_emoji_spec.rb +++ b/spec/requests/api/award_emoji_spec.rb @@ -15,7 +15,7 @@ describe API::AwardEmoji, api: true do describe "GET /projects/:id/awardable/:awardable_id/award_emoji" do context 'on an issue' do it "returns an array of award_emoji" do - get api("/projects/#{project.id}/issues/#{issue.id}/award_emoji", user) + get api("/projects/#{project.id}/issues/#{issue.iid}/award_emoji", user) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -31,7 +31,7 @@ describe API::AwardEmoji, api: true do context 'on a merge request' do it "returns an array of award_emoji" do - get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/award_emoji", user) + get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/award_emoji", user) expect(response).to have_http_status(200) expect(response).to include_pagination_headers @@ -57,7 +57,7 @@ describe API::AwardEmoji, api: true do it 'returns a status code 404' do user1 = create(:user) - get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/award_emoji", user1) + get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/award_emoji", user1) expect(response).to have_http_status(404) end @@ -68,7 +68,7 @@ describe API::AwardEmoji, api: true do let!(:rocket) { create(:award_emoji, awardable: note, name: 'rocket') } it 'returns an array of award emoji' do - get api("/projects/#{project.id}/issues/#{issue.id}/notes/#{note.id}/award_emoji", user) + get api("/projects/#{project.id}/issues/#{issue.iid}/notes/#{note.id}/award_emoji", user) expect(response).to have_http_status(200) expect(json_response).to be_an Array @@ -79,7 +79,7 @@ describe API::AwardEmoji, api: true do describe "GET /projects/:id/awardable/:awardable_id/award_emoji/:award_id" do context 'on an issue' do it "returns the award emoji" do - get api("/projects/#{project.id}/issues/#{issue.id}/award_emoji/#{award_emoji.id}", user) + get api("/projects/#{project.id}/issues/#{issue.iid}/award_emoji/#{award_emoji.id}", user) expect(response).to have_http_status(200) expect(json_response['name']).to eq(award_emoji.name) @@ -88,7 +88,7 @@ describe API::AwardEmoji, api: true do end it "returns a 404 error if the award is not found" do - get api("/projects/#{project.id}/issues/#{issue.id}/award_emoji/12345", user) + get api("/projects/#{project.id}/issues/#{issue.iid}/award_emoji/12345", user) expect(response).to have_http_status(404) end @@ -96,7 +96,7 @@ describe API::AwardEmoji, api: true do context 'on a merge request' do it 'returns the award emoji' do - get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/award_emoji/#{downvote.id}", user) + get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/award_emoji/#{downvote.id}", user) expect(response).to have_http_status(200) expect(json_response['name']).to eq(downvote.name) @@ -123,7 +123,7 @@ describe API::AwardEmoji, api: true do it 'returns a status code 404' do user1 = create(:user) - get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/award_emoji/#{downvote.id}", user1) + get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/award_emoji/#{downvote.id}", user1) expect(response).to have_http_status(404) end @@ -134,7 +134,7 @@ describe API::AwardEmoji, api: true do let!(:rocket) { create(:award_emoji, awardable: note, name: 'rocket') } it 'returns an award emoji' do - get api("/projects/#{project.id}/issues/#{issue.id}/notes/#{note.id}/award_emoji/#{rocket.id}", user) + get api("/projects/#{project.id}/issues/#{issue.iid}/notes/#{note.id}/award_emoji/#{rocket.id}", user) expect(response).to have_http_status(200) expect(json_response).not_to be_an Array @@ -147,7 +147,7 @@ describe API::AwardEmoji, api: true do context "on an issue" do it "creates a new award emoji" do - post api("/projects/#{project.id}/issues/#{issue.id}/award_emoji", user), name: 'blowfish' + post api("/projects/#{project.id}/issues/#{issue.iid}/award_emoji", user), name: 'blowfish' expect(response).to have_http_status(201) expect(json_response['name']).to eq('blowfish') @@ -155,13 +155,13 @@ describe API::AwardEmoji, api: true do end it "returns a 400 bad request error if the name is not given" do - post api("/projects/#{project.id}/issues/#{issue.id}/award_emoji", user) + post api("/projects/#{project.id}/issues/#{issue.iid}/award_emoji", user) expect(response).to have_http_status(400) end it "returns a 401 unauthorized error if the user is not authenticated" do - post api("/projects/#{project.id}/issues/#{issue.id}/award_emoji"), name: 'thumbsup' + post api("/projects/#{project.id}/issues/#{issue.iid}/award_emoji"), name: 'thumbsup' expect(response).to have_http_status(401) end @@ -173,15 +173,15 @@ describe API::AwardEmoji, api: true do end it "normalizes +1 as thumbsup award" do - post api("/projects/#{project.id}/issues/#{issue.id}/award_emoji", user), name: '+1' + post api("/projects/#{project.id}/issues/#{issue.iid}/award_emoji", user), name: '+1' expect(issue.award_emoji.last.name).to eq("thumbsup") end context 'when the emoji already has been awarded' do it 'returns a 404 status code' do - post api("/projects/#{project.id}/issues/#{issue.id}/award_emoji", user), name: 'thumbsup' - post api("/projects/#{project.id}/issues/#{issue.id}/award_emoji", user), name: 'thumbsup' + post api("/projects/#{project.id}/issues/#{issue.iid}/award_emoji", user), name: 'thumbsup' + post api("/projects/#{project.id}/issues/#{issue.iid}/award_emoji", user), name: 'thumbsup' expect(response).to have_http_status(404) expect(json_response["message"]).to match("has already been taken") @@ -207,7 +207,7 @@ describe API::AwardEmoji, api: true do it 'creates a new award emoji' do expect do - post api("/projects/#{project.id}/issues/#{issue.id}/notes/#{note.id}/award_emoji", user), name: 'rocket' + post api("/projects/#{project.id}/issues/#{issue.iid}/notes/#{note.id}/award_emoji", user), name: 'rocket' end.to change { note.award_emoji.count }.from(0).to(1) expect(response).to have_http_status(201) @@ -215,21 +215,21 @@ describe API::AwardEmoji, api: true do end it "it returns 404 error when user authored note" do - post api("/projects/#{project.id}/issues/#{issue.id}/notes/#{note2.id}/award_emoji", user), name: 'thumbsup' + post api("/projects/#{project.id}/issues/#{issue.iid}/notes/#{note2.id}/award_emoji", user), name: 'thumbsup' expect(response).to have_http_status(404) end it "normalizes +1 as thumbsup award" do - post api("/projects/#{project.id}/issues/#{issue.id}/notes/#{note.id}/award_emoji", user), name: '+1' + post api("/projects/#{project.id}/issues/#{issue.iid}/notes/#{note.id}/award_emoji", user), name: '+1' expect(note.award_emoji.last.name).to eq("thumbsup") end context 'when the emoji already has been awarded' do it 'returns a 404 status code' do - post api("/projects/#{project.id}/issues/#{issue.id}/notes/#{note.id}/award_emoji", user), name: 'rocket' - post api("/projects/#{project.id}/issues/#{issue.id}/notes/#{note.id}/award_emoji", user), name: 'rocket' + post api("/projects/#{project.id}/issues/#{issue.iid}/notes/#{note.id}/award_emoji", user), name: 'rocket' + post api("/projects/#{project.id}/issues/#{issue.iid}/notes/#{note.id}/award_emoji", user), name: 'rocket' expect(response).to have_http_status(404) expect(json_response["message"]).to match("has already been taken") @@ -241,14 +241,14 @@ describe API::AwardEmoji, api: true do context 'when the awardable is an Issue' do it 'deletes the award' do expect do - delete api("/projects/#{project.id}/issues/#{issue.id}/award_emoji/#{award_emoji.id}", user) - end.to change { issue.award_emoji.count }.from(1).to(0) + delete api("/projects/#{project.id}/issues/#{issue.iid}/award_emoji/#{award_emoji.id}", user) - expect(response).to have_http_status(200) + expect(response).to have_http_status(204) + end.to change { issue.award_emoji.count }.from(1).to(0) end it 'returns a 404 error when the award emoji can not be found' do - delete api("/projects/#{project.id}/issues/#{issue.id}/award_emoji/12345", user) + delete api("/projects/#{project.id}/issues/#{issue.iid}/award_emoji/12345", user) expect(response).to have_http_status(404) end @@ -257,14 +257,14 @@ describe API::AwardEmoji, api: true do context 'when the awardable is a Merge Request' do it 'deletes the award' do expect do - delete api("/projects/#{project.id}/merge_requests/#{merge_request.id}/award_emoji/#{downvote.id}", user) - end.to change { merge_request.award_emoji.count }.from(1).to(0) + delete api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/award_emoji/#{downvote.id}", user) - expect(response).to have_http_status(200) + expect(response).to have_http_status(204) + end.to change { merge_request.award_emoji.count }.from(1).to(0) end it 'returns a 404 error when note id not found' do - delete api("/projects/#{project.id}/merge_requests/#{merge_request.id}/notes/12345", user) + delete api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/notes/12345", user) expect(response).to have_http_status(404) end @@ -277,9 +277,9 @@ describe API::AwardEmoji, api: true do it 'deletes the award' do expect do delete api("/projects/#{project.id}/snippets/#{snippet.id}/award_emoji/#{award.id}", user) - end.to change { snippet.award_emoji.count }.from(1).to(0) - expect(response).to have_http_status(200) + expect(response).to have_http_status(204) + end.to change { snippet.award_emoji.count }.from(1).to(0) end end end @@ -289,10 +289,10 @@ describe API::AwardEmoji, api: true do it 'deletes the award' do expect do - delete api("/projects/#{project.id}/issues/#{issue.id}/notes/#{note.id}/award_emoji/#{rocket.id}", user) - end.to change { note.award_emoji.count }.from(1).to(0) + delete api("/projects/#{project.id}/issues/#{issue.iid}/notes/#{note.id}/award_emoji/#{rocket.id}", user) - expect(response).to have_http_status(200) + expect(response).to have_http_status(204) + end.to change { note.award_emoji.count }.from(1).to(0) end end end diff --git a/spec/requests/api/boards_spec.rb b/spec/requests/api/boards_spec.rb index 71df534ebe1..87c36639cd4 100644 --- a/spec/requests/api/boards_spec.rb +++ b/spec/requests/api/boards_spec.rb @@ -195,8 +195,7 @@ describe API::Boards, api: true do it "deletes the list if an admin requests it" do delete api("#{base_url}/#{dev_list.id}", owner) - expect(response).to have_http_status(200) - expect(json_response['position']).to eq(1) + expect(response).to have_http_status(204) end end end diff --git a/spec/requests/api/branches_spec.rb b/spec/requests/api/branches_spec.rb index 5571f6cc107..a70f7beaae0 100644 --- a/spec/requests/api/branches_spec.rb +++ b/spec/requests/api/branches_spec.rb @@ -5,77 +5,146 @@ describe API::Branches, api: true do include ApiHelpers let(:user) { create(:user) } - let(:user2) { create(:user) } let!(:project) { create(:project, :repository, creator: user) } let!(:master) { create(:project_member, :master, user: user, project: project) } - let!(:guest) { create(:project_member, :guest, user: user2, project: project) } + let(:guest) { create(:user).tap { |u| create(:project_member, :guest, user: u, project: project) } } let!(:branch_name) { 'feature' } let!(:branch_sha) { '0b4bc9a49b562e85de7cc9e834518ea6828729b9' } - let!(:branch_with_dot) { CreateBranchService.new(project, user).execute("with.1.2.3", "master") } + let(:branch_with_dot) { CreateBranchService.new(project, user).execute("with.1.2.3", "master")[:branch] } describe "GET /projects/:id/repository/branches" do - it "returns an array of project branches" do - project.repository.expire_all_method_caches + let(:route) { "/projects/#{project.id}/repository/branches" } - get api("/projects/#{project.id}/repository/branches", user), per_page: 100 + shared_examples_for 'repository branches' do + it 'returns the repository branches' do + get api(route, current_user), per_page: 100 - expect(response).to have_http_status(200) - expect(response).to include_pagination_headers - expect(json_response).to be_an Array - branch_names = json_response.map { |x| x['name'] } - expect(branch_names).to match_array(project.repository.branch_names) + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + branch_names = json_response.map { |x| x['name'] } + expect(branch_names).to match_array(project.repository.branch_names) + end + + context 'when repository is disabled' do + include_context 'disabled repository' + + it_behaves_like '403 response' do + let(:request) { get api(route, current_user) } + end + end end - end - describe "GET /projects/:id/repository/branches/:branch" do - it "returns the branch information for a single branch" do - get api("/projects/#{project.id}/repository/branches/#{branch_name}", user) - expect(response).to have_http_status(200) + context 'when unauthenticated', 'and project is public' do + it_behaves_like 'repository branches' do + let(:project) { create(:project, :public, :repository) } + let(:current_user) { nil } + end + end - expect(json_response['name']).to eq(branch_name) - json_commit = json_response['commit'] - expect(json_commit['id']).to eq(branch_sha) - expect(json_commit).to have_key('short_id') - expect(json_commit).to have_key('title') - expect(json_commit).to have_key('message') - expect(json_commit).to have_key('author_name') - expect(json_commit).to have_key('author_email') - expect(json_commit).to have_key('authored_date') - expect(json_commit).to have_key('committer_name') - expect(json_commit).to have_key('committer_email') - expect(json_commit).to have_key('committed_date') - expect(json_commit).to have_key('parent_ids') - expect(json_response['merged']).to eq(false) - expect(json_response['protected']).to eq(false) - expect(json_response['developers_can_push']).to eq(false) - expect(json_response['developers_can_merge']).to eq(false) + context 'when unauthenticated', 'and project is private' do + it_behaves_like '404 response' do + let(:request) { get api(route) } + let(:message) { '404 Project Not Found' } + end end - it "returns the branch information for a single branch with dots in the name" do - get api("/projects/#{project.id}/repository/branches/with.1.2.3", user) + context 'when authenticated', 'as a developer' do + it_behaves_like 'repository branches' do + let(:current_user) { user } + end + end - expect(response).to have_http_status(200) - expect(json_response['name']).to eq("with.1.2.3") + context 'when authenticated', 'as a guest' do + it_behaves_like '403 response' do + let(:request) { get api(route, guest) } + end end + end + + describe "GET /projects/:id/repository/branches/:branch" do + let(:route) { "/projects/#{project.id}/repository/branches/#{branch_name}" } - context 'on a merged branch' do - it "returns the branch information for a single branch" do - get api("/projects/#{project.id}/repository/branches/merge-test", user) + shared_examples_for 'repository branch' do |merged: false| + it 'returns the repository branch' do + get api(route, current_user) expect(response).to have_http_status(200) - expect(json_response['name']).to eq('merge-test') - expect(json_response['merged']).to eq(true) + expect(json_response['name']).to eq(branch_name) + expect(json_response['merged']).to eq(merged) + expect(json_response['protected']).to eq(false) + expect(json_response['developers_can_push']).to eq(false) + expect(json_response['developers_can_merge']).to eq(false) + + json_commit = json_response['commit'] + expect(json_commit['id']).to eq(branch_sha) + expect(json_commit).to have_key('short_id') + expect(json_commit).to have_key('title') + expect(json_commit).to have_key('message') + expect(json_commit).to have_key('author_name') + expect(json_commit).to have_key('author_email') + expect(json_commit).to have_key('authored_date') + expect(json_commit).to have_key('committer_name') + expect(json_commit).to have_key('committer_email') + expect(json_commit).to have_key('committed_date') + expect(json_commit).to have_key('parent_ids') + end + + context 'when branch does not exist' do + let(:branch_name) { 'unknown' } + + it_behaves_like '404 response' do + let(:request) { get api(route, current_user) } + let(:message) { '404 Branch Not Found' } + end + end + + context 'when repository is disabled' do + include_context 'disabled repository' + + it_behaves_like '403 response' do + let(:request) { get api(route, current_user) } + end end end - it "returns a 403 error if guest" do - get api("/projects/#{project.id}/repository/branches", user2) - expect(response).to have_http_status(403) + context 'when unauthenticated', 'and project is public' do + it_behaves_like 'repository branch' do + let(:project) { create(:project, :public, :repository) } + let(:current_user) { nil } + end end - it "returns a 404 error if branch is not available" do - get api("/projects/#{project.id}/repository/branches/unknown", user) - expect(response).to have_http_status(404) + context 'when unauthenticated', 'and project is private' do + it_behaves_like '404 response' do + let(:request) { get api(route) } + let(:message) { '404 Project Not Found' } + end + end + + context 'when authenticated', 'as a developer' do + let(:current_user) { user } + it_behaves_like 'repository branch' + + context 'when branch contains a dot' do + let(:branch_name) { branch_with_dot.name } + let(:branch_sha) { project.commit('master').sha } + + it_behaves_like 'repository branch' + end + + context 'when branch is merged' do + let(:branch_name) { 'merge-test' } + let(:branch_sha) { project.commit('merge-test').sha } + + it_behaves_like 'repository branch', merged: true + end + end + + context 'when authenticated', 'as a guest' do + it_behaves_like '403 response' do + let(:request) { get api(route, guest) } + end end end @@ -93,10 +162,10 @@ describe API::Branches, api: true do end it "protects a single branch with dots in the name" do - put api("/projects/#{project.id}/repository/branches/with.1.2.3/protect", user) + put api("/projects/#{project.id}/repository/branches/#{branch_with_dot.name}/protect", user) expect(response).to have_http_status(200) - expect(json_response['name']).to eq("with.1.2.3") + expect(json_response['name']).to eq(branch_with_dot.name) expect(json_response['protected']).to eq(true) end @@ -234,7 +303,7 @@ describe API::Branches, api: true do end it "returns a 403 error if guest" do - put api("/projects/#{project.id}/repository/branches/#{branch_name}/protect", user2) + put api("/projects/#{project.id}/repository/branches/#{branch_name}/protect", guest) expect(response).to have_http_status(403) end end @@ -250,10 +319,10 @@ describe API::Branches, api: true do end it "update branches with dots in branch name" do - put api("/projects/#{project.id}/repository/branches/with.1.2.3/unprotect", user) + put api("/projects/#{project.id}/repository/branches/#{branch_with_dot.name}/unprotect", user) expect(response).to have_http_status(200) - expect(json_response['name']).to eq("with.1.2.3") + expect(json_response['name']).to eq(branch_with_dot.name) expect(json_response['protected']).to eq(false) end @@ -282,7 +351,7 @@ describe API::Branches, api: true do end it "denies for user without push access" do - post api("/projects/#{project.id}/repository/branches", user2), + post api("/projects/#{project.id}/repository/branches", guest), branch: branch_name, ref: branch_sha expect(response).to have_http_status(403) @@ -325,15 +394,14 @@ describe API::Branches, api: true do it "removes branch" do delete api("/projects/#{project.id}/repository/branches/#{branch_name}", user) - expect(response).to have_http_status(200) - expect(json_response['branch']).to eq(branch_name) + + expect(response).to have_http_status(204) end it "removes a branch with dots in the branch name" do - delete api("/projects/#{project.id}/repository/branches/with.1.2.3", user) + delete api("/projects/#{project.id}/repository/branches/#{branch_with_dot.name}", user) - expect(response).to have_http_status(200) - expect(json_response['branch']).to eq("with.1.2.3") + expect(response).to have_http_status(204) end it 'returns 404 if branch not exists' do @@ -360,13 +428,15 @@ describe API::Branches, api: true do allow_any_instance_of(Repository).to receive(:rm_branch).and_return(true) end - it 'returns 200' do + it 'returns 202 with json body' do delete api("/projects/#{project.id}/repository/merged_branches", user) - expect(response).to have_http_status(200) + + expect(response).to have_http_status(202) + expect(json_response['message']).to eql('202 Accepted') end it 'returns a 403 error if guest' do - delete api("/projects/#{project.id}/repository/merged_branches", user2) + delete api("/projects/#{project.id}/repository/merged_branches", guest) expect(response).to have_http_status(403) end end diff --git a/spec/requests/api/broadcast_messages_spec.rb b/spec/requests/api/broadcast_messages_spec.rb index 921d8714173..024fa66848c 100644 --- a/spec/requests/api/broadcast_messages_spec.rb +++ b/spec/requests/api/broadcast_messages_spec.rb @@ -174,8 +174,11 @@ describe API::BroadcastMessages, api: true do end it 'deletes the broadcast message for admins' do - expect { delete api("/broadcast_messages/#{message.id}", admin) } - .to change { BroadcastMessage.count }.by(-1) + expect do + delete api("/broadcast_messages/#{message.id}", admin) + + expect(response).to have_http_status(204) + end.to change { BroadcastMessage.count }.by(-1) end end end diff --git a/spec/requests/api/commit_statuses_spec.rb b/spec/requests/api/commit_statuses_spec.rb index 81a8856b8f1..d8b3cc041a5 100644 --- a/spec/requests/api/commit_statuses_spec.rb +++ b/spec/requests/api/commit_statuses_spec.rb @@ -151,26 +151,62 @@ describe API::CommitStatuses, api: true do end context 'with all optional parameters' do - before do - optional_params = { state: 'success', - context: 'coverage', - ref: 'develop', - description: 'test', - coverage: 80.0, - target_url: 'http://gitlab.com/status' } - - post api(post_url, developer), optional_params + context 'when creating a commit status' do + it 'creates commit status' do + post api(post_url, developer), { + state: 'success', + context: 'coverage', + ref: 'develop', + description: 'test', + coverage: 80.0, + target_url: 'http://gitlab.com/status' + } + + expect(response).to have_http_status(201) + expect(json_response['sha']).to eq(commit.id) + expect(json_response['status']).to eq('success') + expect(json_response['name']).to eq('coverage') + expect(json_response['ref']).to eq('develop') + expect(json_response['coverage']).to eq(80.0) + expect(json_response['description']).to eq('test') + expect(json_response['target_url']).to eq('http://gitlab.com/status') + end end - it 'creates commit status' do - expect(response).to have_http_status(201) - expect(json_response['sha']).to eq(commit.id) - expect(json_response['status']).to eq('success') - expect(json_response['name']).to eq('coverage') - expect(json_response['ref']).to eq('develop') - expect(json_response['coverage']).to eq(80.0) - expect(json_response['description']).to eq('test') - expect(json_response['target_url']).to eq('http://gitlab.com/status') + context 'when updatig a commit status' do + before do + post api(post_url, developer), { + state: 'running', + context: 'coverage', + ref: 'develop', + description: 'coverage test', + coverage: 0.0, + target_url: 'http://gitlab.com/status' + } + + post api(post_url, developer), { + state: 'success', + name: 'coverage', + ref: 'develop', + description: 'new description', + coverage: 90.0 + } + end + + it 'updates a commit status' do + expect(response).to have_http_status(201) + expect(json_response['sha']).to eq(commit.id) + expect(json_response['status']).to eq('success') + expect(json_response['name']).to eq('coverage') + expect(json_response['ref']).to eq('develop') + expect(json_response['coverage']).to eq(90.0) + expect(json_response['description']).to eq('new description') + expect(json_response['target_url']).to eq('http://gitlab.com/status') + end + + it 'does not create a new commit status' do + expect(CommitStatus.count).to eq 1 + end end end diff --git a/spec/requests/api/commits_spec.rb b/spec/requests/api/commits_spec.rb index 8b3dfedc5a9..a10d876ffad 100644 --- a/spec/requests/api/commits_spec.rb +++ b/spec/requests/api/commits_spec.rb @@ -19,6 +19,7 @@ describe API::Commits, api: true do it "returns project commits" do commit = project.repository.commit + get api("/projects/#{project.id}/repository/commits", user) expect(response).to have_http_status(200) @@ -27,6 +28,16 @@ describe API::Commits, api: true do expect(json_response.first['committer_name']).to eq(commit.committer_name) expect(json_response.first['committer_email']).to eq(commit.committer_email) end + + it 'include correct pagination headers' do + commit_count = project.repository.count_commits(ref: 'master').to_s + + get api("/projects/#{project.id}/repository/commits", user) + + expect(response).to include_pagination_headers + expect(response.headers['X-Total']).to eq(commit_count) + expect(response.headers['X-Page']).to eql('1') + end end context "unauthorized user" do @@ -39,14 +50,26 @@ describe API::Commits, api: true do context "since optional parameter" do it "returns project commits since provided parameter" do commits = project.repository.commits("master") - since = commits.second.created_at + after = commits.second.created_at - get api("/projects/#{project.id}/repository/commits?since=#{since.utc.iso8601}", user) + get api("/projects/#{project.id}/repository/commits?since=#{after.utc.iso8601}", user) expect(json_response.size).to eq 2 expect(json_response.first["id"]).to eq(commits.first.id) expect(json_response.second["id"]).to eq(commits.second.id) end + + it 'include correct pagination headers' do + commits = project.repository.commits("master") + after = commits.second.created_at + commit_count = project.repository.count_commits(ref: 'master', after: after).to_s + + get api("/projects/#{project.id}/repository/commits?since=#{after.utc.iso8601}", user) + + expect(response).to include_pagination_headers + expect(response.headers['X-Total']).to eq(commit_count) + expect(response.headers['X-Page']).to eql('1') + end end context "until optional parameter" do @@ -65,6 +88,18 @@ describe API::Commits, api: true do expect(json_response.first["id"]).to eq(commits.second.id) expect(json_response.second["id"]).to eq(commits.third.id) end + + it 'include correct pagination headers' do + commits = project.repository.commits("master") + before = commits.second.created_at + commit_count = project.repository.count_commits(ref: 'master', before: before).to_s + + get api("/projects/#{project.id}/repository/commits?until=#{before.utc.iso8601}", user) + + expect(response).to include_pagination_headers + expect(response.headers['X-Total']).to eq(commit_count) + expect(response.headers['X-Page']).to eql('1') + end end context "invalid xmlschema date parameters" do @@ -79,16 +114,71 @@ describe API::Commits, api: true do context "path optional parameter" do it "returns project commits matching provided path parameter" do path = 'files/ruby/popen.rb' + commit_count = project.repository.count_commits(ref: 'master', path: path).to_s get api("/projects/#{project.id}/repository/commits?path=#{path}", user) expect(json_response.size).to eq(3) expect(json_response.first["id"]).to eq("570e7b2abdd848b95f2f578043fc23bd6f6fd24d") + expect(response).to include_pagination_headers + expect(response.headers['X-Total']).to eq(commit_count) + end + + it 'include correct pagination headers' do + path = 'files/ruby/popen.rb' + commit_count = project.repository.count_commits(ref: 'master', path: path).to_s + + get api("/projects/#{project.id}/repository/commits?path=#{path}", user) + + expect(response).to include_pagination_headers + expect(response.headers['X-Total']).to eq(commit_count) + expect(response.headers['X-Page']).to eql('1') + end + end + + context 'with pagination params' do + let(:page) { 1 } + let(:per_page) { 5 } + let(:ref_name) { 'master' } + let!(:request) do + get api("/projects/#{project.id}/repository/commits?page=#{page}&per_page=#{per_page}&ref_name=#{ref_name}", user) + end + + it 'returns correct headers' do + commit_count = project.repository.count_commits(ref: ref_name).to_s + + expect(response).to include_pagination_headers + expect(response.headers['X-Total']).to eq(commit_count) + expect(response.headers['X-Page']).to eq('1') + expect(response.headers['Link']).to match(/page=1&per_page=5/) + expect(response.headers['Link']).to match(/page=2&per_page=5/) + end + + context 'viewing the first page' do + it 'returns the first 5 commits' do + commit = project.repository.commit + + expect(json_response.size).to eq(per_page) + expect(json_response.first['id']).to eq(commit.id) + expect(response.headers['X-Page']).to eq('1') + end + end + + context 'viewing the third page' do + let(:page) { 3 } + + it 'returns the third 5 commits' do + commit = project.repository.commits('HEAD', offset: (page - 1) * per_page).first + + expect(json_response.size).to eq(per_page) + expect(json_response.first['id']).to eq(commit.id) + expect(response.headers['X-Page']).to eq('3') + end end end end - describe "Create a commit with multiple files and actions" do + describe "POST /projects/:id/repository/commits" do let!(:url) { "/projects/#{project.id}/repository/commits" } it 'returns a 403 unauthorized for user without permissions' do @@ -103,7 +193,7 @@ describe API::Commits, api: true do expect(response).to have_http_status(400) end - context :create do + describe 'create' do let(:message) { 'Created file' } let!(:invalid_c_params) do { @@ -147,8 +237,8 @@ describe API::Commits, api: true do expect(response).to have_http_status(400) end - context 'with project path in URL' do - let(:url) { "/projects/#{project.namespace.path}%2F#{project.path}/repository/commits" } + context 'with project path containing a dot in URL' do + let(:url) { "/projects/#{CGI.escape(project.full_path)}/repository/commits" } it 'a new file in project repo' do post api(url, user), valid_c_params @@ -158,7 +248,7 @@ describe API::Commits, api: true do end end - context :delete do + describe 'delete' do let(:message) { 'Deleted file' } let!(:invalid_d_params) do { @@ -199,7 +289,7 @@ describe API::Commits, api: true do end end - context :move do + describe 'move' do let(:message) { 'Moved file' } let!(:invalid_m_params) do { @@ -244,7 +334,7 @@ describe API::Commits, api: true do end end - context :update do + describe 'update' do let(:message) { 'Updated file' } let!(:invalid_u_params) do { @@ -287,7 +377,7 @@ describe API::Commits, api: true do end end - context "multiple operations" do + describe 'multiple operations' do let(:message) { 'Multiple actions' } let!(:invalid_mo_params) do { diff --git a/spec/requests/api/deploy_keys_spec.rb b/spec/requests/api/deploy_keys_spec.rb index 7e682e91bd1..4f4b18cf0e0 100644 --- a/spec/requests/api/deploy_keys_spec.rb +++ b/spec/requests/api/deploy_keys_spec.rb @@ -116,6 +116,8 @@ describe API::DeployKeys, api: true do it 'should delete existing key' do expect do delete api("/projects/#{project.id}/deploy_keys/#{deploy_key.id}", admin) + + expect(response).to have_http_status(204) end.to change{ project.deploy_keys.count }.by(-1) end diff --git a/spec/requests/api/doorkeeper_access_spec.rb b/spec/requests/api/doorkeeper_access_spec.rb index bd9ecaf2685..f6fd567eca5 100644 --- a/spec/requests/api/doorkeeper_access_spec.rb +++ b/spec/requests/api/doorkeeper_access_spec.rb @@ -1,17 +1,23 @@ require 'spec_helper' -describe API::API, api: true do +describe API::API, api: true do include ApiHelpers let!(:user) { create(:user) } let!(:application) { Doorkeeper::Application.create!(name: "MyApp", redirect_uri: "https://app.com", owner: user) } let!(:token) { Doorkeeper::AccessToken.create! application_id: application.id, resource_owner_id: user.id, scopes: "api" } - describe "when unauthenticated" do + describe "unauthenticated" do it "returns authentication success" do get api("/user"), access_token: token.token expect(response).to have_http_status(200) end + + include_examples 'user login request with unique ip limit' do + def request + get api('/user'), access_token: token.token + end + end end describe "when token invalid" do @@ -26,5 +32,29 @@ describe API::API, api: true do get api("/user", user) expect(response).to have_http_status(200) end + + include_examples 'user login request with unique ip limit' do + def request + get api('/user', user) + end + end + end + + describe "when user is blocked" do + it "returns authentication error" do + user.block + get api("/user"), access_token: token.token + + expect(response).to have_http_status(401) + end + end + + describe "when user is ldap_blocked" do + it "returns authentication error" do + user.ldap_block + get api("/user"), access_token: token.token + + expect(response).to have_http_status(401) + end end end diff --git a/spec/requests/api/environments_spec.rb b/spec/requests/api/environments_spec.rb index d0958d39d44..b54ee8e8b85 100644 --- a/spec/requests/api/environments_spec.rb +++ b/spec/requests/api/environments_spec.rb @@ -15,6 +15,8 @@ describe API::Environments, api: true do describe 'GET /projects/:id/environments' do context 'as member of the project' do it 'returns project environments' do + project_data_keys = %w(id http_url_to_repo web_url name name_with_namespace path path_with_namespace) + get api("/projects/#{project.id}/environments", user) expect(response).to have_http_status(200) @@ -23,7 +25,7 @@ describe API::Environments, api: true do expect(json_response.size).to eq(1) expect(json_response.first['name']).to eq(environment.name) expect(json_response.first['external_url']).to eq(environment.external_url) - expect(json_response.first['project']['id']).to eq(project.id) + expect(json_response.first['project'].keys).to contain_exactly(*project_data_keys) end end @@ -122,7 +124,7 @@ describe API::Environments, api: true do it 'returns a 200 for an existing environment' do delete api("/projects/#{project.id}/environments/#{environment.id}", user) - expect(response).to have_http_status(200) + expect(response).to have_http_status(204) end it 'returns a 404 for non existing id' do @@ -141,4 +143,39 @@ describe API::Environments, api: true do end end end + + describe 'POST /projects/:id/environments/:environment_id/stop' do + context 'as a master' do + context 'with a stoppable environment' do + before do + environment.update(state: :available) + + post api("/projects/#{project.id}/environments/#{environment.id}/stop", user) + end + + it 'returns a 200' do + expect(response).to have_http_status(200) + end + + it 'actually stops the environment' do + expect(environment.reload).to be_stopped + end + end + + it 'returns a 404 for non existing id' do + post api("/projects/#{project.id}/environments/12345/stop", user) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 Not found') + end + end + + context 'a non member' do + it 'rejects the request' do + post api("/projects/#{project.id}/environments/#{environment.id}/stop", non_member) + + expect(response).to have_http_status(404) + end + end + end end diff --git a/spec/requests/api/files_spec.rb b/spec/requests/api/files_spec.rb index a8ce0430401..a7fad7f0bdb 100644 --- a/spec/requests/api/files_spec.rb +++ b/spec/requests/api/files_spec.rb @@ -5,10 +5,9 @@ describe API::Files, api: true do let(:user) { create(:user) } let!(:project) { create(:project, :repository, namespace: user.namespace ) } let(:guest) { create(:user) { |u| project.add_guest(u) } } - let(:file_path) { 'files/ruby/popen.rb' } + let(:file_path) { "files%2Fruby%2Fpopen%2Erb" } let(:params) do { - file_path: file_path, ref: 'master' } end @@ -30,36 +29,54 @@ describe API::Files, api: true do before { project.team << [user, :developer] } - describe "GET /projects/:id/repository/files" do - let(:route) { "/projects/#{project.id}/repository/files" } + def route(file_path = nil) + "/projects/#{project.id}/repository/files/#{file_path}" + end + describe "GET /projects/:id/repository/files/:file_path" do shared_examples_for 'repository files' do - it "returns file info" do - get api(route, current_user), params + it 'returns file attributes as json' do + get api(route(file_path), current_user), params expect(response).to have_http_status(200) - expect(json_response['file_path']).to eq(file_path) + expect(json_response['file_path']).to eq(CGI.unescape(file_path)) expect(json_response['file_name']).to eq('popen.rb') expect(json_response['last_commit_id']).to eq('570e7b2abdd848b95f2f578043fc23bd6f6fd24d') expect(Base64.decode64(json_response['content']).lines.first).to eq("require 'fileutils'\n") end - context 'when no params are given' do + it 'returns file by commit sha' do + # This file is deleted on HEAD + file_path = "files%2Fjs%2Fcommit%2Ejs%2Ecoffee" + params[:ref] = "6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9" + + get api(route(file_path), current_user), params + + expect(response).to have_http_status(200) + expect(json_response['file_name']).to eq('commit.js.coffee') + expect(Base64.decode64(json_response['content']).lines.first).to eq("class Commit\n") + end + + it 'returns raw file info' do + url = route(file_path) + "/raw" + expect(Gitlab::Workhorse).to receive(:send_git_blob) + + get api(url, current_user), params + + expect(response).to have_http_status(200) + end + + context 'when mandatory params are not given' do it_behaves_like '400 response' do - let(:request) { get api(route, current_user) } + let(:request) { get api(route("any%2Ffile"), current_user) } end end context 'when file_path does not exist' do - let(:params) do - { - file_path: 'app/models/application.rb', - ref: 'master', - } - end + let(:params) { { ref: 'master' } } it_behaves_like '404 response' do - let(:request) { get api(route, current_user), params } + let(:request) { get api(route('app%2Fmodels%2Fapplication%2Erb'), current_user), params } let(:message) { '404 File Not Found' } end end @@ -68,7 +85,7 @@ describe API::Files, api: true do include_context 'disabled repository' it_behaves_like '403 response' do - let(:request) { get api(route, current_user), params } + let(:request) { get api(route(file_path), current_user), params } end end end @@ -82,7 +99,7 @@ describe API::Files, api: true do context 'when unauthenticated', 'and project is private' do it_behaves_like '404 response' do - let(:request) { get api(route), params } + let(:request) { get api(route(file_path)), params } let(:message) { '404 Project Not Found' } end end @@ -95,42 +112,115 @@ describe API::Files, api: true do context 'when authenticated', 'as a guest' do it_behaves_like '403 response' do - let(:request) { get api(route, guest), params } + let(:request) { get api(route(file_path), guest), params } + end + end + end + + describe "GET /projects/:id/repository/files/:file_path/raw" do + shared_examples_for 'repository raw files' do + it 'returns raw file info' do + url = route(file_path) + "/raw" + expect(Gitlab::Workhorse).to receive(:send_git_blob) + + get api(url, current_user), params + + expect(response).to have_http_status(200) + end + + it 'returns file by commit sha' do + # This file is deleted on HEAD + file_path = "files%2Fjs%2Fcommit%2Ejs%2Ecoffee" + params[:ref] = "6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9" + expect(Gitlab::Workhorse).to receive(:send_git_blob) + + get api(route(file_path) + "/raw", current_user), params + + expect(response).to have_http_status(200) + end + + context 'when mandatory params are not given' do + it_behaves_like '400 response' do + let(:request) { get api(route("any%2Ffile"), current_user) } + end + end + + context 'when file_path does not exist' do + let(:params) { { ref: 'master' } } + + it_behaves_like '404 response' do + let(:request) { get api(route('app%2Fmodels%2Fapplication%2Erb'), current_user), params } + let(:message) { '404 File Not Found' } + end + end + + context 'when repository is disabled' do + include_context 'disabled repository' + + it_behaves_like '403 response' do + let(:request) { get api(route(file_path), current_user), params } + end + end + end + + context 'when unauthenticated', 'and project is public' do + it_behaves_like 'repository raw files' do + let(:project) { create(:project, :public) } + let(:current_user) { nil } + end + end + + context 'when unauthenticated', 'and project is private' do + it_behaves_like '404 response' do + let(:request) { get api(route(file_path)), params } + let(:message) { '404 Project Not Found' } + end + end + + context 'when authenticated', 'as a developer' do + it_behaves_like 'repository raw files' do + let(:current_user) { user } + end + end + + context 'when authenticated', 'as a guest' do + it_behaves_like '403 response' do + let(:request) { get api(route(file_path), guest), params } end end end - describe "POST /projects/:id/repository/files" do + describe "POST /projects/:id/repository/files/:file_path" do + let!(:file_path) { "new_subfolder%2Fnewfile%2Erb" } let(:valid_params) do { - file_path: 'newfile.rb', - branch: 'master', - content: 'puts 8', - commit_message: 'Added newfile' + branch: "master", + content: "puts 8", + commit_message: "Added newfile" } end it "creates a new file in project repo" do - post api("/projects/#{project.id}/repository/files", user), valid_params + post api(route(file_path), user), valid_params expect(response).to have_http_status(201) - expect(json_response['file_path']).to eq('newfile.rb') + expect(json_response["file_path"]).to eq(CGI.unescape(file_path)) last_commit = project.repository.commit.raw expect(last_commit.author_email).to eq(user.email) expect(last_commit.author_name).to eq(user.name) end - it "returns a 400 bad request if no params given" do - post api("/projects/#{project.id}/repository/files", user) + it "returns a 400 bad request if no mandatory params given" do + post api(route("any%2Etxt"), user) expect(response).to have_http_status(400) end it "returns a 400 if editor fails to create file" do - allow_any_instance_of(Repository).to receive(:commit_file). + allow_any_instance_of(Repository).to receive(:create_file). and_return(false) - post api("/projects/#{project.id}/repository/files", user), valid_params + post api(route("any%2Etxt"), user), valid_params expect(response).to have_http_status(400) end @@ -139,7 +229,7 @@ describe API::Files, api: true do it "creates a new file with the specified author" do valid_params.merge!(author_email: author_email, author_name: author_name) - post api("/projects/#{project.id}/repository/files", user), valid_params + post api(route("new_file_with_author%2Etxt"), user), valid_params expect(response).to have_http_status(201) last_commit = project.repository.commit.raw @@ -147,12 +237,25 @@ describe API::Files, api: true do expect(last_commit.author_name).to eq(author_name) end end + + context 'when the repo is empty' do + let!(:project) { create(:project_empty_repo, namespace: user.namespace ) } + + it "creates a new file in project repo" do + post api(route("newfile%2Erb"), user), valid_params + + expect(response).to have_http_status(201) + expect(json_response['file_path']).to eq('newfile.rb') + last_commit = project.repository.commit.raw + expect(last_commit.author_email).to eq(user.email) + expect(last_commit.author_name).to eq(user.name) + end + end end describe "PUT /projects/:id/repository/files" do let(:valid_params) do { - file_path: file_path, branch: 'master', content: 'puts 8', commit_message: 'Changed file' @@ -160,17 +263,17 @@ describe API::Files, api: true do end it "updates existing file in project repo" do - put api("/projects/#{project.id}/repository/files", user), valid_params + put api(route(file_path), user), valid_params expect(response).to have_http_status(200) - expect(json_response['file_path']).to eq(file_path) + expect(json_response['file_path']).to eq(CGI.unescape(file_path)) last_commit = project.repository.commit.raw expect(last_commit.author_email).to eq(user.email) expect(last_commit.author_name).to eq(user.name) end it "returns a 400 bad request if no params given" do - put api("/projects/#{project.id}/repository/files", user) + put api(route(file_path), user) expect(response).to have_http_status(400) end @@ -179,7 +282,7 @@ describe API::Files, api: true do it "updates a file with the specified author" do valid_params.merge!(author_email: author_email, author_name: author_name, content: "New content") - put api("/projects/#{project.id}/repository/files", user), valid_params + put api(route(file_path), user), valid_params expect(response).to have_http_status(200) last_commit = project.repository.commit.raw @@ -192,32 +295,27 @@ describe API::Files, api: true do describe "DELETE /projects/:id/repository/files" do let(:valid_params) do { - file_path: file_path, branch: 'master', commit_message: 'Changed file' } end it "deletes existing file in project repo" do - delete api("/projects/#{project.id}/repository/files", user), valid_params + delete api(route(file_path), user), valid_params - expect(response).to have_http_status(200) - expect(json_response['file_path']).to eq(file_path) - last_commit = project.repository.commit.raw - expect(last_commit.author_email).to eq(user.email) - expect(last_commit.author_name).to eq(user.name) + expect(response).to have_http_status(204) end it "returns a 400 bad request if no params given" do - delete api("/projects/#{project.id}/repository/files", user) + delete api(route(file_path), user) expect(response).to have_http_status(400) end it "returns a 400 if fails to create file" do - allow_any_instance_of(Repository).to receive(:remove_file).and_return(false) + allow_any_instance_of(Repository).to receive(:delete_file).and_return(false) - delete api("/projects/#{project.id}/repository/files", user), valid_params + delete api(route(file_path), user), valid_params expect(response).to have_http_status(400) end @@ -226,21 +324,17 @@ describe API::Files, api: true do it "removes a file with the specified author" do valid_params.merge!(author_email: author_email, author_name: author_name) - delete api("/projects/#{project.id}/repository/files", user), valid_params + delete api(route(file_path), user), valid_params - expect(response).to have_http_status(200) - last_commit = project.repository.commit.raw - expect(last_commit.author_email).to eq(author_email) - expect(last_commit.author_name).to eq(author_name) + expect(response).to have_http_status(204) end end end describe "POST /projects/:id/repository/files with binary file" do - let(:file_path) { 'test.bin' } + let(:file_path) { 'test%2Ebin' } let(:put_params) do { - file_path: file_path, branch: 'master', content: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAAACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII=', commit_message: 'Binary file with a \n should not be touched', @@ -249,21 +343,20 @@ describe API::Files, api: true do end let(:get_params) do { - file_path: file_path, ref: 'master', } end before do - post api("/projects/#{project.id}/repository/files", user), put_params + post api(route(file_path), user), put_params end it "remains unchanged" do - get api("/projects/#{project.id}/repository/files", user), get_params + get api(route(file_path), user), get_params expect(response).to have_http_status(200) - expect(json_response['file_path']).to eq(file_path) - expect(json_response['file_name']).to eq(file_path) + expect(json_response['file_path']).to eq(CGI.unescape(file_path)) + expect(json_response['file_name']).to eq(CGI.unescape(file_path)) expect(json_response['content']).to eq(put_params[:content]) end end diff --git a/spec/requests/api/groups_spec.rb b/spec/requests/api/groups_spec.rb index a59112579e5..2545da7b1db 100644 --- a/spec/requests/api/groups_spec.rb +++ b/spec/requests/api/groups_spec.rb @@ -76,6 +76,8 @@ describe API::Groups, api: true do lfs_objects_size: 234, build_artifacts_size: 345, }.stringify_keys + exposed_attributes = attributes.dup + exposed_attributes['job_artifacts_size'] = exposed_attributes.delete('build_artifacts_size') project1.statistics.update!(attributes) @@ -85,7 +87,7 @@ describe API::Groups, api: true do expect(response).to include_pagination_headers expect(json_response).to be_an Array expect(json_response) - .to satisfy_one { |group| group['statistics'] == attributes } + .to satisfy_one { |group| group['statistics'] == exposed_attributes } end end @@ -150,20 +152,10 @@ describe API::Groups, api: true do expect(response_groups).to eq([group1.name, group3.name]) end end - end - - describe 'GET /groups/owned' do - context 'when unauthenticated' do - it 'returns authentication error' do - get api('/groups/owned') - - expect(response).to have_http_status(401) - end - end - context 'when authenticated as group owner' do + context 'when using owned in the request' do it 'returns an array of groups the user owns' do - get api('/groups/owned', user2) + get api('/groups', user2), owned: true expect(response).to have_http_status(200) expect(response).to include_pagination_headers @@ -186,7 +178,7 @@ describe API::Groups, api: true do expect(json_response['name']).to eq(group1.name) expect(json_response['path']).to eq(group1.path) expect(json_response['description']).to eq(group1.description) - expect(json_response['visibility_level']).to eq(group1.visibility_level) + expect(json_response['visibility']).to eq(Gitlab::VisibilityLevel.string_level(group1.visibility_level)) expect(json_response['avatar_url']).to eq(group1.avatar_url) expect(json_response['web_url']).to eq(group1.web_url) expect(json_response['request_access_enabled']).to eq(group1.request_access_enabled) @@ -303,9 +295,9 @@ describe API::Groups, api: true do expect(response).to have_http_status(200) expect(response).to include_pagination_headers expect(json_response.length).to eq(2) - project_names = json_response.map { |proj| proj['name' ] } + project_names = json_response.map { |proj| proj['name'] } expect(project_names).to match_array([project1.name, project3.name]) - expect(json_response.first['visibility_level']).to be_present + expect(json_response.first['visibility']).to be_present end it "returns the group's projects with simple representation" do @@ -314,9 +306,9 @@ describe API::Groups, api: true do expect(response).to have_http_status(200) expect(response).to include_pagination_headers expect(json_response.length).to eq(2) - project_names = json_response.map { |proj| proj['name' ] } + project_names = json_response.map { |proj| proj['name'] } expect(project_names).to match_array([project1.name, project3.name]) - expect(json_response.first['visibility_level']).not_to be_present + expect(json_response.first['visibility']).not_to be_present end it 'filters the groups projects' do @@ -398,7 +390,7 @@ describe API::Groups, api: true do expect(response).to have_http_status(200) expect(response).to include_pagination_headers - project_names = json_response.map { |proj| proj['name' ] } + project_names = json_response.map { |proj| proj['name'] } expect(project_names).to match_array([project1.name, project3.name]) end @@ -477,7 +469,7 @@ describe API::Groups, api: true do it "removes group" do delete api("/groups/#{group1.id}", user1) - expect(response).to have_http_status(200) + expect(response).to have_http_status(204) end it "does not remove a group if not an owner" do @@ -506,7 +498,7 @@ describe API::Groups, api: true do it "removes any existing group" do delete api("/groups/#{group2.id}", admin) - expect(response).to have_http_status(200) + expect(response).to have_http_status(204) end it "does not remove a non existing group" do @@ -519,7 +511,7 @@ describe API::Groups, api: true do describe "POST /groups/:id/projects/:project_id" do let(:project) { create(:empty_project) } - let(:project_path) { "#{project.namespace.path}%2F#{project.path}" } + let(:project_path) { project.full_path.gsub('/', '%2F') } before(:each) do allow_any_instance_of(Projects::TransferService). diff --git a/spec/requests/api/helpers_spec.rb b/spec/requests/api/helpers_spec.rb index a89676fec93..988a57a80ea 100644 --- a/spec/requests/api/helpers_spec.rb +++ b/spec/requests/api/helpers_spec.rb @@ -436,7 +436,7 @@ describe API::Helpers, api: true do context 'current_user is present' do before do - expect_any_instance_of(self.class).to receive(:current_user).and_return(true) + expect_any_instance_of(self.class).to receive(:current_user).at_least(:once).and_return(User.new) end it 'does not raise an error' do diff --git a/spec/requests/api/internal_spec.rb b/spec/requests/api/internal_spec.rb index ffeacb15f17..63ec00cdf04 100644 --- a/spec/requests/api/internal_spec.rb +++ b/spec/requests/api/internal_spec.rb @@ -397,16 +397,53 @@ describe API::Internal, api: true do before do project.team << [user, :developer] - get api("/internal/merge_request_urls?project=#{repo_name}&changes=#{changes}"), secret_token: secret_token end it 'returns link to create new merge request' do + get api("/internal/merge_request_urls?project=#{repo_name}&changes=#{changes}"), secret_token: secret_token + expect(json_response).to match [{ "branch_name" => "new_branch", "url" => "http://#{Gitlab.config.gitlab.host}/#{project.namespace.name}/#{project.path}/merge_requests/new?merge_request%5Bsource_branch%5D=new_branch", "new_merge_request" => true }] end + + it 'returns empty array if printing_merge_request_link_enabled is false' do + project.update!(printing_merge_request_link_enabled: false) + + get api("/internal/merge_request_urls?project=#{repo_name}&changes=#{changes}"), secret_token: secret_token + + expect(json_response).to eq([]) + end + end + + describe 'POST /notify_post_receive' do + let(:valid_params) do + { repo_path: project.repository.path, secret_token: secret_token } + end + + before do + allow(Gitlab.config.gitaly).to receive(:socket_path).and_return('path/to/gitaly.socket') + end + + it "calls the Gitaly client if it's enabled" do + expect_any_instance_of(Gitlab::GitalyClient::Notifications). + to receive(:post_receive).with(project.repository.path) + + post api("/internal/notify_post_receive"), valid_params + + expect(response).to have_http_status(200) + end + + it "returns 500 if the gitaly call fails" do + expect_any_instance_of(Gitlab::GitalyClient::Notifications). + to receive(:post_receive).with(project.repository.path).and_raise(GRPC::Unavailable) + + post api("/internal/notify_post_receive"), valid_params + + expect(response).to have_http_status(500) + end end def project_with_repo_path(path) diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb index 56ca4c04e7d..52f68fed2cc 100644 --- a/spec/requests/api/issues_spec.rb +++ b/spec/requests/api/issues_spec.rb @@ -153,6 +153,16 @@ describe API::Issues, api: true do expect(json_response.first['state']).to eq('opened') end + it 'returns unlabeled issues for "No Label" label' do + get api("/issues", user), labels: 'No Label' + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.length).to eq(1) + expect(json_response.first['labels']).to be_empty + end + it 'returns an empty array if no issue matches labels and state filters' do get api("/issues?labels=#{label.title}&state=closed", user) @@ -212,6 +222,25 @@ describe API::Issues, api: true do expect(json_response.first['id']).to eq(confidential_issue.id) end + it 'returns an array of issues found by iids' do + get api('/issues', user), iids: [closed_issue.iid] + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.length).to eq(1) + expect(json_response.first['id']).to eq(closed_issue.id) + end + + it 'returns an empty array if iid does not exist' do + get api("/issues", user), iids: [99999] + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.length).to eq(0) + end + it 'sorts by created_at descending by default' do get api('/issues', user) @@ -251,6 +280,13 @@ describe API::Issues, api: true do expect(json_response).to be_an Array expect(response_dates).to eq(response_dates.sort) end + + it 'matches V4 response schema' do + get api('/issues', user) + + expect(response).to have_http_status(200) + expect(response).to match_response_schema('public_api/v4/issues') + end end end @@ -377,6 +413,25 @@ describe API::Issues, api: true do expect(json_response.first['labels']).to eq([label_c.title, label_b.title, group_label.title]) end + it 'returns an array of issues found by iids' do + get api(base_url, user), iids: [group_issue.iid] + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.length).to eq(1) + expect(json_response.first['id']).to eq(group_issue.id) + end + + it 'returns an empty array if iid does not exist' do + get api(base_url, user), iids: [99999] + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.length).to eq(0) + end + it 'returns an empty array if no group issue matches labels' do get api("#{base_url}?labels=foo,bar", user) @@ -479,6 +534,12 @@ describe API::Issues, api: true do describe "GET /projects/:id/issues" do let(:base_url) { "/projects/#{project.id}" } + it 'returns 404 when project does not exist' do + get api('/projects/1000/issues', non_member) + + expect(response).to have_http_status(404) + end + it "returns 404 on private projects for other users" do private_project = create(:empty_project, :private) create(:issue, project: private_project) @@ -586,6 +647,25 @@ describe API::Issues, api: true do expect(json_response.first['labels']).to eq([label_c.title, label_b.title, label.title]) end + it 'returns an array of issues found by iids' do + get api("#{base_url}/issues", user), iids: [issue.iid] + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.length).to eq(1) + expect(json_response.first['id']).to eq(issue.id) + end + + it 'returns an empty array if iid does not exist' do + get api("#{base_url}/issues", user), iids: [99999] + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.length).to eq(0) + end + it 'returns an empty array if not all labels matches' do get api("#{base_url}/issues?labels=#{label.title},foo", user) @@ -693,9 +773,9 @@ describe API::Issues, api: true do end end - describe "GET /projects/:id/issues/:issue_id" do + describe "GET /projects/:id/issues/:issue_iid" do it 'exposes known attributes' do - get api("/projects/#{project.id}/issues/#{issue.id}", user) + get api("/projects/#{project.id}/issues/#{issue.iid}", user) expect(response).to have_http_status(200) expect(json_response['id']).to eq(issue.id) @@ -713,8 +793,8 @@ describe API::Issues, api: true do expect(json_response['confidential']).to be_falsy end - it "returns a project issue by id" do - get api("/projects/#{project.id}/issues/#{issue.id}", user) + it "returns a project issue by internal id" do + get api("/projects/#{project.id}/issues/#{issue.iid}", user) expect(response).to have_http_status(200) expect(json_response['title']).to eq(issue.title) @@ -726,40 +806,52 @@ describe API::Issues, api: true do expect(response).to have_http_status(404) end + it "returns 404 if the issue ID is used" do + get api("/projects/#{project.id}/issues/#{issue.id}", user) + + expect(response).to have_http_status(404) + end + context 'confidential issues' do it "returns 404 for non project members" do - get api("/projects/#{project.id}/issues/#{confidential_issue.id}", non_member) + get api("/projects/#{project.id}/issues/#{confidential_issue.iid}", non_member) + expect(response).to have_http_status(404) end it "returns 404 for project members with guest role" do - get api("/projects/#{project.id}/issues/#{confidential_issue.id}", guest) + get api("/projects/#{project.id}/issues/#{confidential_issue.iid}", guest) + expect(response).to have_http_status(404) end it "returns confidential issue for project members" do - get api("/projects/#{project.id}/issues/#{confidential_issue.id}", user) + get api("/projects/#{project.id}/issues/#{confidential_issue.iid}", user) + expect(response).to have_http_status(200) expect(json_response['title']).to eq(confidential_issue.title) expect(json_response['iid']).to eq(confidential_issue.iid) end it "returns confidential issue for author" do - get api("/projects/#{project.id}/issues/#{confidential_issue.id}", author) + get api("/projects/#{project.id}/issues/#{confidential_issue.iid}", author) + expect(response).to have_http_status(200) expect(json_response['title']).to eq(confidential_issue.title) expect(json_response['iid']).to eq(confidential_issue.iid) end it "returns confidential issue for assignee" do - get api("/projects/#{project.id}/issues/#{confidential_issue.id}", assignee) + get api("/projects/#{project.id}/issues/#{confidential_issue.iid}", assignee) + expect(response).to have_http_status(200) expect(json_response['title']).to eq(confidential_issue.title) expect(json_response['iid']).to eq(confidential_issue.iid) end it "returns confidential issue for admin" do - get api("/projects/#{project.id}/issues/#{confidential_issue.id}", admin) + get api("/projects/#{project.id}/issues/#{confidential_issue.iid}", admin) + expect(response).to have_http_status(200) expect(json_response['title']).to eq(confidential_issue.title) expect(json_response['iid']).to eq(confidential_issue.iid) @@ -775,7 +867,7 @@ describe API::Issues, api: true do expect(response).to have_http_status(201) expect(json_response['title']).to eq('new issue') expect(json_response['description']).to be_nil - expect(json_response['labels']).to eq(['label', 'label2']) + expect(json_response['labels']).to eq(%w(label label2)) expect(json_response['confidential']).to be_falsy end @@ -852,29 +944,34 @@ describe API::Issues, api: true do ]) end - context 'resolving issues in a merge request' do + context 'resolving discussions' do let(:discussion) { Discussion.for_diff_notes([create(:diff_note_on_merge_request)]).first } let(:merge_request) { discussion.noteable } let(:project) { merge_request.source_project } + before do project.team << [user, :master] - post api("/projects/#{project.id}/issues", user), - title: 'New Issue', - merge_request_for_resolving_discussions: merge_request.iid end - it 'creates a new project issue' do - expect(response).to have_http_status(:created) - end + context 'resolving all discussions in a merge request' do + before do + post api("/projects/#{project.id}/issues", user), + title: 'New Issue', + merge_request_to_resolve_discussions_of: merge_request.iid + end - it 'resolves the discussions in a merge request' do - discussion.first_note.reload - - expect(discussion.resolved?).to be(true) + it_behaves_like 'creating an issue resolving discussions through the API' end - it 'assigns a description to the issue mentioning the merge request' do - expect(json_response['description']).to include(merge_request.to_reference) + context 'resolving a single discussion' do + before do + post api("/projects/#{project.id}/issues", user), + title: 'New Issue', + merge_request_to_resolve_discussions_of: merge_request.iid, + discussion_to_resolve: discussion.id + end + + it_behaves_like 'creating an issue resolving discussions through the API' end end @@ -940,23 +1037,29 @@ describe API::Issues, api: true do end end - describe "PUT /projects/:id/issues/:issue_id to update only title" do + describe "PUT /projects/:id/issues/:issue_iid to update only title" do it "updates a project issue" do - put api("/projects/#{project.id}/issues/#{issue.id}", user), + put api("/projects/#{project.id}/issues/#{issue.iid}", user), title: 'updated title' expect(response).to have_http_status(200) expect(json_response['title']).to eq('updated title') end - it "returns 404 error if issue id not found" do + it "returns 404 error if issue iid not found" do put api("/projects/#{project.id}/issues/44444", user), title: 'updated title' expect(response).to have_http_status(404) end - it 'allows special label names' do + it "returns 404 error if issue id is used instead of the iid" do put api("/projects/#{project.id}/issues/#{issue.id}", user), + title: 'updated title' + expect(response).to have_http_status(404) + end + + it 'allows special label names' do + put api("/projects/#{project.id}/issues/#{issue.iid}", user), title: 'updated title', labels: 'label, label?, label&foo, ?, &' @@ -970,40 +1073,40 @@ describe API::Issues, api: true do context 'confidential issues' do it "returns 403 for non project members" do - put api("/projects/#{project.id}/issues/#{confidential_issue.id}", non_member), + put api("/projects/#{project.id}/issues/#{confidential_issue.iid}", non_member), title: 'updated title' expect(response).to have_http_status(403) end it "returns 403 for project members with guest role" do - put api("/projects/#{project.id}/issues/#{confidential_issue.id}", guest), + put api("/projects/#{project.id}/issues/#{confidential_issue.iid}", guest), title: 'updated title' expect(response).to have_http_status(403) end it "updates a confidential issue for project members" do - put api("/projects/#{project.id}/issues/#{confidential_issue.id}", user), + put api("/projects/#{project.id}/issues/#{confidential_issue.iid}", user), title: 'updated title' expect(response).to have_http_status(200) expect(json_response['title']).to eq('updated title') end it "updates a confidential issue for author" do - put api("/projects/#{project.id}/issues/#{confidential_issue.id}", author), + put api("/projects/#{project.id}/issues/#{confidential_issue.iid}", author), title: 'updated title' expect(response).to have_http_status(200) expect(json_response['title']).to eq('updated title') end it "updates a confidential issue for admin" do - put api("/projects/#{project.id}/issues/#{confidential_issue.id}", admin), + put api("/projects/#{project.id}/issues/#{confidential_issue.iid}", admin), title: 'updated title' expect(response).to have_http_status(200) expect(json_response['title']).to eq('updated title') end it 'sets an issue to confidential' do - put api("/projects/#{project.id}/issues/#{issue.id}", user), + put api("/projects/#{project.id}/issues/#{issue.iid}", user), confidential: true expect(response).to have_http_status(200) @@ -1011,7 +1114,7 @@ describe API::Issues, api: true do end it 'makes a confidential issue public' do - put api("/projects/#{project.id}/issues/#{confidential_issue.id}", user), + put api("/projects/#{project.id}/issues/#{confidential_issue.iid}", user), confidential: false expect(response).to have_http_status(200) @@ -1019,7 +1122,7 @@ describe API::Issues, api: true do end it 'does not update a confidential issue with wrong confidential flag' do - put api("/projects/#{project.id}/issues/#{confidential_issue.id}", user), + put api("/projects/#{project.id}/issues/#{confidential_issue.iid}", user), confidential: 'foo' expect(response).to have_http_status(400) @@ -1028,7 +1131,7 @@ describe API::Issues, api: true do end end - describe 'PUT /projects/:id/issues/:issue_id with spam filtering' do + describe 'PUT /projects/:id/issues/:issue_iid with spam filtering' do let(:params) do { title: 'updated title', @@ -1041,7 +1144,7 @@ describe API::Issues, api: true do allow_any_instance_of(SpamService).to receive_messages(check_for_spam?: true) allow_any_instance_of(AkismetService).to receive_messages(is_spam?: true) - put api("/projects/#{project.id}/issues/#{issue.id}", user), params + put api("/projects/#{project.id}/issues/#{issue.iid}", user), params expect(response).to have_http_status(400) expect(json_response['message']).to eq({ "error" => "Spam detected" }) @@ -1055,12 +1158,12 @@ describe API::Issues, api: true do end end - describe 'PUT /projects/:id/issues/:issue_id to update labels' do + describe 'PUT /projects/:id/issues/:issue_iid to update labels' do let!(:label) { create(:label, title: 'dummy', project: project) } let!(:label_link) { create(:label_link, label: label, target: issue) } it 'does not update labels if not present' do - put api("/projects/#{project.id}/issues/#{issue.id}", user), + put api("/projects/#{project.id}/issues/#{issue.iid}", user), title: 'updated title' expect(response).to have_http_status(200) expect(json_response['labels']).to eq([label.title]) @@ -1071,7 +1174,7 @@ describe API::Issues, api: true do label.toggle_subscription(user2, project) perform_enqueued_jobs do - put api("/projects/#{project.id}/issues/#{issue.id}", user), + put api("/projects/#{project.id}/issues/#{issue.iid}", user), title: 'updated title', labels: label.title end @@ -1079,14 +1182,14 @@ describe API::Issues, api: true do end it 'removes all labels' do - put api("/projects/#{project.id}/issues/#{issue.id}", user), labels: '' + put api("/projects/#{project.id}/issues/#{issue.iid}", user), labels: '' expect(response).to have_http_status(200) expect(json_response['labels']).to eq([]) end it 'updates labels' do - put api("/projects/#{project.id}/issues/#{issue.id}", user), + put api("/projects/#{project.id}/issues/#{issue.iid}", user), labels: 'foo,bar' expect(response).to have_http_status(200) expect(json_response['labels']).to include 'foo' @@ -1094,7 +1197,7 @@ describe API::Issues, api: true do end it 'allows special label names' do - put api("/projects/#{project.id}/issues/#{issue.id}", user), + put api("/projects/#{project.id}/issues/#{issue.iid}", user), labels: 'label:foo, label-bar,label_bar,label/bar,label?bar,label&bar,?,&' expect(response.status).to eq(200) expect(json_response['labels']).to include 'label:foo' @@ -1108,7 +1211,7 @@ describe API::Issues, api: true do end it 'returns 400 if title is too long' do - put api("/projects/#{project.id}/issues/#{issue.id}", user), + put api("/projects/#{project.id}/issues/#{issue.iid}", user), title: 'g' * 256 expect(response).to have_http_status(400) expect(json_response['message']['title']).to eq([ @@ -1117,9 +1220,9 @@ describe API::Issues, api: true do end end - describe "PUT /projects/:id/issues/:issue_id to update state and label" do + describe "PUT /projects/:id/issues/:issue_iid to update state and label" do it "updates a project issue" do - put api("/projects/#{project.id}/issues/#{issue.id}", user), + put api("/projects/#{project.id}/issues/#{issue.iid}", user), labels: 'label2', state_event: "close" expect(response).to have_http_status(200) @@ -1128,7 +1231,7 @@ describe API::Issues, api: true do end it 'reopens a project isssue' do - put api("/projects/#{project.id}/issues/#{closed_issue.id}", user), state_event: 'reopen' + put api("/projects/#{project.id}/issues/#{closed_issue.iid}", user), state_event: 'reopen' expect(response).to have_http_status(200) expect(json_response['state']).to eq 'reopened' @@ -1137,7 +1240,7 @@ describe API::Issues, api: true do context 'when an admin or owner makes the request' do it 'accepts the update date to be set' do update_time = 2.weeks.ago - put api("/projects/#{project.id}/issues/#{issue.id}", user), + put api("/projects/#{project.id}/issues/#{issue.iid}", user), labels: 'label3', state_event: 'close', updated_at: update_time expect(response).to have_http_status(200) @@ -1147,25 +1250,25 @@ describe API::Issues, api: true do end end - describe 'PUT /projects/:id/issues/:issue_id to update due date' do + describe 'PUT /projects/:id/issues/:issue_iid to update due date' do it 'creates a new project issue' do due_date = 2.weeks.from_now.strftime('%Y-%m-%d') - put api("/projects/#{project.id}/issues/#{issue.id}", user), due_date: due_date + put api("/projects/#{project.id}/issues/#{issue.iid}", user), due_date: due_date expect(response).to have_http_status(200) expect(json_response['due_date']).to eq(due_date) end end - describe "DELETE /projects/:id/issues/:issue_id" do + describe "DELETE /projects/:id/issues/:issue_iid" do it "rejects a non member from deleting an issue" do - delete api("/projects/#{project.id}/issues/#{issue.id}", non_member) + delete api("/projects/#{project.id}/issues/#{issue.iid}", non_member) expect(response).to have_http_status(403) end it "rejects a developer from deleting an issue" do - delete api("/projects/#{project.id}/issues/#{issue.id}", author) + delete api("/projects/#{project.id}/issues/#{issue.iid}", author) expect(response).to have_http_status(403) end @@ -1174,9 +1277,9 @@ describe API::Issues, api: true do let(:project) { create(:empty_project, namespace: owner.namespace) } it "deletes the issue if an admin requests it" do - delete api("/projects/#{project.id}/issues/#{issue.id}", owner) - expect(response).to have_http_status(200) - expect(json_response['state']).to eq 'opened' + delete api("/projects/#{project.id}/issues/#{issue.iid}", owner) + + expect(response).to have_http_status(204) end end @@ -1187,14 +1290,20 @@ describe API::Issues, api: true do expect(response).to have_http_status(404) end end + + it 'returns 404 when using the issue ID instead of IID' do + delete api("/projects/#{project.id}/issues/#{issue.id}", user) + + expect(response).to have_http_status(404) + end end - describe '/projects/:id/issues/:issue_id/move' do + describe '/projects/:id/issues/:issue_iid/move' do let!(:target_project) { create(:empty_project, path: 'project2', creator_id: user.id, namespace: user.namespace ) } let!(:target_project2) { create(:empty_project, creator_id: non_member.id, namespace: non_member.namespace ) } it 'moves an issue' do - post api("/projects/#{project.id}/issues/#{issue.id}/move", user), + post api("/projects/#{project.id}/issues/#{issue.iid}/move", user), to_project_id: target_project.id expect(response).to have_http_status(201) @@ -1203,7 +1312,7 @@ describe API::Issues, api: true do context 'when source and target projects are the same' do it 'returns 400 when trying to move an issue' do - post api("/projects/#{project.id}/issues/#{issue.id}/move", user), + post api("/projects/#{project.id}/issues/#{issue.iid}/move", user), to_project_id: project.id expect(response).to have_http_status(400) @@ -1213,7 +1322,7 @@ describe API::Issues, api: true do context 'when the user does not have the permission to move issues' do it 'returns 400 when trying to move an issue' do - post api("/projects/#{project.id}/issues/#{issue.id}/move", user), + post api("/projects/#{project.id}/issues/#{issue.iid}/move", user), to_project_id: target_project2.id expect(response).to have_http_status(400) @@ -1222,13 +1331,23 @@ describe API::Issues, api: true do end it 'moves the issue to another namespace if I am admin' do - post api("/projects/#{project.id}/issues/#{issue.id}/move", admin), + post api("/projects/#{project.id}/issues/#{issue.iid}/move", admin), to_project_id: target_project2.id expect(response).to have_http_status(201) expect(json_response['project_id']).to eq(target_project2.id) end + context 'when using the issue ID instead of iid' do + it 'returns 404 when trying to move an issue' do + post api("/projects/#{project.id}/issues/#{issue.id}/move", user), + to_project_id: target_project.id + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 Issue Not Found') + end + end + context 'when issue does not exist' do it 'returns 404 when trying to move an issue' do post api("/projects/#{project.id}/issues/123/move", user), @@ -1241,7 +1360,7 @@ describe API::Issues, api: true do context 'when source project does not exist' do it 'returns 404 when trying to move an issue' do - post api("/projects/123/issues/#{issue.id}/move", user), + post api("/projects/123/issues/#{issue.iid}/move", user), to_project_id: target_project.id expect(response).to have_http_status(404) @@ -1251,7 +1370,7 @@ describe API::Issues, api: true do context 'when target project does not exist' do it 'returns 404 when trying to move an issue' do - post api("/projects/#{project.id}/issues/#{issue.id}/move", user), + post api("/projects/#{project.id}/issues/#{issue.iid}/move", user), to_project_id: 123 expect(response).to have_http_status(404) @@ -1259,16 +1378,16 @@ describe API::Issues, api: true do end end - describe 'POST :id/issues/:issue_id/subscribe' do + describe 'POST :id/issues/:issue_iid/subscribe' do it 'subscribes to an issue' do - post api("/projects/#{project.id}/issues/#{issue.id}/subscribe", user2) + post api("/projects/#{project.id}/issues/#{issue.iid}/subscribe", user2) expect(response).to have_http_status(201) expect(json_response['subscribed']).to eq(true) end it 'returns 304 if already subscribed' do - post api("/projects/#{project.id}/issues/#{issue.id}/subscribe", user) + post api("/projects/#{project.id}/issues/#{issue.iid}/subscribe", user) expect(response).to have_http_status(304) end @@ -1279,8 +1398,14 @@ describe API::Issues, api: true do expect(response).to have_http_status(404) end + it 'returns 404 if the issue ID is used instead of the iid' do + post api("/projects/#{project.id}/issues/#{issue.id}/subscribe", user) + + expect(response).to have_http_status(404) + end + it 'returns 404 if the issue is confidential' do - post api("/projects/#{project.id}/issues/#{confidential_issue.id}/subscribe", non_member) + post api("/projects/#{project.id}/issues/#{confidential_issue.iid}/subscribe", non_member) expect(response).to have_http_status(404) end @@ -1288,14 +1413,14 @@ describe API::Issues, api: true do describe 'POST :id/issues/:issue_id/unsubscribe' do it 'unsubscribes from an issue' do - post api("/projects/#{project.id}/issues/#{issue.id}/unsubscribe", user) + post api("/projects/#{project.id}/issues/#{issue.iid}/unsubscribe", user) expect(response).to have_http_status(201) expect(json_response['subscribed']).to eq(false) end it 'returns 304 if not subscribed' do - post api("/projects/#{project.id}/issues/#{issue.id}/unsubscribe", user2) + post api("/projects/#{project.id}/issues/#{issue.iid}/unsubscribe", user2) expect(response).to have_http_status(304) end @@ -1306,8 +1431,14 @@ describe API::Issues, api: true do expect(response).to have_http_status(404) end + it 'returns 404 if using the issue ID instead of iid' do + post api("/projects/#{project.id}/issues/#{issue.id}/unsubscribe", user) + + expect(response).to have_http_status(404) + end + it 'returns 404 if the issue is confidential' do - post api("/projects/#{project.id}/issues/#{confidential_issue.id}/unsubscribe", non_member) + post api("/projects/#{project.id}/issues/#{confidential_issue.iid}/unsubscribe", non_member) expect(response).to have_http_status(404) end diff --git a/spec/requests/api/jobs_spec.rb b/spec/requests/api/jobs_spec.rb new file mode 100644 index 00000000000..9450701064b --- /dev/null +++ b/spec/requests/api/jobs_spec.rb @@ -0,0 +1,480 @@ +require 'spec_helper' + +describe API::Jobs, api: true do + include ApiHelpers + + let(:user) { create(:user) } + let(:api_user) { user } + let!(:project) { create(:project, :repository, creator: user, public_builds: false) } + let!(:developer) { create(:project_member, :developer, user: user, project: project) } + let(:reporter) { create(:project_member, :reporter, project: project) } + let(:guest) { create(:project_member, :guest, project: project) } + let!(:pipeline) { create(:ci_empty_pipeline, project: project, sha: project.commit.id, ref: project.default_branch) } + let!(:build) { create(:ci_build, pipeline: pipeline) } + + describe 'GET /projects/:id/jobs' do + let(:query) { Hash.new } + + before do + get api("/projects/#{project.id}/jobs", api_user), query + end + + context 'authorized user' do + it 'returns project jobs' do + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + end + + it 'returns correct values' do + expect(json_response).not_to be_empty + expect(json_response.first['commit']['id']).to eq project.commit.id + end + + it 'returns pipeline data' do + json_build = json_response.first + + expect(json_build['pipeline']).not_to be_empty + expect(json_build['pipeline']['id']).to eq build.pipeline.id + expect(json_build['pipeline']['ref']).to eq build.pipeline.ref + expect(json_build['pipeline']['sha']).to eq build.pipeline.sha + expect(json_build['pipeline']['status']).to eq build.pipeline.status + end + + context 'filter project with one scope element' do + let(:query) { { 'scope' => 'pending' } } + + it do + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + end + end + + context 'filter project with array of scope elements' do + let(:query) { { scope: %w(pending running) } } + + it do + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + end + end + + context 'respond 400 when scope contains invalid state' do + let(:query) { { scope: %w(unknown running) } } + + it { expect(response).to have_http_status(400) } + end + end + + context 'unauthorized user' do + let(:api_user) { nil } + + it 'does not return project builds' do + expect(response).to have_http_status(401) + end + end + end + + describe 'GET /projects/:id/pipelines/:pipeline_id/jobs' do + let(:query) { Hash.new } + + before do + get api("/projects/#{project.id}/pipelines/#{pipeline.id}/jobs", api_user), query + end + + context 'authorized user' do + it 'returns pipeline jobs' do + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + end + + it 'returns correct values' do + expect(json_response).not_to be_empty + expect(json_response.first['commit']['id']).to eq project.commit.id + end + + it 'returns pipeline data' do + json_build = json_response.first + + expect(json_build['pipeline']).not_to be_empty + expect(json_build['pipeline']['id']).to eq build.pipeline.id + expect(json_build['pipeline']['ref']).to eq build.pipeline.ref + expect(json_build['pipeline']['sha']).to eq build.pipeline.sha + expect(json_build['pipeline']['status']).to eq build.pipeline.status + end + + context 'filter jobs with one scope element' do + let(:query) { { 'scope' => 'pending' } } + + it do + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + end + end + + context 'filter jobs with array of scope elements' do + let(:query) { { scope: %w(pending running) } } + + it do + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + end + end + + context 'respond 400 when scope contains invalid state' do + let(:query) { { scope: %w(unknown running) } } + + it { expect(response).to have_http_status(400) } + end + + context 'jobs in different pipelines' do + let!(:pipeline2) { create(:ci_empty_pipeline, project: project) } + let!(:build2) { create(:ci_build, pipeline: pipeline2) } + + it 'excludes jobs from other pipelines' do + json_response.each { |job| expect(job['pipeline']['id']).to eq(pipeline.id) } + end + end + end + + context 'unauthorized user' do + let(:api_user) { nil } + + it 'does not return jobs' do + expect(response).to have_http_status(401) + end + end + end + + describe 'GET /projects/:id/jobs/:job_id' do + before do + get api("/projects/#{project.id}/jobs/#{build.id}", api_user) + end + + context 'authorized user' do + it 'returns specific job data' do + expect(response).to have_http_status(200) + expect(json_response['name']).to eq('test') + end + + it 'returns pipeline data' do + json_build = json_response + expect(json_build['pipeline']).not_to be_empty + expect(json_build['pipeline']['id']).to eq build.pipeline.id + expect(json_build['pipeline']['ref']).to eq build.pipeline.ref + expect(json_build['pipeline']['sha']).to eq build.pipeline.sha + expect(json_build['pipeline']['status']).to eq build.pipeline.status + end + end + + context 'unauthorized user' do + let(:api_user) { nil } + + it 'does not return specific job data' do + expect(response).to have_http_status(401) + end + end + end + + describe 'GET /projects/:id/jobs/:job_id/artifacts' do + before do + get api("/projects/#{project.id}/jobs/#{build.id}/artifacts", api_user) + end + + context 'job with artifacts' do + let(:build) { create(:ci_build, :artifacts, pipeline: pipeline) } + + context 'authorized user' do + let(:download_headers) do + { 'Content-Transfer-Encoding' => 'binary', + 'Content-Disposition' => 'attachment; filename=ci_build_artifacts.zip' } + end + + it 'returns specific job artifacts' do + expect(response).to have_http_status(200) + expect(response.headers).to include(download_headers) + expect(response.body).to match_file(build.artifacts_file.file.file) + end + end + + context 'unauthorized user' do + let(:api_user) { nil } + + it 'does not return specific job artifacts' do + expect(response).to have_http_status(401) + end + end + end + + it 'does not return job artifacts if not uploaded' do + expect(response).to have_http_status(404) + end + end + + describe 'GET /projects/:id/artifacts/:ref_name/download?job=name' do + let(:api_user) { reporter.user } + let(:build) { create(:ci_build, :artifacts, pipeline: pipeline) } + + before do + build.success + end + + def get_for_ref(ref = pipeline.ref, job = build.name) + get api("/projects/#{project.id}/jobs/artifacts/#{ref}/download", api_user), job: job + end + + context 'when not logged in' do + let(:api_user) { nil } + + before do + get_for_ref + end + + it 'gives 401' do + expect(response).to have_http_status(401) + end + end + + context 'when logging as guest' do + let(:api_user) { guest.user } + + before do + get_for_ref + end + + it 'gives 403' do + expect(response).to have_http_status(403) + end + end + + context 'non-existing job' do + shared_examples 'not found' do + it { expect(response).to have_http_status(:not_found) } + end + + context 'has no such ref' do + before do + get_for_ref('TAIL') + end + + it_behaves_like 'not found' + end + + context 'has no such job' do + before do + get_for_ref(pipeline.ref, 'NOBUILD') + end + + it_behaves_like 'not found' + end + end + + context 'find proper job' do + shared_examples 'a valid file' do + let(:download_headers) do + { 'Content-Transfer-Encoding' => 'binary', + 'Content-Disposition' => + "attachment; filename=#{build.artifacts_file.filename}" } + end + + it { expect(response).to have_http_status(200) } + it { expect(response.headers).to include(download_headers) } + end + + context 'with regular branch' do + before do + pipeline.reload + pipeline.update(ref: 'master', + sha: project.commit('master').sha) + + get_for_ref('master') + end + + it_behaves_like 'a valid file' + end + + context 'with branch name containing slash' do + before do + pipeline.reload + pipeline.update(ref: 'improve/awesome', + sha: project.commit('improve/awesome').sha) + end + + before do + get_for_ref('improve/awesome') + end + + it_behaves_like 'a valid file' + end + end + end + + describe 'GET /projects/:id/jobs/:job_id/trace' do + let(:build) { create(:ci_build, :trace, pipeline: pipeline) } + + before do + get api("/projects/#{project.id}/jobs/#{build.id}/trace", api_user) + end + + context 'authorized user' do + it 'returns specific job trace' do + expect(response).to have_http_status(200) + expect(response.body).to eq(build.trace) + end + end + + context 'unauthorized user' do + let(:api_user) { nil } + + it 'does not return specific job trace' do + expect(response).to have_http_status(401) + end + end + end + + describe 'POST /projects/:id/jobs/:job_id/cancel' do + before do + post api("/projects/#{project.id}/jobs/#{build.id}/cancel", api_user) + end + + context 'authorized user' do + context 'user with :update_build persmission' do + it 'cancels running or pending job' do + expect(response).to have_http_status(201) + expect(project.builds.first.status).to eq('canceled') + end + end + + context 'user without :update_build permission' do + let(:api_user) { reporter.user } + + it 'does not cancel job' do + expect(response).to have_http_status(403) + end + end + end + + context 'unauthorized user' do + let(:api_user) { nil } + + it 'does not cancel job' do + expect(response).to have_http_status(401) + end + end + end + + describe 'POST /projects/:id/jobs/:job_id/retry' do + let(:build) { create(:ci_build, :canceled, pipeline: pipeline) } + + before do + post api("/projects/#{project.id}/jobs/#{build.id}/retry", api_user) + end + + context 'authorized user' do + context 'user with :update_build permission' do + it 'retries non-running job' do + expect(response).to have_http_status(201) + expect(project.builds.first.status).to eq('canceled') + expect(json_response['status']).to eq('pending') + end + end + + context 'user without :update_build permission' do + let(:api_user) { reporter.user } + + it 'does not retry job' do + expect(response).to have_http_status(403) + end + end + end + + context 'unauthorized user' do + let(:api_user) { nil } + + it 'does not retry job' do + expect(response).to have_http_status(401) + end + end + end + + describe 'POST /projects/:id/jobs/:job_id/erase' do + before do + post api("/projects/#{project.id}/jobs/#{build.id}/erase", user) + end + + context 'job is erasable' do + let(:build) { create(:ci_build, :trace, :artifacts, :success, project: project, pipeline: pipeline) } + + it 'erases job content' do + expect(response).to have_http_status(201) + expect(build.trace).to be_empty + expect(build.artifacts_file.exists?).to be_falsy + expect(build.artifacts_metadata.exists?).to be_falsy + end + + it 'updates job' do + build.reload + expect(build.erased_at).to be_truthy + expect(build.erased_by).to eq(user) + end + end + + context 'job is not erasable' do + let(:build) { create(:ci_build, :trace, project: project, pipeline: pipeline) } + + it 'responds with forbidden' do + expect(response).to have_http_status(403) + end + end + end + + describe 'POST /projects/:id/jobs/:build_id/artifacts/keep' do + before do + post api("/projects/#{project.id}/jobs/#{build.id}/artifacts/keep", user) + end + + context 'artifacts did not expire' do + let(:build) do + create(:ci_build, :trace, :artifacts, :success, + project: project, pipeline: pipeline, artifacts_expire_at: Time.now + 7.days) + end + + it 'keeps artifacts' do + expect(response).to have_http_status(200) + expect(build.reload.artifacts_expire_at).to be_nil + end + end + + context 'no artifacts' do + let(:build) { create(:ci_build, project: project, pipeline: pipeline) } + + it 'responds with not found' do + expect(response).to have_http_status(404) + end + end + end + + describe 'POST /projects/:id/jobs/:job_id/play' do + before do + post api("/projects/#{project.id}/jobs/#{build.id}/play", user) + end + + context 'on an playable job' do + let(:build) { create(:ci_build, :manual, project: project, pipeline: pipeline) } + + it 'plays the job' do + expect(response).to have_http_status(200) + expect(json_response['user']['id']).to eq(user.id) + expect(json_response['id']).to eq(build.id) + end + end + + context 'on a non-playable job' do + it 'returns a status code 400, Bad Request' do + expect(response).to have_http_status 400 + expect(response.body).to match("Unplayable Job") + end + end + end +end diff --git a/spec/requests/api/labels_spec.rb b/spec/requests/api/labels_spec.rb index 566d11bba57..a1adaba7b98 100644 --- a/spec/requests/api/labels_spec.rb +++ b/spec/requests/api/labels_spec.rb @@ -21,11 +21,11 @@ describe API::Labels, api: true do create(:labeled_issue, project: project, labels: [label1], author: user, state: :closed) create(:labeled_merge_request, labels: [priority_label], author: user, source_project: project ) - expected_keys = [ - 'id', 'name', 'color', 'description', - 'open_issues_count', 'closed_issues_count', 'open_merge_requests_count', - 'subscribed', 'priority' - ] + expected_keys = %w( + id name color description + open_issues_count closed_issues_count open_merge_requests_count + subscribed priority + ) get api("/projects/#{project.id}/labels", user) @@ -175,9 +175,10 @@ describe API::Labels, api: true do end describe 'DELETE /projects/:id/labels' do - it 'returns 200 for existing label' do + it 'returns 204 for existing label' do delete api("/projects/#{project.id}/labels", user), name: 'label1' - expect(response).to have_http_status(200) + + expect(response).to have_http_status(204) end it 'returns 404 for non existing label' do diff --git a/spec/requests/api/members_spec.rb b/spec/requests/api/members_spec.rb index 31166b50033..2d37d026a39 100644 --- a/spec/requests/api/members_spec.rb +++ b/spec/requests/api/members_spec.rb @@ -173,11 +173,11 @@ describe API::Members, api: true do expect(response).to have_http_status(400) end - it 'returns 422 when access_level is not valid' do + it 'returns 400 when access_level is not valid' do post api("/#{source_type.pluralize}/#{source.id}/members", master), user_id: stranger.id, access_level: 1234 - expect(response).to have_http_status(422) + expect(response).to have_http_status(400) end end end @@ -230,11 +230,11 @@ describe API::Members, api: true do expect(response).to have_http_status(400) end - it 'returns 422 when access level is not valid' do + it 'returns 400 when access level is not valid' do put api("/#{source_type.pluralize}/#{source.id}/members/#{developer.id}", master), access_level: 1234 - expect(response).to have_http_status(422) + expect(response).to have_http_status(400) end end end @@ -263,18 +263,18 @@ describe API::Members, api: true do expect do delete api("/#{source_type.pluralize}/#{source.id}/members/#{developer.id}", developer) - expect(response).to have_http_status(200) + expect(response).to have_http_status(204) end.to change { source.members.count }.by(-1) end end context 'when authenticated as a master/owner' do context 'and member is a requester' do - it "returns #{source_type == 'project' ? 200 : 404}" do + it 'returns 404' do expect do delete api("/#{source_type.pluralize}/#{source.id}/members/#{access_requester.id}", master) - expect(response).to have_http_status(source_type == 'project' ? 200 : 404) + expect(response).to have_http_status(404) end.not_to change { source.requesters.count } end end @@ -283,15 +283,15 @@ describe API::Members, api: true do expect do delete api("/#{source_type.pluralize}/#{source.id}/members/#{developer.id}", master) - expect(response).to have_http_status(200) + expect(response).to have_http_status(204) end.to change { source.members.count }.by(-1) end end - it "returns #{source_type == 'project' ? 200 : 404} if member does not exist" do + it 'returns 404 if member does not exist' do delete api("/#{source_type.pluralize}/#{source.id}/members/123", master) - expect(response).to have_http_status(source_type == 'project' ? 200 : 404) + expect(response).to have_http_status(404) end end end @@ -342,7 +342,7 @@ describe API::Members, api: true do post api("/projects/#{project.id}/members", master), user_id: stranger.id, access_level: Member::OWNER - expect(response).to have_http_status(422) + expect(response).to have_http_status(400) end.to change { project.members.count }.by(0) end end diff --git a/spec/requests/api/merge_request_diffs_spec.rb b/spec/requests/api/merge_request_diffs_spec.rb index 1d02e827183..79f3151ba52 100644 --- a/spec/requests/api/merge_request_diffs_spec.rb +++ b/spec/requests/api/merge_request_diffs_spec.rb @@ -13,9 +13,9 @@ describe API::MergeRequestDiffs, 'MergeRequestDiffs', api: true do project.team << [user, :master] end - describe 'GET /projects/:id/merge_requests/:merge_request_id/versions' do + describe 'GET /projects/:id/merge_requests/:merge_request_iid/versions' do it 'returns 200 for a valid merge request' do - get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/versions", user) + get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/versions", user) merge_request_diff = merge_request.merge_request_diffs.first expect(response.status).to eq 200 @@ -26,16 +26,22 @@ describe API::MergeRequestDiffs, 'MergeRequestDiffs', api: true do expect(json_response.first['head_commit_sha']).to eq(merge_request_diff.head_commit_sha) end - it 'returns a 404 when merge_request_id not found' do + it 'returns a 404 when merge_request id is used instead of the iid' do + get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/versions", user) + expect(response).to have_http_status(404) + end + + it 'returns a 404 when merge_request_iid not found' do get api("/projects/#{project.id}/merge_requests/999/versions", user) expect(response).to have_http_status(404) end end - describe 'GET /projects/:id/merge_requests/:merge_request_id/versions/:version_id' do + describe 'GET /projects/:id/merge_requests/:merge_request_iid/versions/:version_id' do + let(:merge_request_diff) { merge_request.merge_request_diffs.first } + it 'returns a 200 for a valid merge request' do - merge_request_diff = merge_request.merge_request_diffs.first - get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/versions/#{merge_request_diff.id}", user) + get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/versions/#{merge_request_diff.id}", user) expect(response.status).to eq 200 expect(json_response['id']).to eq(merge_request_diff.id) @@ -43,8 +49,18 @@ describe API::MergeRequestDiffs, 'MergeRequestDiffs', api: true do expect(json_response['diffs'].size).to eq(merge_request_diff.diffs.size) end - it 'returns a 404 when merge_request_id not found' do - get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/versions/999", user) + it 'returns a 404 when merge_request id is used instead of the iid' do + get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/versions/#{merge_request_diff.id}", user) + expect(response).to have_http_status(404) + end + + it 'returns a 404 when merge_request version_id is not found' do + get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/versions/999", user) + expect(response).to have_http_status(404) + end + + it 'returns a 404 when merge_request_iid is not found' do + get api("/projects/#{project.id}/merge_requests/12345/versions/#{merge_request_diff.id}", user) expect(response).to have_http_status(404) end end diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb index c125df8b90b..9aba1d75612 100644 --- a/spec/requests/api/merge_requests_spec.rb +++ b/spec/requests/api/merge_requests_spec.rb @@ -93,6 +93,13 @@ describe API::MergeRequests, api: true do expect(json_response.first['id']).to eq merge_request_closed.id end + it 'matches V4 response schema' do + get api("/projects/#{project.id}/merge_requests", user) + + expect(response).to have_http_status(200) + expect(response).to match_response_schema('public_api/v4/merge_requests') + end + context "with ordering" do before do @mr_later = mr_with_later_created_and_updated_at_time @@ -146,9 +153,9 @@ describe API::MergeRequests, api: true do end end - describe "GET /projects/:id/merge_requests/:merge_request_id" do + describe "GET /projects/:id/merge_requests/:merge_request_iid" do it 'exposes known attributes' do - get api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user) + get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user) expect(response).to have_http_status(200) expect(json_response['id']).to eq(merge_request.id) @@ -170,14 +177,14 @@ describe API::MergeRequests, api: true do expect(json_response['source_project_id']).to eq(merge_request.source_project.id) expect(json_response['target_project_id']).to eq(merge_request.target_project.id) expect(json_response['work_in_progress']).to be_falsy - expect(json_response['merge_when_build_succeeds']).to be_falsy + expect(json_response['merge_when_pipeline_succeeds']).to be_falsy expect(json_response['merge_status']).to eq('can_be_merged') expect(json_response['should_close_merge_request']).to be_falsy expect(json_response['force_close_merge_request']).to be_falsy end it "returns merge_request" do - get api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user) + get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user) expect(response).to have_http_status(200) expect(json_response['title']).to eq(merge_request.title) expect(json_response['iid']).to eq(merge_request.iid) @@ -187,25 +194,31 @@ describe API::MergeRequests, api: true do expect(json_response['force_close_merge_request']).to be_falsy end - it "returns a 404 error if merge_request_id not found" do + it "returns a 404 error if merge_request_iid not found" do get api("/projects/#{project.id}/merge_requests/999", user) expect(response).to have_http_status(404) end + it "returns a 404 error if merge_request `id` is used instead of iid" do + get api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user) + + expect(response).to have_http_status(404) + end + context 'Work in Progress' do let!(:merge_request_wip) { create(:merge_request, author: user, assignee: user, source_project: project, target_project: project, title: "WIP: Test", created_at: base_time + 1.second) } it "returns merge_request" do - get api("/projects/#{project.id}/merge_requests/#{merge_request_wip.id}", user) + get api("/projects/#{project.id}/merge_requests/#{merge_request_wip.iid}", user) expect(response).to have_http_status(200) expect(json_response['work_in_progress']).to eq(true) end end end - describe 'GET /projects/:id/merge_requests/:merge_request_id/commits' do + describe 'GET /projects/:id/merge_requests/:merge_request_iid/commits' do it 'returns a 200 when merge request is valid' do - get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/commits", user) + get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/commits", user) commit = merge_request.commits.first expect(response.status).to eq 200 @@ -216,24 +229,36 @@ describe API::MergeRequests, api: true do expect(json_response.first['title']).to eq(commit.title) end - it 'returns a 404 when merge_request_id not found' do + it 'returns a 404 when merge_request_iid not found' do get api("/projects/#{project.id}/merge_requests/999/commits", user) expect(response).to have_http_status(404) end + + it 'returns a 404 when merge_request id is used instead of iid' do + get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/commits", user) + + expect(response).to have_http_status(404) + end end - describe 'GET /projects/:id/merge_requests/:merge_request_id/changes' do + describe 'GET /projects/:id/merge_requests/:merge_request_iid/changes' do it 'returns the change information of the merge_request' do - get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/changes", user) + get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/changes", user) expect(response.status).to eq 200 expect(json_response['changes'].size).to eq(merge_request.diffs.size) end - it 'returns a 404 when merge_request_id not found' do + it 'returns a 404 when merge_request_iid not found' do get api("/projects/#{project.id}/merge_requests/999/changes", user) expect(response).to have_http_status(404) end + + it 'returns a 404 when merge_request id is used instead of iid' do + get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/changes", user) + + expect(response).to have_http_status(404) + end end describe "POST /projects/:id/merge_requests" do @@ -250,7 +275,7 @@ describe API::MergeRequests, api: true do expect(response).to have_http_status(201) expect(json_response['title']).to eq('Test merge_request') - expect(json_response['labels']).to eq(['label', 'label2']) + expect(json_response['labels']).to eq(%w(label label2)) expect(json_response['milestone']['id']).to eq(milestone.id) expect(json_response['force_remove_source_branch']).to be_truthy end @@ -393,7 +418,7 @@ describe API::MergeRequests, api: true do end end - describe "DELETE /projects/:id/merge_requests/:merge_request_id" do + describe "DELETE /projects/:id/merge_requests/:merge_request_iid" do context "when the user is developer" do let(:developer) { create(:user) } @@ -402,25 +427,37 @@ describe API::MergeRequests, api: true do end it "denies the deletion of the merge request" do - delete api("/projects/#{project.id}/merge_requests/#{merge_request.id}", developer) + delete api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", developer) expect(response).to have_http_status(403) end end context "when the user is project owner" do it "destroys the merge request owners can destroy" do + delete api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user) + + expect(response).to have_http_status(204) + end + + it "returns 404 for an invalid merge request IID" do + delete api("/projects/#{project.id}/merge_requests/12345", user) + + expect(response).to have_http_status(404) + end + + it "returns 404 if the merge request id is used instead of iid" do delete api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user) - expect(response).to have_http_status(200) + expect(response).to have_http_status(404) end end end - describe "PUT /projects/:id/merge_requests/:merge_request_id/merge" do + describe "PUT /projects/:id/merge_requests/:merge_request_iid/merge" do let(:pipeline) { create(:ci_pipeline_without_jobs) } it "returns merge_request in case of success" do - put api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user) + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user) expect(response).to have_http_status(200) end @@ -429,7 +466,7 @@ describe API::MergeRequests, api: true do allow_any_instance_of(MergeRequest). to receive(:can_be_merged?).and_return(false) - put api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user) + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user) expect(response).to have_http_status(406) expect(json_response['message']).to eq('Branch cannot be merged') @@ -437,14 +474,14 @@ describe API::MergeRequests, api: true do it "returns 405 if merge_request is not open" do merge_request.close - put api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user) + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user) expect(response).to have_http_status(405) expect(json_response['message']).to eq('405 Method Not Allowed') end it "returns 405 if merge_request is a work in progress" do merge_request.update_attribute(:title, "WIP: #{merge_request.title}") - put api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user) + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user) expect(response).to have_http_status(405) expect(json_response['message']).to eq('405 Method Not Allowed') end @@ -452,7 +489,7 @@ describe API::MergeRequests, api: true do it 'returns 405 if the build failed for a merge request that requires success' do allow_any_instance_of(MergeRequest).to receive(:mergeable_ci_state?).and_return(false) - put api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user) + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user) expect(response).to have_http_status(405) expect(json_response['message']).to eq('405 Method Not Allowed') @@ -461,20 +498,20 @@ describe API::MergeRequests, api: true do it "returns 401 if user has no permissions to merge" do user2 = create(:user) project.team << [user2, :reporter] - put api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user2) + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user2) expect(response).to have_http_status(401) expect(json_response['message']).to eq('401 Unauthorized') end it "returns 409 if the SHA parameter doesn't match" do - put api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user), sha: merge_request.diff_head_sha.reverse + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user), sha: merge_request.diff_head_sha.reverse expect(response).to have_http_status(409) expect(json_response['message']).to start_with('SHA does not match HEAD of source branch') end it "succeeds if the SHA parameter matches" do - put api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user), sha: merge_request.diff_head_sha + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user), sha: merge_request.diff_head_sha expect(response).to have_http_status(200) end @@ -483,18 +520,30 @@ describe API::MergeRequests, api: true do allow_any_instance_of(MergeRequest).to receive(:head_pipeline).and_return(pipeline) allow(pipeline).to receive(:active?).and_return(true) - put api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user), merge_when_build_succeeds: true + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/merge", user), merge_when_pipeline_succeeds: true expect(response).to have_http_status(200) expect(json_response['title']).to eq('Test') - expect(json_response['merge_when_build_succeeds']).to eq(true) + expect(json_response['merge_when_pipeline_succeeds']).to eq(true) + end + + it "returns 404 for an invalid merge request IID" do + put api("/projects/#{project.id}/merge_requests/12345/merge", user) + + expect(response).to have_http_status(404) + end + + it "returns 404 if the merge request id is used instead of iid" do + put api("/projects/#{project.id}/merge_requests/#{merge_request.id}/merge", user) + + expect(response).to have_http_status(404) end end - describe "PUT /projects/:id/merge_requests/:merge_request_id" do + describe "PUT /projects/:id/merge_requests/:merge_request_iid" do context "to close a MR" do it "returns merge_request" do - put api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), state_event: "close" + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user), state_event: "close" expect(response).to have_http_status(200) expect(json_response['state']).to eq('closed') @@ -502,38 +551,38 @@ describe API::MergeRequests, api: true do end it "updates title and returns merge_request" do - put api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), title: "New title" + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user), title: "New title" expect(response).to have_http_status(200) expect(json_response['title']).to eq('New title') end it "updates description and returns merge_request" do - put api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), description: "New description" + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user), description: "New description" expect(response).to have_http_status(200) expect(json_response['description']).to eq('New description') end it "updates milestone_id and returns merge_request" do - put api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), milestone_id: milestone.id + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user), milestone_id: milestone.id expect(response).to have_http_status(200) expect(json_response['milestone']['id']).to eq(milestone.id) end it "returns merge_request with renamed target_branch" do - put api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), target_branch: "wiki" + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user), target_branch: "wiki" expect(response).to have_http_status(200) expect(json_response['target_branch']).to eq('wiki') end it "returns merge_request that removes the source branch" do - put api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), remove_source_branch: true + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user), remove_source_branch: true expect(response).to have_http_status(200) expect(json_response['force_remove_source_branch']).to be_truthy end it 'allows special label names' do - put api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user), title: 'new issue', labels: 'label, label?, label&foo, ?, &' @@ -546,7 +595,7 @@ describe API::MergeRequests, api: true do end it 'does not update state when title is empty' do - put api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), state_event: 'close', title: nil + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user), state_event: 'close', title: nil merge_request.reload expect(response).to have_http_status(400) @@ -554,19 +603,31 @@ describe API::MergeRequests, api: true do end it 'does not update state when target_branch is empty' do - put api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), state_event: 'close', target_branch: nil + put api("/projects/#{project.id}/merge_requests/#{merge_request.iid}", user), state_event: 'close', target_branch: nil merge_request.reload expect(response).to have_http_status(400) expect(merge_request.state).to eq('opened') end + + it "returns 404 for an invalid merge request IID" do + put api("/projects/#{project.id}/merge_requests/12345", user), state_event: "close" + + expect(response).to have_http_status(404) + end + + it "returns 404 if the merge request id is used instead of iid" do + put api("/projects/#{project.id}/merge_requests/#{merge_request.id}", user), state_event: "close" + + expect(response).to have_http_status(404) + end end - describe "POST /projects/:id/merge_requests/:merge_request_id/comments" do + describe "POST /projects/:id/merge_requests/:merge_request_iid/comments" do it "returns comment" do original_count = merge_request.notes.size - post api("/projects/#{project.id}/merge_requests/#{merge_request.id}/comments", user), note: "My comment" + post api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/comments", user), note: "My comment" expect(response).to have_http_status(201) expect(json_response['note']).to eq('My comment') @@ -576,23 +637,29 @@ describe API::MergeRequests, api: true do end it "returns 400 if note is missing" do - post api("/projects/#{project.id}/merge_requests/#{merge_request.id}/comments", user) + post api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/comments", user) expect(response).to have_http_status(400) end - it "returns 404 if note is attached to non existent merge request" do + it "returns 404 if merge request iid is invalid" do post api("/projects/#{project.id}/merge_requests/404/comments", user), note: 'My comment' expect(response).to have_http_status(404) end + + it "returns 404 if merge request id is used instead of iid" do + post api("/projects/#{project.id}/merge_requests/#{merge_request.id}/comments", user), + note: 'My comment' + expect(response).to have_http_status(404) + end end - describe "GET :id/merge_requests/:merge_request_id/comments" do + describe "GET :id/merge_requests/:merge_request_iid/comments" do let!(:note) { create(:note_on_merge_request, author: user, project: project, noteable: merge_request, note: "a comment on a MR") } let!(:note2) { create(:note_on_merge_request, author: user, project: project, noteable: merge_request, note: "another comment on a MR") } it "returns merge_request comments ordered by created_at" do - get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/comments", user) + get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/comments", user) expect(response).to have_http_status(200) expect(response).to include_pagination_headers @@ -603,20 +670,25 @@ describe API::MergeRequests, api: true do expect(json_response.last['note']).to eq("another comment on a MR") end - it "returns a 404 error if merge_request_id not found" do + it "returns a 404 error if merge_request_iid is invalid" do get api("/projects/#{project.id}/merge_requests/999/comments", user) expect(response).to have_http_status(404) end + + it "returns a 404 error if merge_request id is used instead of iid" do + get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/comments", user) + expect(response).to have_http_status(404) + end end - describe 'GET :id/merge_requests/:merge_request_id/closes_issues' do + describe 'GET :id/merge_requests/:merge_request_iid/closes_issues' do it 'returns the issue that will be closed on merge' do issue = create(:issue, project: project) mr = merge_request.tap do |mr| mr.update_attribute(:description, "Closes #{issue.to_reference(mr.project)}") end - get api("/projects/#{project.id}/merge_requests/#{mr.id}/closes_issues", user) + get api("/projects/#{project.id}/merge_requests/#{mr.iid}/closes_issues", user) expect(response).to have_http_status(200) expect(response).to include_pagination_headers @@ -626,7 +698,7 @@ describe API::MergeRequests, api: true do end it 'returns an empty array when there are no issues to be closed' do - get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/closes_issues", user) + get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/closes_issues", user) expect(response).to have_http_status(200) expect(response).to include_pagination_headers @@ -640,7 +712,7 @@ describe API::MergeRequests, api: true do merge_request = create(:merge_request, :simple, author: user, assignee: user, source_project: jira_project) merge_request.update_attribute(:description, "Closes #{issue.to_reference(jira_project)}") - get api("/projects/#{jira_project.id}/merge_requests/#{merge_request.id}/closes_issues", user) + get api("/projects/#{jira_project.id}/merge_requests/#{merge_request.iid}/closes_issues", user) expect(response).to have_http_status(200) expect(response).to include_pagination_headers @@ -656,22 +728,34 @@ describe API::MergeRequests, api: true do guest = create(:user) project.team << [guest, :guest] - get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/closes_issues", guest) + get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/closes_issues", guest) expect(response).to have_http_status(403) end + + it "returns 404 for an invalid merge request IID" do + get api("/projects/#{project.id}/merge_requests/12345/closes_issues", user) + + expect(response).to have_http_status(404) + end + + it "returns 404 if the merge request id is used instead of iid" do + get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/closes_issues", user) + + expect(response).to have_http_status(404) + end end - describe 'POST :id/merge_requests/:merge_request_id/subscribe' do + describe 'POST :id/merge_requests/:merge_request_iid/subscribe' do it 'subscribes to a merge request' do - post api("/projects/#{project.id}/merge_requests/#{merge_request.id}/subscribe", admin) + post api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/subscribe", admin) expect(response).to have_http_status(201) expect(json_response['subscribed']).to eq(true) end it 'returns 304 if already subscribed' do - post api("/projects/#{project.id}/merge_requests/#{merge_request.id}/subscribe", user) + post api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/subscribe", user) expect(response).to have_http_status(304) end @@ -682,26 +766,32 @@ describe API::MergeRequests, api: true do expect(response).to have_http_status(404) end + it 'returns 404 if the merge request id is used instead of iid' do + post api("/projects/#{project.id}/merge_requests/#{merge_request.id}/subscribe", user) + + expect(response).to have_http_status(404) + end + it 'returns 403 if user has no access to read code' do guest = create(:user) project.team << [guest, :guest] - post api("/projects/#{project.id}/merge_requests/#{merge_request.id}/subscribe", guest) + post api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/subscribe", guest) expect(response).to have_http_status(403) end end - describe 'POST :id/merge_requests/:merge_request_id/unsubscribe' do + describe 'POST :id/merge_requests/:merge_request_iid/unsubscribe' do it 'unsubscribes from a merge request' do - post api("/projects/#{project.id}/merge_requests/#{merge_request.id}/unsubscribe", user) + post api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/unsubscribe", user) expect(response).to have_http_status(201) expect(json_response['subscribed']).to eq(false) end it 'returns 304 if not subscribed' do - post api("/projects/#{project.id}/merge_requests/#{merge_request.id}/unsubscribe", admin) + post api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/unsubscribe", admin) expect(response).to have_http_status(304) end @@ -712,11 +802,17 @@ describe API::MergeRequests, api: true do expect(response).to have_http_status(404) end + it 'returns 404 if the merge request id is used instead of iid' do + post api("/projects/#{project.id}/merge_requests/#{merge_request.id}/unsubscribe", user) + + expect(response).to have_http_status(404) + end + it 'returns 403 if user has no access to read code' do guest = create(:user) project.team << [guest, :guest] - post api("/projects/#{project.id}/merge_requests/#{merge_request.id}/unsubscribe", guest) + post api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/unsubscribe", guest) expect(response).to have_http_status(403) end diff --git a/spec/requests/api/milestones_spec.rb b/spec/requests/api/milestones_spec.rb index 418bf5a507c..7fb728fed6f 100644 --- a/spec/requests/api/milestones_spec.rb +++ b/spec/requests/api/milestones_spec.rb @@ -4,8 +4,8 @@ describe API::Milestones, api: true do include ApiHelpers let(:user) { create(:user) } let!(:project) { create(:empty_project, namespace: user.namespace ) } - let!(:closed_milestone) { create(:closed_milestone, project: project) } - let!(:milestone) { create(:milestone, project: project) } + let!(:closed_milestone) { create(:closed_milestone, project: project, title: 'version1', description: 'closed milestone') } + let!(:milestone) { create(:milestone, project: project, title: 'version2', description: 'open milestone') } before { project.team << [user, :developer] } @@ -45,8 +45,37 @@ describe API::Milestones, api: true do expect(json_response.first['id']).to eq(closed_milestone.id) end - it 'returns a project milestone by iid' do - get api("/projects/#{project.id}/milestones?iid=#{closed_milestone.iid}", user) + it 'returns an array of milestones specified by iids' do + other_milestone = create(:milestone, project: project) + + get api("/projects/#{project.id}/milestones", user), iids: [closed_milestone.iid, other_milestone.iid] + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(2) + expect(json_response.map{ |m| m['id'] }).to match_array([closed_milestone.id, other_milestone.id]) + end + + it 'does not return any milestone if none found' do + get api("/projects/#{project.id}/milestones", user), iids: [Milestone.maximum(:iid).succ] + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(0) + end + end + + describe 'GET /projects/:id/milestones/:milestone_id' do + it 'returns a project milestone by id' do + get api("/projects/#{project.id}/milestones/#{milestone.id}", user) + + expect(response).to have_http_status(200) + expect(json_response['title']).to eq(milestone.title) + expect(json_response['iid']).to eq(milestone.iid) + end + + it 'returns a project milestone by iids array' do + get api("/projects/#{project.id}/milestones?iids=#{closed_milestone.iid}", user) expect(response.status).to eq 200 expect(response).to include_pagination_headers @@ -56,21 +85,22 @@ describe API::Milestones, api: true do expect(json_response.first['id']).to eq closed_milestone.id end - it 'returns a project milestone by iid array' do - get api("/projects/#{project.id}/milestones", user), iid: [milestone.iid, closed_milestone.iid] + it 'returns a project milestone by searching for title' do + get api("/projects/#{project.id}/milestones", user), search: 'version2' expect(response).to have_http_status(200) - expect(json_response.size).to eq(2) + expect(response).to include_pagination_headers + expect(json_response.size).to eq(1) expect(json_response.first['title']).to eq milestone.title expect(json_response.first['id']).to eq milestone.id end - it 'returns a project milestone by iid array' do - get api("/projects/#{project.id}/milestones", user), iid: [milestone.iid, closed_milestone.iid] + it 'returns a project milestones by searching for description' do + get api("/projects/#{project.id}/milestones", user), search: 'open' expect(response).to have_http_status(200) expect(response).to include_pagination_headers - expect(json_response.size).to eq(2) + expect(json_response.size).to eq(1) expect(json_response.first['title']).to eq milestone.title expect(json_response.first['id']).to eq milestone.id end @@ -197,6 +227,13 @@ describe API::Milestones, api: true do expect(json_response.first['milestone']['title']).to eq(milestone.title) end + it 'matches V4 response schema for a list of issues' do + get api("/projects/#{project.id}/milestones/#{milestone.id}/issues", user) + + expect(response).to have_http_status(200) + expect(response).to match_response_schema('public_api/v4/issues') + end + it 'returns a 401 error if user not authenticated' do get api("/projects/#{project.id}/milestones/#{milestone.id}/issues") @@ -206,8 +243,8 @@ describe API::Milestones, api: true do describe 'confidential issues' do let(:public_project) { create(:empty_project, :public) } let(:milestone) { create(:milestone, project: public_project) } - let(:issue) { create(:issue, project: public_project) } - let(:confidential_issue) { create(:issue, confidential: true, project: public_project) } + let(:issue) { create(:issue, project: public_project, position: 2) } + let(:confidential_issue) { create(:issue, confidential: true, project: public_project, position: 1) } before do public_project.team << [user, :developer] @@ -246,11 +283,24 @@ describe API::Milestones, api: true do expect(json_response.size).to eq(1) expect(json_response.map { |issue| issue['id'] }).to include(issue.id) end + + it 'returns issues ordered by position asc' do + get api("/projects/#{public_project.id}/milestones/#{milestone.id}/issues", user) + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.size).to eq(2) + expect(json_response.first['id']).to eq(confidential_issue.id) + expect(json_response.second['id']).to eq(issue.id) + end end end describe 'GET /projects/:id/milestones/:milestone_id/merge_requests' do - let(:merge_request) { create(:merge_request, source_project: project) } + let(:merge_request) { create(:merge_request, source_project: project, position: 2) } + let(:another_merge_request) { create(:merge_request, :simple, source_project: project, position: 1) } + before do milestone.merge_requests << merge_request end @@ -283,5 +333,18 @@ describe API::Milestones, api: true do expect(response).to have_http_status(401) end + + it 'returns merge_requests ordered by position asc' do + milestone.merge_requests << another_merge_request + + get api("/projects/#{project.id}/milestones/#{milestone.id}/merge_requests", user) + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.size).to eq(2) + expect(json_response.first['id']).to eq(another_merge_request.id) + expect(json_response.second['id']).to eq(merge_request.id) + end end end diff --git a/spec/requests/api/notes_spec.rb b/spec/requests/api/notes_spec.rb index 3cca4468be7..347f8f6fa3b 100644 --- a/spec/requests/api/notes_spec.rb +++ b/spec/requests/api/notes_spec.rb @@ -225,11 +225,11 @@ describe API::Notes, api: true do context 'when the user is posting an award emoji on an issue created by someone else' do let(:issue2) { create(:issue, project: project) } - it 'returns an award emoji' do + it 'creates a new issue note' do post api("/projects/#{project.id}/issues/#{issue2.id}/notes", user), body: ':+1:' expect(response).to have_http_status(201) - expect(json_response['awardable_id']).to eq issue2.id + expect(json_response['body']).to eq(':+1:') end end @@ -373,7 +373,7 @@ describe API::Notes, api: true do delete api("/projects/#{project.id}/issues/#{issue.id}/"\ "notes/#{issue_note.id}", user) - expect(response).to have_http_status(200) + expect(response).to have_http_status(204) # Check if note is really deleted delete api("/projects/#{project.id}/issues/#{issue.id}/"\ "notes/#{issue_note.id}", user) @@ -392,7 +392,7 @@ describe API::Notes, api: true do delete api("/projects/#{project.id}/snippets/#{snippet.id}/"\ "notes/#{snippet_note.id}", user) - expect(response).to have_http_status(200) + expect(response).to have_http_status(204) # Check if note is really deleted delete api("/projects/#{project.id}/snippets/#{snippet.id}/"\ "notes/#{snippet_note.id}", user) @@ -412,7 +412,7 @@ describe API::Notes, api: true do delete api("/projects/#{project.id}/merge_requests/"\ "#{merge_request.id}/notes/#{merge_request_note.id}", user) - expect(response).to have_http_status(200) + expect(response).to have_http_status(204) # Check if note is really deleted delete api("/projects/#{project.id}/merge_requests/"\ "#{merge_request.id}/notes/#{merge_request_note.id}", user) diff --git a/spec/requests/api/oauth_tokens_spec.rb b/spec/requests/api/oauth_tokens_spec.rb index 7e2cc50e591..367225df717 100644 --- a/spec/requests/api/oauth_tokens_spec.rb +++ b/spec/requests/api/oauth_tokens_spec.rb @@ -29,5 +29,27 @@ describe API::API, api: true do expect(json_response['access_token']).not_to be_nil end end + + context "when user is blocked" do + it "does not create an access token" do + user = create(:user) + user.block + + request_oauth_token(user) + + expect(response).to have_http_status(401) + end + end + + context "when user is ldap_blocked" do + it "does not create an access token" do + user = create(:user) + user.ldap_block + + request_oauth_token(user) + + expect(response).to have_http_status(401) + end + end end end diff --git a/spec/requests/api/pipelines_spec.rb b/spec/requests/api/pipelines_spec.rb index 98d004b572e..51af999b455 100644 --- a/spec/requests/api/pipelines_spec.rb +++ b/spec/requests/api/pipelines_spec.rb @@ -24,6 +24,7 @@ describe API::Pipelines, api: true do expect(json_response).to be_an Array expect(json_response.first['sha']).to match /\A\h{40}\z/ expect(json_response.first['id']).to eq pipeline.id + expect(json_response.first.keys).to contain_exactly(*%w[id sha ref status]) end end diff --git a/spec/requests/api/project_hooks_spec.rb b/spec/requests/api/project_hooks_spec.rb index 20c76bd2c05..b1f8c249092 100644 --- a/spec/requests/api/project_hooks_spec.rb +++ b/spec/requests/api/project_hooks_spec.rb @@ -33,7 +33,7 @@ describe API::ProjectHooks, 'ProjectHooks', api: true do expect(json_response.first['merge_requests_events']).to eq(true) expect(json_response.first['tag_push_events']).to eq(true) expect(json_response.first['note_events']).to eq(true) - expect(json_response.first['build_events']).to eq(true) + expect(json_response.first['job_events']).to eq(true) expect(json_response.first['pipeline_events']).to eq(true) expect(json_response.first['wiki_page_events']).to eq(true) expect(json_response.first['enable_ssl_verification']).to eq(true) @@ -59,7 +59,7 @@ describe API::ProjectHooks, 'ProjectHooks', api: true do expect(json_response['merge_requests_events']).to eq(hook.merge_requests_events) expect(json_response['tag_push_events']).to eq(hook.tag_push_events) expect(json_response['note_events']).to eq(hook.note_events) - expect(json_response['build_events']).to eq(hook.build_events) + expect(json_response['job_events']).to eq(hook.build_events) expect(json_response['pipeline_events']).to eq(hook.pipeline_events) expect(json_response['wiki_page_events']).to eq(hook.wiki_page_events) expect(json_response['enable_ssl_verification']).to eq(hook.enable_ssl_verification) @@ -98,7 +98,7 @@ describe API::ProjectHooks, 'ProjectHooks', api: true do expect(json_response['merge_requests_events']).to eq(false) expect(json_response['tag_push_events']).to eq(false) expect(json_response['note_events']).to eq(false) - expect(json_response['build_events']).to eq(false) + expect(json_response['job_events']).to eq(false) expect(json_response['pipeline_events']).to eq(false) expect(json_response['wiki_page_events']).to eq(true) expect(json_response['enable_ssl_verification']).to eq(true) @@ -144,7 +144,7 @@ describe API::ProjectHooks, 'ProjectHooks', api: true do expect(json_response['merge_requests_events']).to eq(hook.merge_requests_events) expect(json_response['tag_push_events']).to eq(hook.tag_push_events) expect(json_response['note_events']).to eq(hook.note_events) - expect(json_response['build_events']).to eq(hook.build_events) + expect(json_response['job_events']).to eq(hook.build_events) expect(json_response['pipeline_events']).to eq(hook.pipeline_events) expect(json_response['wiki_page_events']).to eq(hook.wiki_page_events) expect(json_response['enable_ssl_verification']).to eq(hook.enable_ssl_verification) @@ -183,13 +183,9 @@ describe API::ProjectHooks, 'ProjectHooks', api: true do it "deletes hook from project" do expect do delete api("/projects/#{project.id}/hooks/#{hook.id}", user) - end.to change {project.hooks.count}.by(-1) - expect(response).to have_http_status(200) - end - it "returns success when deleting hook" do - delete api("/projects/#{project.id}/hooks/#{hook.id}", user) - expect(response).to have_http_status(200) + expect(response).to have_http_status(204) + end.to change {project.hooks.count}.by(-1) end it "returns a 404 error when deleting non existent hook" do diff --git a/spec/requests/api/project_snippets_spec.rb b/spec/requests/api/project_snippets_spec.rb index da9df56401b..9e88c19b0bc 100644 --- a/spec/requests/api/project_snippets_spec.rb +++ b/spec/requests/api/project_snippets_spec.rb @@ -44,7 +44,7 @@ describe API::ProjectSnippets, api: true do title: 'Test Title', file_name: 'test.rb', code: 'puts "hello world"', - visibility_level: Snippet::PUBLIC + visibility: 'public' } end @@ -56,7 +56,7 @@ describe API::ProjectSnippets, api: true do expect(snippet.content).to eq(params[:code]) expect(snippet.title).to eq(params[:title]) expect(snippet.file_name).to eq(params[:file_name]) - expect(snippet.visibility_level).to eq(params[:visibility_level]) + expect(snippet.visibility_level).to eq(Snippet::PUBLIC) end it 'returns 400 for missing parameters' do @@ -80,14 +80,14 @@ describe API::ProjectSnippets, api: true do context 'when the snippet is private' do it 'creates the snippet' do - expect { create_snippet(project, visibility_level: Snippet::PRIVATE) }. + expect { create_snippet(project, visibility: 'private') }. to change { Snippet.count }.by(1) end end context 'when the snippet is public' do - it 'rejects the shippet' do - expect { create_snippet(project, visibility_level: Snippet::PUBLIC) }. + it 'rejects the snippet' do + expect { create_snippet(project, visibility: 'public') }. not_to change { Snippet.count } expect(response).to have_http_status(400) @@ -95,7 +95,7 @@ describe API::ProjectSnippets, api: true do end it 'creates a spam log' do - expect { create_snippet(project, visibility_level: Snippet::PUBLIC) }. + expect { create_snippet(project, visibility: 'public') }. to change { SpamLog.count }.by(1) end end @@ -165,7 +165,7 @@ describe API::ProjectSnippets, api: true do let(:visibility_level) { Snippet::PRIVATE } it 'rejects the snippet' do - expect { update_snippet(title: 'Foo', visibility_level: Snippet::PUBLIC) }. + expect { update_snippet(title: 'Foo', visibility: 'public') }. not_to change { snippet.reload.title } expect(response).to have_http_status(400) @@ -173,7 +173,7 @@ describe API::ProjectSnippets, api: true do end it 'creates a spam log' do - expect { update_snippet(title: 'Foo', visibility_level: Snippet::PUBLIC) }. + expect { update_snippet(title: 'Foo', visibility: 'public') }. to change { SpamLog.count }.by(1) end end @@ -189,7 +189,7 @@ describe API::ProjectSnippets, api: true do delete api("/projects/#{snippet.project.id}/snippets/#{snippet.id}/", admin) - expect(response).to have_http_status(200) + expect(response).to have_http_status(204) end it 'returns 404 for invalid snippet id' do @@ -212,7 +212,7 @@ describe API::ProjectSnippets, api: true do end it 'returns 404 for invalid snippet id' do - delete api("/projects/#{snippet.project.id}/snippets/1234", admin) + get api("/projects/#{snippet.project.id}/snippets/1234/raw", admin) expect(response).to have_http_status(404) expect(json_response['message']).to eq('404 Snippet Not Found') diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index 4e90aae9279..c481b7e72b1 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- require 'spec_helper' -describe API::Projects, api: true do - include ApiHelpers +describe API::Projects, :api do include Gitlab::CurrentSettings + let(:user) { create(:user) } let(:user2) { create(:user) } let(:user3) { create(:user) } @@ -43,9 +43,10 @@ describe API::Projects, api: true do describe 'GET /projects' do shared_examples_for 'projects response' do it 'returns an array of projects' do - get api('/projects', current_user) + get api('/projects', current_user), filter expect(response).to have_http_status(200) + expect(response).to include_pagination_headers expect(json_response).to be_an Array expect(json_response.map { |p| p['id'] }).to contain_exactly(*projects.map(&:id)) end @@ -61,6 +62,7 @@ describe API::Projects, api: true do context 'when unauthenticated' do it_behaves_like 'projects response' do + let(:filter) { {} } let(:current_user) { nil } let(:projects) { [public_project] } end @@ -68,6 +70,7 @@ describe API::Projects, api: true do context 'when authenticated as regular user' do it_behaves_like 'projects response' do + let(:filter) { {} } let(:current_user) { user } let(:projects) { [public_project, project, project2, project3] } end @@ -121,7 +124,7 @@ describe API::Projects, api: true do context 'and with simple=true' do it 'returns a simplified version of all the projects' do - expected_keys = ["id", "http_url_to_repo", "web_url", "name", "name_with_namespace", "path", "path_with_namespace"] + expected_keys = %w(id http_url_to_repo web_url name name_with_namespace path path_with_namespace) get api('/projects?simple=true', user) @@ -133,13 +136,18 @@ describe API::Projects, api: true do end context 'and using search' do - it 'returns searched project' do - get api('/projects', user), { search: project.name } + it_behaves_like 'projects response' do + let(:filter) { { search: project.name } } + let(:current_user) { user } + let(:projects) { [project] } + end + end - expect(response).to have_http_status(200) - expect(response).to include_pagination_headers - expect(json_response).to be_an Array - expect(json_response.length).to eq(1) + context 'and membership=true' do + it_behaves_like 'projects response' do + let(:filter) { { membership: true } } + let(:current_user) { user } + let(:projects) { [project, project2, project3] } end end @@ -216,36 +224,52 @@ describe API::Projects, api: true do end context 'and with all query parameters' do - # | | project5 | project6 | project7 | project8 | project9 | - # |---------+----------+----------+----------+----------+----------| - # | search | x | | x | x | x | - # | starred | x | x | | x | x | - # | public | x | x | x | | x | - # | owned | x | x | x | x | | - let!(:project5) { create(:empty_project, :public, path: 'gitlab5', namespace: user.namespace) } + let!(:project5) { create(:empty_project, :public, path: 'gitlab5', namespace: create(:namespace)) } let!(:project6) { create(:empty_project, :public, path: 'project6', namespace: user.namespace) } let!(:project7) { create(:empty_project, :public, path: 'gitlab7', namespace: user.namespace) } let!(:project8) { create(:empty_project, path: 'gitlab8', namespace: user.namespace) } let!(:project9) { create(:empty_project, :public, path: 'gitlab9') } before do - user.update_attributes(starred_projects: [project5, project6, project8, project9]) + user.update_attributes(starred_projects: [project5, project7, project8, project9]) end - it 'returns only projects that satify all query parameters' do - get api('/projects', user), { visibility: 'public', owned: true, starred: true, search: 'gitlab' } + context 'including owned filter' do + it 'returns only projects that satisfy all query parameters' do + get api('/projects', user), { visibility: 'public', owned: true, starred: true, search: 'gitlab' } - expect(response).to have_http_status(200) - expect(response).to include_pagination_headers - expect(json_response).to be_an Array - expect(json_response.size).to eq(1) - expect(json_response.first['id']).to eq(project5.id) + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.size).to eq(1) + expect(json_response.first['id']).to eq(project7.id) + end + end + + context 'including membership filter' do + before do + create(:project_member, + user: user, + project: project5, + access_level: ProjectMember::MASTER) + end + + it 'returns only projects that satisfy all query parameters' do + get api('/projects', user), { visibility: 'public', membership: true, starred: true, search: 'gitlab' } + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.size).to eq(2) + expect(json_response.map { |project| project['id'] }).to contain_exactly(project5.id, project7.id) + end end end end context 'when authenticated as a different user' do it_behaves_like 'projects response' do + let(:filter) { {} } let(:current_user) { user2 } let(:projects) { [public_project] } end @@ -253,6 +277,7 @@ describe API::Projects, api: true do context 'when authenticated as admin' do it_behaves_like 'projects response' do + let(:filter) { {} } let(:current_user) { admin } let(:projects) { Project.all } end @@ -269,10 +294,37 @@ describe API::Projects, api: true do end end - it 'creates new project without path and return 201' do - expect { post api('/projects', user), name: 'foo' }. + it 'creates new project without path but with name and returns 201' do + expect { post api('/projects', user), name: 'Foo Project' }. to change { Project.count }.by(1) expect(response).to have_http_status(201) + + project = Project.first + + expect(project.name).to eq('Foo Project') + expect(project.path).to eq('foo-project') + end + + it 'creates new project without name but with path and returns 201' do + expect { post api('/projects', user), path: 'foo_project' }. + to change { Project.count }.by(1) + expect(response).to have_http_status(201) + + project = Project.first + + expect(project.name).to eq('foo_project') + expect(project.path).to eq('foo_project') + end + + it 'creates new project name and path and returns 201' do + expect { post api('/projects', user), path: 'foo-Project', name: 'Foo Project' }. + to change { Project.count }.by(1) + expect(response).to have_http_status(201) + + project = Project.first + + expect(project.name).to eq('Foo Project') + expect(project.path).to eq('foo-Project') end it 'creates last project before reaching project limit' do @@ -281,7 +333,7 @@ describe API::Projects, api: true do expect(response).to have_http_status(201) end - it 'does not create new project without name and return 400' do + it 'does not create new project without name or path and returns 400' do expect { post api('/projects', user) }.not_to change { Project.count } expect(response).to have_http_status(400) end @@ -293,7 +345,7 @@ describe API::Projects, api: true do issues_enabled: false, merge_requests_enabled: false, wiki_enabled: false, - only_allow_merge_if_build_succeeds: false, + only_allow_merge_if_pipeline_succeeds: false, request_access_enabled: true, only_allow_merge_if_all_discussions_are_resolved: false }) @@ -313,36 +365,39 @@ describe API::Projects, api: true do end it 'sets a project as public' do - project = attributes_for(:project, :public) + project = attributes_for(:project, visibility: 'public') + post api('/projects', user), project - expect(json_response['public']).to be_truthy - expect(json_response['visibility_level']).to eq(Gitlab::VisibilityLevel::PUBLIC) + + expect(json_response['visibility']).to eq('public') end it 'sets a project as internal' do - project = attributes_for(:project, :internal) + project = attributes_for(:project, visibility: 'internal') + post api('/projects', user), project - expect(json_response['public']).to be_falsey - expect(json_response['visibility_level']).to eq(Gitlab::VisibilityLevel::INTERNAL) + + expect(json_response['visibility']).to eq('internal') end it 'sets a project as private' do - project = attributes_for(:project, :private) + project = attributes_for(:project, visibility: 'private') + post api('/projects', user), project - expect(json_response['public']).to be_falsey - expect(json_response['visibility_level']).to eq(Gitlab::VisibilityLevel::PRIVATE) + + expect(json_response['visibility']).to eq('private') end it 'sets a project as allowing merge even if build fails' do - project = attributes_for(:project, { only_allow_merge_if_build_succeeds: false }) + project = attributes_for(:project, { only_allow_merge_if_pipeline_succeeds: false }) post api('/projects', user), project - expect(json_response['only_allow_merge_if_build_succeeds']).to be_falsey + expect(json_response['only_allow_merge_if_pipeline_succeeds']).to be_falsey end - it 'sets a project as allowing merge only if build succeeds' do - project = attributes_for(:project, { only_allow_merge_if_build_succeeds: true }) + it 'sets a project as allowing merge only if merge_when_pipeline_succeeds' do + project = attributes_for(:project, { only_allow_merge_if_pipeline_succeeds: true }) post api('/projects', user), project - expect(json_response['only_allow_merge_if_build_succeeds']).to be_truthy + expect(json_response['only_allow_merge_if_pipeline_succeeds']).to be_truthy end it 'sets a project as allowing merge even if discussions are unresolved' do @@ -369,8 +424,16 @@ describe API::Projects, api: true do expect(json_response['only_allow_merge_if_all_discussions_are_resolved']).to be_truthy end + it 'ignores import_url when it is nil' do + project = attributes_for(:project, { import_url: nil }) + + post api('/projects', user), project + + expect(response).to have_http_status(201) + end + context 'when a visibility level is restricted' do - let(:project_param) { attributes_for(:project, :public) } + let(:project_param) { attributes_for(:project, visibility: 'public') } before do stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::PUBLIC]) @@ -388,10 +451,7 @@ describe API::Projects, api: true do it 'allows an admin to override restricted visibility settings' do post api('/projects', admin), project_param - expect(json_response['public']).to be_truthy - expect(json_response['visibility_level']).to( - eq(Gitlab::VisibilityLevel::PUBLIC) - ) + expect(json_response['visibility']).to eq('public') end end end @@ -432,40 +492,41 @@ describe API::Projects, api: true do end it 'sets a project as public' do - project = attributes_for(:project, :public) + project = attributes_for(:project, visibility: 'public') + post api("/projects/user/#{user.id}", admin), project expect(response).to have_http_status(201) - expect(json_response['public']).to be_truthy - expect(json_response['visibility_level']).to eq(Gitlab::VisibilityLevel::PUBLIC) + expect(json_response['visibility']).to eq('public') end it 'sets a project as internal' do - project = attributes_for(:project, :internal) + project = attributes_for(:project, visibility: 'internal') + post api("/projects/user/#{user.id}", admin), project expect(response).to have_http_status(201) - expect(json_response['public']).to be_falsey - expect(json_response['visibility_level']).to eq(Gitlab::VisibilityLevel::INTERNAL) + expect(json_response['visibility']).to eq('internal') end it 'sets a project as private' do - project = attributes_for(:project, :private) + project = attributes_for(:project, visibility: 'private') + post api("/projects/user/#{user.id}", admin), project - expect(json_response['public']).to be_falsey - expect(json_response['visibility_level']).to eq(Gitlab::VisibilityLevel::PRIVATE) + + expect(json_response['visibility']).to eq('private') end it 'sets a project as allowing merge even if build fails' do - project = attributes_for(:project, { only_allow_merge_if_build_succeeds: false }) + project = attributes_for(:project, { only_allow_merge_if_pipeline_succeeds: false }) post api("/projects/user/#{user.id}", admin), project - expect(json_response['only_allow_merge_if_build_succeeds']).to be_falsey + expect(json_response['only_allow_merge_if_pipeline_succeeds']).to be_falsey end - it 'sets a project as allowing merge only if build succeeds' do - project = attributes_for(:project, { only_allow_merge_if_build_succeeds: true }) + it 'sets a project as allowing merge only if merge_when_pipeline_succeeds' do + project = attributes_for(:project, { only_allow_merge_if_pipeline_succeeds: true }) post api("/projects/user/#{user.id}", admin), project - expect(json_response['only_allow_merge_if_build_succeeds']).to be_truthy + expect(json_response['only_allow_merge_if_pipeline_succeeds']).to be_truthy end it 'sets a project as allowing merge even if discussions are unresolved' do @@ -529,9 +590,8 @@ describe API::Projects, api: true do expect(json_response['description']).to eq(project.description) expect(json_response['default_branch']).to eq(project.default_branch) expect(json_response['tag_list']).to be_an Array - expect(json_response['public']).to be_falsey expect(json_response['archived']).to be_falsey - expect(json_response['visibility_level']).to be_present + expect(json_response['visibility']).to be_present expect(json_response['ssh_url_to_repo']).to be_present expect(json_response['http_url_to_repo']).to be_present expect(json_response['web_url']).to be_present @@ -542,7 +602,7 @@ describe API::Projects, api: true do expect(json_response['issues_enabled']).to be_present expect(json_response['merge_requests_enabled']).to be_present expect(json_response['wiki_enabled']).to be_present - expect(json_response['builds_enabled']).to be_present + expect(json_response['jobs_enabled']).to be_present expect(json_response['snippets_enabled']).to be_present expect(json_response['container_registry_enabled']).to be_present expect(json_response['created_at']).to be_present @@ -553,13 +613,13 @@ describe API::Projects, api: true do expect(json_response['avatar_url']).to be_nil expect(json_response['star_count']).to be_present expect(json_response['forks_count']).to be_present - expect(json_response['public_builds']).to be_present + expect(json_response['public_jobs']).to be_present expect(json_response['shared_with_groups']).to be_an Array expect(json_response['shared_with_groups'].length).to eq(1) expect(json_response['shared_with_groups'][0]['group_id']).to eq(group.id) expect(json_response['shared_with_groups'][0]['group_name']).to eq(group.name) expect(json_response['shared_with_groups'][0]['group_access_level']).to eq(link.group_access) - expect(json_response['only_allow_merge_if_build_succeeds']).to eq(project.only_allow_merge_if_build_succeeds) + expect(json_response['only_allow_merge_if_pipeline_succeeds']).to eq(project.only_allow_merge_if_pipeline_succeeds) expect(json_response['only_allow_merge_if_all_discussions_are_resolved']).to eq(project.only_allow_merge_if_all_discussions_are_resolved) end @@ -785,8 +845,7 @@ describe API::Projects, api: true do describe 'POST /projects/:id/snippets' do it 'creates a new project snippet' do post api("/projects/#{project.id}/snippets", user), - title: 'api test', file_name: 'sample.rb', code: 'test', - visibility_level: Gitlab::VisibilityLevel::PRIVATE + title: 'api test', file_name: 'sample.rb', code: 'test', visibility: 'private' expect(response).to have_http_status(201) expect(json_response['title']).to eq('api test') end @@ -820,8 +879,9 @@ describe API::Projects, api: true do it 'deletes existing project snippet' do expect do delete api("/projects/#{project.id}/snippets/#{snippet.id}", user) + + expect(response).to have_http_status(204) end.to change { Snippet.count }.by(-1) - expect(response).to have_http_status(200) end it 'returns 404 when deleting unknown snippet id' do @@ -905,8 +965,10 @@ describe API::Projects, api: true do project_fork_target.reload expect(project_fork_target.forked_from_project).not_to be_nil expect(project_fork_target.forked?).to be_truthy + delete api("/projects/#{project_fork_target.id}/fork", admin) - expect(response).to have_http_status(200) + + expect(response).to have_http_status(204) project_fork_target.reload expect(project_fork_target.forked_from_project).to be_nil expect(project_fork_target.forked?).not_to be_truthy @@ -1035,7 +1097,7 @@ describe API::Projects, api: true do end it 'updates visibility_level' do - project_param = { visibility_level: Gitlab::VisibilityLevel::PUBLIC } + project_param = { visibility: 'public' } put api("/projects/#{project3.id}", user), project_param expect(response).to have_http_status(200) project_param.each_pair do |k, v| @@ -1045,13 +1107,13 @@ describe API::Projects, api: true do it 'updates visibility_level from public to private' do project3.update_attributes({ visibility_level: Gitlab::VisibilityLevel::PUBLIC }) - project_param = { visibility_level: Gitlab::VisibilityLevel::PRIVATE } + project_param = { visibility: 'private' } put api("/projects/#{project3.id}", user), project_param expect(response).to have_http_status(200) project_param.each_pair do |k, v| expect(json_response[k.to_s]).to eq(v) end - expect(json_response['visibility_level']).to eq(Gitlab::VisibilityLevel::PRIVATE) + expect(json_response['visibility']).to eq('private') end it 'does not update name to existing name' do @@ -1118,7 +1180,7 @@ describe API::Projects, api: true do end it 'does not update visibility_level' do - project_param = { visibility_level: Gitlab::VisibilityLevel::PUBLIC } + project_param = { visibility: 'public' } put api("/projects/#{project3.id}", user4), project_param expect(response).to have_http_status(403) end @@ -1263,7 +1325,9 @@ describe API::Projects, api: true do context 'when authenticated as user' do it 'removes project' do delete api("/projects/#{project.id}", user) - expect(response).to have_http_status(200) + + expect(response).to have_http_status(202) + expect(json_response['message']).to eql('202 Accepted') end it 'does not remove a project if not an owner' do @@ -1287,7 +1351,9 @@ describe API::Projects, api: true do context 'when authenticated as admin' do it 'removes any existing project' do delete api("/projects/#{project.id}", admin) - expect(response).to have_http_status(200) + + expect(response).to have_http_status(202) + expect(json_response['message']).to eql('202 Accepted') end it 'does not remove a non existing project' do @@ -1422,4 +1488,53 @@ describe API::Projects, api: true do end end end + + describe 'POST /projects/:id/housekeeping' do + let(:housekeeping) { Projects::HousekeepingService.new(project) } + + before do + allow(Projects::HousekeepingService).to receive(:new).with(project).and_return(housekeeping) + end + + context 'when authenticated as owner' do + it 'starts the housekeeping process' do + expect(housekeeping).to receive(:execute).once + + post api("/projects/#{project.id}/housekeeping", user) + + expect(response).to have_http_status(201) + end + + context 'when housekeeping lease is taken' do + it 'returns conflict' do + expect(housekeeping).to receive(:execute).once.and_raise(Projects::HousekeepingService::LeaseTaken) + + post api("/projects/#{project.id}/housekeeping", user) + + expect(response).to have_http_status(409) + expect(json_response['message']).to match(/Somebody already triggered housekeeping for this project/) + end + end + end + + context 'when authenticated as developer' do + before do + project_member2 + end + + it 'returns forbidden error' do + post api("/projects/#{project.id}/housekeeping", user3) + + expect(response).to have_http_status(403) + end + end + + context 'when unauthenticated' do + it 'returns authentication error' do + post api("/projects/#{project.id}/housekeeping") + + expect(response).to have_http_status(401) + end + end + end end diff --git a/spec/requests/api/repositories_spec.rb b/spec/requests/api/repositories_spec.rb index 7652606a491..4783d011d54 100644 --- a/spec/requests/api/repositories_spec.rb +++ b/spec/requests/api/repositories_spec.rb @@ -30,7 +30,7 @@ describe API::Repositories, api: true do context 'when ref does not exist' do it_behaves_like '404 response' do - let(:request) { get api("#{route}?ref_name=foo", current_user) } + let(:request) { get api("#{route}?ref=foo", current_user) } let(:message) { '404 Tree Not Found' } end end @@ -66,7 +66,7 @@ describe API::Repositories, api: true do context 'when ref does not exist' do it_behaves_like '404 response' do - let(:request) { get api("#{route}?recursive=1&ref_name=foo", current_user) } + let(:request) { get api("#{route}?recursive=1&ref=foo", current_user) } let(:message) { '404 Tree Not Found' } end end @@ -100,82 +100,70 @@ describe API::Repositories, api: true do end end - { - 'blobs/:sha' => 'blobs/master', - 'commits/:sha/blob' => 'commits/master/blob' - }.each do |desc_path, example_path| - describe "GET /projects/:id/repository/#{desc_path}" do - let(:route) { "/projects/#{project.id}/repository/#{example_path}?filepath=README.md" } + describe "GET /projects/:id/repository/blobs/:sha" do + let(:route) { "/projects/#{project.id}/repository/blobs/#{sample_blob.oid}" } - shared_examples_for 'repository blob' do - it 'returns the repository blob' do - get api(route, current_user) - - expect(response).to have_http_status(200) - end - - context 'when sha does not exist' do - it_behaves_like '404 response' do - let(:request) { get api(route.sub('master', 'invalid_branch_name'), current_user) } - let(:message) { '404 Commit Not Found' } - end - end + shared_examples_for 'repository blob' do + it 'returns blob attributes as json' do + get api(route, current_user) - context 'when filepath does not exist' do - it_behaves_like '404 response' do - let(:request) { get api(route.sub('README.md', 'README.invalid'), current_user) } - let(:message) { '404 File Not Found' } - end - end + expect(response).to have_http_status(200) + expect(json_response['size']).to eq(111) + expect(json_response['encoding']).to eq("base64") + expect(Base64.decode64(json_response['content']).lines.first).to eq("class Commit\n") + expect(json_response['sha']).to eq(sample_blob.oid) + end - context 'when no filepath is given' do - it_behaves_like '400 response' do - let(:request) { get api(route.sub('?filepath=README.md', ''), current_user) } - end + context 'when sha does not exist' do + it_behaves_like '404 response' do + let(:request) { get api(route.sub(sample_blob.oid, '123456'), current_user) } + let(:message) { '404 Blob Not Found' } end + end - context 'when repository is disabled' do - include_context 'disabled repository' + context 'when repository is disabled' do + include_context 'disabled repository' - it_behaves_like '403 response' do - let(:request) { get api(route, current_user) } - end + it_behaves_like '403 response' do + let(:request) { get api(route, current_user) } end end + end - context 'when unauthenticated', 'and project is public' do - it_behaves_like 'repository blob' do - let(:project) { create(:project, :public, :repository) } - let(:current_user) { nil } - end + context 'when unauthenticated', 'and project is public' do + it_behaves_like 'repository blob' do + let(:project) { create(:project, :public, :repository) } + let(:current_user) { nil } end + end - context 'when unauthenticated', 'and project is private' do - it_behaves_like '404 response' do - let(:request) { get api(route) } - let(:message) { '404 Project Not Found' } - end + context 'when unauthenticated', 'and project is private' do + it_behaves_like '404 response' do + let(:request) { get api(route) } + let(:message) { '404 Project Not Found' } end + end - context 'when authenticated', 'as a developer' do - it_behaves_like 'repository blob' do - let(:current_user) { user } - end + context 'when authenticated', 'as a developer' do + it_behaves_like 'repository blob' do + let(:current_user) { user } end + end - context 'when authenticated', 'as a guest' do - it_behaves_like '403 response' do - let(:request) { get api(route, guest) } - end + context 'when authenticated', 'as a guest' do + it_behaves_like '403 response' do + let(:request) { get api(route, guest) } end end end - describe "GET /projects/:id/repository/raw_blobs/:sha" do - let(:route) { "/projects/#{project.id}/repository/raw_blobs/#{sample_blob.oid}" } + describe "GET /projects/:id/repository/blobs/:sha/raw" do + let(:route) { "/projects/#{project.id}/repository/blobs/#{sample_blob.oid}/raw" } shared_examples_for 'repository raw blob' do it 'returns the repository raw blob' do + expect(Gitlab::Workhorse).to receive(:send_git_blob) + get api(route, current_user) expect(response).to have_http_status(200) diff --git a/spec/requests/api/runner_spec.rb b/spec/requests/api/runner_spec.rb new file mode 100644 index 00000000000..044b989e5ba --- /dev/null +++ b/spec/requests/api/runner_spec.rb @@ -0,0 +1,1072 @@ +require 'spec_helper' + +describe API::Runner do + include ApiHelpers + include StubGitlabCalls + + let(:registration_token) { 'abcdefg123456' } + + before do + stub_gitlab_calls + stub_application_setting(runners_registration_token: registration_token) + end + + describe '/api/v4/runners' do + describe 'POST /api/v4/runners' do + context 'when no token is provided' do + it 'returns 400 error' do + post api('/runners') + + expect(response).to have_http_status 400 + end + end + + context 'when invalid token is provided' do + it 'returns 403 error' do + post api('/runners'), token: 'invalid' + + expect(response).to have_http_status 403 + end + end + + context 'when valid token is provided' do + it 'creates runner with default values' do + post api('/runners'), token: registration_token + + runner = Ci::Runner.first + + expect(response).to have_http_status 201 + expect(json_response['id']).to eq(runner.id) + expect(json_response['token']).to eq(runner.token) + expect(runner.run_untagged).to be true + expect(runner.token).not_to eq(registration_token) + end + + context 'when project token is used' do + let(:project) { create(:empty_project) } + + it 'creates runner' do + post api('/runners'), token: project.runners_token + + expect(response).to have_http_status 201 + expect(project.runners.size).to eq(1) + expect(Ci::Runner.first.token).not_to eq(registration_token) + expect(Ci::Runner.first.token).not_to eq(project.runners_token) + end + end + end + + context 'when runner description is provided' do + it 'creates runner' do + post api('/runners'), token: registration_token, + description: 'server.hostname' + + expect(response).to have_http_status 201 + expect(Ci::Runner.first.description).to eq('server.hostname') + end + end + + context 'when runner tags are provided' do + it 'creates runner' do + post api('/runners'), token: registration_token, + tag_list: 'tag1, tag2' + + expect(response).to have_http_status 201 + expect(Ci::Runner.first.tag_list.sort).to eq(%w(tag1 tag2)) + end + end + + context 'when option for running untagged jobs is provided' do + context 'when tags are provided' do + it 'creates runner' do + post api('/runners'), token: registration_token, + run_untagged: false, + tag_list: ['tag'] + + expect(response).to have_http_status 201 + expect(Ci::Runner.first.run_untagged).to be false + expect(Ci::Runner.first.tag_list.sort).to eq(['tag']) + end + end + + context 'when tags are not provided' do + it 'returns 404 error' do + post api('/runners'), token: registration_token, + run_untagged: false + + expect(response).to have_http_status 404 + end + end + end + + context 'when option for locking Runner is provided' do + it 'creates runner' do + post api('/runners'), token: registration_token, + locked: true + + expect(response).to have_http_status 201 + expect(Ci::Runner.first.locked).to be true + end + end + + %w(name version revision platform architecture).each do |param| + context "when info parameter '#{param}' info is present" do + let(:value) { "#{param}_value" } + + it "updates provided Runner's parameter" do + post api('/runners'), token: registration_token, + info: { param => value } + + expect(response).to have_http_status 201 + expect(Ci::Runner.first.read_attribute(param.to_sym)).to eq(value) + end + end + end + end + + describe 'DELETE /api/v4/runners' do + context 'when no token is provided' do + it 'returns 400 error' do + delete api('/runners') + + expect(response).to have_http_status 400 + end + end + + context 'when invalid token is provided' do + it 'returns 403 error' do + delete api('/runners'), token: 'invalid' + + expect(response).to have_http_status 403 + end + end + + context 'when valid token is provided' do + let(:runner) { create(:ci_runner) } + + it 'deletes Runner' do + delete api('/runners'), token: runner.token + + expect(response).to have_http_status 204 + expect(Ci::Runner.count).to eq(0) + end + end + end + + describe 'POST /api/v4/runners/verify' do + let(:runner) { create(:ci_runner) } + + context 'when no token is provided' do + it 'returns 400 error' do + post api('/runners/verify') + + expect(response).to have_http_status :bad_request + end + end + + context 'when invalid token is provided' do + it 'returns 403 error' do + post api('/runners/verify'), token: 'invalid-token' + + expect(response).to have_http_status 403 + end + end + + context 'when valid token is provided' do + it 'verifies Runner credentials' do + post api('/runners/verify'), token: runner.token + + expect(response).to have_http_status 200 + end + end + end + end + + describe '/api/v4/jobs' do + let(:project) { create(:empty_project, shared_runners_enabled: false) } + let(:pipeline) { create(:ci_pipeline_without_jobs, project: project, ref: 'master') } + let(:runner) { create(:ci_runner) } + let!(:job) do + create(:ci_build, :artifacts, :extended_options, + pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0, commands: "ls\ndate") + end + + before { project.runners << runner } + + describe 'POST /api/v4/jobs/request' do + let!(:last_update) {} + let!(:new_update) { } + let(:user_agent) { 'gitlab-runner 9.0.0 (9-0-stable; go1.7.4; linux/amd64)' } + + before { stub_container_registry_config(enabled: false) } + + shared_examples 'no jobs available' do + before { request_job } + + context 'when runner sends version in User-Agent' do + context 'for stable version' do + it 'gives 204 and set X-GitLab-Last-Update' do + expect(response).to have_http_status(204) + expect(response.header).to have_key('X-GitLab-Last-Update') + end + end + + context 'when last_update is up-to-date' do + let(:last_update) { runner.ensure_runner_queue_value } + + it 'gives 204 and set the same X-GitLab-Last-Update' do + expect(response).to have_http_status(204) + expect(response.header['X-GitLab-Last-Update']).to eq(last_update) + end + end + + context 'when last_update is outdated' do + let(:last_update) { runner.ensure_runner_queue_value } + let(:new_update) { runner.tick_runner_queue } + + it 'gives 204 and set a new X-GitLab-Last-Update' do + expect(response).to have_http_status(204) + expect(response.header['X-GitLab-Last-Update']).to eq(new_update) + end + end + + context 'when beta version is sent' do + let(:user_agent) { 'gitlab-runner 9.0.0~beta.167.g2b2bacc (master; go1.7.4; linux/amd64)' } + + it { expect(response).to have_http_status(204) } + end + + context 'when pre-9-0 version is sent' do + let(:user_agent) { 'gitlab-ci-multi-runner 1.6.0 (1-6-stable; go1.6.3; linux/amd64)' } + + it { expect(response).to have_http_status(204) } + end + + context 'when pre-9-0 beta version is sent' do + let(:user_agent) { 'gitlab-ci-multi-runner 1.6.0~beta.167.g2b2bacc (master; go1.6.3; linux/amd64)' } + + it { expect(response).to have_http_status(204) } + end + end + end + + context 'when no token is provided' do + it 'returns 400 error' do + post api('/jobs/request') + + expect(response).to have_http_status 400 + end + end + + context 'when invalid token is provided' do + it 'returns 403 error' do + post api('/jobs/request'), token: 'invalid' + + expect(response).to have_http_status 403 + end + end + + context 'when valid token is provided' do + context 'when Runner is not active' do + let(:runner) { create(:ci_runner, :inactive) } + + it 'returns 204 error' do + request_job + + expect(response).to have_http_status 204 + end + end + + context 'when jobs are finished' do + before { job.success } + + it_behaves_like 'no jobs available' + end + + context 'when other projects have pending jobs' do + before do + job.success + create(:ci_build, :pending) + end + + it_behaves_like 'no jobs available' + end + + context 'when shared runner requests job for project without shared_runners_enabled' do + let(:runner) { create(:ci_runner, :shared) } + + it_behaves_like 'no jobs available' + end + + context 'when there is a pending job' do + let(:expected_job_info) do + { 'name' => job.name, + 'stage' => job.stage, + 'project_id' => job.project.id, + 'project_name' => job.project.name } + end + + let(:expected_git_info) do + { 'repo_url' => job.repo_url, + 'ref' => job.ref, + 'sha' => job.sha, + 'before_sha' => job.before_sha, + 'ref_type' => 'branch' } + end + + let(:expected_steps) do + [{ 'name' => 'script', + 'script' => %w(ls date), + 'timeout' => job.timeout, + 'when' => 'on_success', + 'allow_failure' => false }, + { 'name' => 'after_script', + 'script' => %w(ls date), + 'timeout' => job.timeout, + 'when' => 'always', + 'allow_failure' => true }] + end + + let(:expected_variables) do + [{ 'key' => 'CI_JOB_NAME', 'value' => 'spinach', 'public' => true }, + { 'key' => 'CI_JOB_STAGE', 'value' => 'test', 'public' => true }, + { 'key' => 'DB_NAME', 'value' => 'postgres', 'public' => true }] + end + + let(:expected_artifacts) do + [{ 'name' => 'artifacts_file', + 'untracked' => false, + 'paths' => %w(out/), + 'when' => 'always', + 'expire_in' => '7d' }] + end + + let(:expected_cache) do + [{ 'key' => 'cache_key', + 'untracked' => false, + 'paths' => ['vendor/*'] }] + end + + it 'picks a job' do + request_job info: { platform: :darwin } + + expect(response).to have_http_status(201) + expect(response.headers).not_to have_key('X-GitLab-Last-Update') + expect(runner.reload.platform).to eq('darwin') + expect(json_response['id']).to eq(job.id) + expect(json_response['token']).to eq(job.token) + expect(json_response['job_info']).to eq(expected_job_info) + expect(json_response['git_info']).to eq(expected_git_info) + expect(json_response['image']).to eq({ 'name' => 'ruby:2.1' }) + expect(json_response['services']).to eq([{ 'name' => 'postgres' }]) + expect(json_response['steps']).to eq(expected_steps) + expect(json_response['artifacts']).to eq(expected_artifacts) + expect(json_response['cache']).to eq(expected_cache) + expect(json_response['variables']).to include(*expected_variables) + end + + context 'when job is made for tag' do + let!(:job) { create(:ci_build_tag, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) } + + it 'sets branch as ref_type' do + request_job + + expect(response).to have_http_status(201) + expect(json_response['git_info']['ref_type']).to eq('tag') + end + end + + context 'when job is made for branch' do + it 'sets tag as ref_type' do + request_job + + expect(response).to have_http_status(201) + expect(json_response['git_info']['ref_type']).to eq('branch') + end + end + + it 'updates runner info' do + expect { request_job }.to change { runner.reload.contacted_at } + end + + %w(name version revision platform architecture).each do |param| + context "when info parameter '#{param}' is present" do + let(:value) { "#{param}_value" } + + it "updates provided Runner's parameter" do + request_job info: { param => value } + + expect(response).to have_http_status(201) + expect(runner.reload.read_attribute(param.to_sym)).to eq(value) + end + end + end + + context 'when concurrently updating a job' do + before do + expect_any_instance_of(Ci::Build).to receive(:run!). + and_raise(ActiveRecord::StaleObjectError.new(nil, nil)) + end + + it 'returns a conflict' do + request_job + + expect(response).to have_http_status(409) + expect(response.headers).not_to have_key('X-GitLab-Last-Update') + end + end + + context 'when project and pipeline have multiple jobs' do + let!(:job) { create(:ci_build_tag, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) } + let!(:job2) { create(:ci_build_tag, pipeline: pipeline, name: 'rubocop', stage: 'test', stage_idx: 0) } + let!(:test_job) { create(:ci_build, pipeline: pipeline, name: 'deploy', stage: 'deploy', stage_idx: 1) } + + before do + job.success + job2.success + end + + it 'returns dependent jobs' do + request_job + + expect(response).to have_http_status(201) + expect(json_response['id']).to eq(test_job.id) + expect(json_response['dependencies'].count).to eq(2) + expect(json_response['dependencies']).to include({ 'id' => job.id, 'name' => job.name, 'token' => job.token }, + { 'id' => job2.id, 'name' => job2.name, 'token' => job2.token }) + end + end + + context 'when explicit dependencies are defined' do + let!(:job) { create(:ci_build_tag, pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0) } + let!(:job2) { create(:ci_build_tag, pipeline: pipeline, name: 'rubocop', stage: 'test', stage_idx: 0) } + let!(:test_job) do + create(:ci_build, pipeline: pipeline, token: 'test-job-token', name: 'deploy', + stage: 'deploy', stage_idx: 1, + options: { dependencies: [job2.name] }) + end + + before do + job.success + job2.success + end + + it 'returns dependent jobs' do + request_job + + expect(response).to have_http_status(201) + expect(json_response['id']).to eq(test_job.id) + expect(json_response['dependencies'].count).to eq(1) + expect(json_response['dependencies'][0]).to include('id' => job2.id, 'name' => job2.name, 'token' => job2.token) + end + end + + context 'when job has no tags' do + before { job.update(tags: []) } + + context 'when runner is allowed to pick untagged jobs' do + before { runner.update_column(:run_untagged, true) } + + it 'picks job' do + request_job + + expect(response).to have_http_status 201 + end + end + + context 'when runner is not allowed to pick untagged jobs' do + before { runner.update_column(:run_untagged, false) } + + it_behaves_like 'no jobs available' + end + end + + context 'when triggered job is available' do + let(:expected_variables) do + [{ 'key' => 'CI_JOB_NAME', 'value' => 'spinach', 'public' => true }, + { 'key' => 'CI_JOB_STAGE', 'value' => 'test', 'public' => true }, + { 'key' => 'CI_PIPELINE_TRIGGERED', 'value' => 'true', 'public' => true }, + { 'key' => 'DB_NAME', 'value' => 'postgres', 'public' => true }, + { 'key' => 'SECRET_KEY', 'value' => 'secret_value', 'public' => false }, + { 'key' => 'TRIGGER_KEY_1', 'value' => 'TRIGGER_VALUE_1', 'public' => false }] + end + + before do + trigger = create(:ci_trigger, project: project) + create(:ci_trigger_request_with_variables, pipeline: pipeline, builds: [job], trigger: trigger) + project.variables << Ci::Variable.new(key: 'SECRET_KEY', value: 'secret_value') + end + + it 'returns variables for triggers' do + request_job + + expect(response).to have_http_status(201) + expect(json_response['variables']).to include(*expected_variables) + end + end + + describe 'registry credentials support' do + let(:registry_url) { 'registry.example.com:5005' } + let(:registry_credentials) do + { 'type' => 'registry', + 'url' => registry_url, + 'username' => 'gitlab-ci-token', + 'password' => job.token } + end + + context 'when registry is enabled' do + before { stub_container_registry_config(enabled: true, host_port: registry_url) } + + it 'sends registry credentials key' do + request_job + + expect(json_response).to have_key('credentials') + expect(json_response['credentials']).to include(registry_credentials) + end + end + + context 'when registry is disabled' do + before { stub_container_registry_config(enabled: false, host_port: registry_url) } + + it 'does not send registry credentials' do + request_job + + expect(json_response).to have_key('credentials') + expect(json_response['credentials']).not_to include(registry_credentials) + end + end + end + end + + def request_job(token = runner.token, **params) + new_params = params.merge(token: token, last_update: last_update) + post api('/jobs/request'), new_params, { 'User-Agent' => user_agent } + end + end + end + + describe 'PUT /api/v4/jobs/:id' do + let(:job) { create(:ci_build, :pending, :trace, pipeline: pipeline, runner_id: runner.id) } + + before { job.run! } + + context 'when status is given' do + it 'mark job as succeeded' do + update_job(state: 'success') + + expect(job.reload.status).to eq 'success' + end + + it 'mark job as failed' do + update_job(state: 'failed') + + expect(job.reload.status).to eq 'failed' + end + end + + context 'when tace is given' do + it 'updates a running build' do + update_job(trace: 'BUILD TRACE UPDATED') + + expect(response).to have_http_status(200) + expect(job.reload.trace).to eq 'BUILD TRACE UPDATED' + end + end + + context 'when no trace is given' do + it 'does not override trace information' do + update_job + + expect(job.reload.trace).to eq 'BUILD TRACE' + end + end + + context 'when job has been erased' do + let(:job) { create(:ci_build, runner_id: runner.id, erased_at: Time.now) } + + it 'responds with forbidden' do + update_job + + expect(response).to have_http_status(403) + end + end + + def update_job(token = job.token, **params) + new_params = params.merge(token: token) + put api("/jobs/#{job.id}"), new_params + end + end + + describe 'PATCH /api/v4/jobs/:id/trace' do + let(:job) { create(:ci_build, :running, :trace, runner_id: runner.id, pipeline: pipeline) } + let(:headers) { { API::Helpers::Runner::JOB_TOKEN_HEADER => job.token, 'Content-Type' => 'text/plain' } } + let(:headers_with_range) { headers.merge({ 'Content-Range' => '11-20' }) } + let(:update_interval) { 10.seconds.to_i } + + before { initial_patch_the_trace } + + context 'when request is valid' do + it 'gets correct response' do + expect(response.status).to eq 202 + expect(job.reload.trace).to eq 'BUILD TRACE appended' + expect(response.header).to have_key 'Range' + expect(response.header).to have_key 'Job-Status' + end + + context 'when job has been updated recently' do + it { expect{ patch_the_trace }.not_to change { job.updated_at }} + + it "changes the job's trace" do + patch_the_trace + + expect(job.reload.trace).to eq 'BUILD TRACE appended appended' + end + + context 'when Runner makes a force-patch' do + it { expect{ force_patch_the_trace }.not_to change { job.updated_at }} + + it "doesn't change the build.trace" do + force_patch_the_trace + + expect(job.reload.trace).to eq 'BUILD TRACE appended' + end + end + end + + context 'when job was not updated recently' do + let(:update_interval) { 15.minutes.to_i } + + it { expect { patch_the_trace }.to change { job.updated_at } } + + it 'changes the job.trace' do + patch_the_trace + + expect(job.reload.trace).to eq 'BUILD TRACE appended appended' + end + + context 'when Runner makes a force-patch' do + it { expect { force_patch_the_trace }.to change { job.updated_at } } + + it "doesn't change the job.trace" do + force_patch_the_trace + + expect(job.reload.trace).to eq 'BUILD TRACE appended' + end + end + end + + context 'when project for the build has been deleted' do + let(:job) do + create(:ci_build, :running, :trace, runner_id: runner.id, pipeline: pipeline) do |job| + job.project.update(pending_delete: true) + end + end + + it 'responds with forbidden' do + expect(response.status).to eq(403) + end + end + end + + context 'when Runner makes a force-patch' do + before do + force_patch_the_trace + end + + it 'gets correct response' do + expect(response.status).to eq 202 + expect(job.reload.trace).to eq 'BUILD TRACE appended' + expect(response.header).to have_key 'Range' + expect(response.header).to have_key 'Job-Status' + end + end + + context 'when content-range start is too big' do + let(:headers_with_range) { headers.merge({ 'Content-Range' => '15-20' }) } + + it 'gets 416 error response with range headers' do + expect(response.status).to eq 416 + expect(response.header).to have_key 'Range' + expect(response.header['Range']).to eq '0-11' + end + end + + context 'when content-range start is too small' do + let(:headers_with_range) { headers.merge({ 'Content-Range' => '8-20' }) } + + it 'gets 416 error response with range headers' do + expect(response.status).to eq 416 + expect(response.header).to have_key 'Range' + expect(response.header['Range']).to eq '0-11' + end + end + + context 'when Content-Range header is missing' do + let(:headers_with_range) { headers } + + it { expect(response.status).to eq 400 } + end + + context 'when job has been errased' do + let(:job) { create(:ci_build, runner_id: runner.id, erased_at: Time.now) } + + it { expect(response.status).to eq 403 } + end + + def patch_the_trace(content = ' appended', request_headers = nil) + unless request_headers + offset = job.trace_length + limit = offset + content.length - 1 + request_headers = headers.merge({ 'Content-Range' => "#{offset}-#{limit}" }) + end + + Timecop.travel(job.updated_at + update_interval) do + patch api("/jobs/#{job.id}/trace"), content, request_headers + job.reload + end + end + + def initial_patch_the_trace + patch_the_trace(' appended', headers_with_range) + end + + def force_patch_the_trace + 2.times { patch_the_trace('') } + end + end + + describe 'artifacts' do + let(:job) { create(:ci_build, :pending, pipeline: pipeline, runner_id: runner.id) } + let(:jwt_token) { JWT.encode({ 'iss' => 'gitlab-workhorse' }, Gitlab::Workhorse.secret, 'HS256') } + let(:headers) { { 'GitLab-Workhorse' => '1.0', Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER => jwt_token } } + let(:headers_with_token) { headers.merge(API::Helpers::Runner::JOB_TOKEN_HEADER => job.token) } + let(:file_upload) { fixture_file_upload(Rails.root + 'spec/fixtures/banana_sample.gif', 'image/gif') } + let(:file_upload2) { fixture_file_upload(Rails.root + 'spec/fixtures/dk.png', 'image/gif') } + + before { job.run! } + + describe 'POST /api/v4/jobs/:id/artifacts/authorize' do + context 'when using token as parameter' do + it 'authorizes posting artifacts to running job' do + authorize_artifacts_with_token_in_params + + expect(response).to have_http_status(200) + expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + expect(json_response['TempPath']).not_to be_nil + end + + it 'fails to post too large artifact' do + stub_application_setting(max_artifacts_size: 0) + + authorize_artifacts_with_token_in_params(filesize: 100) + + expect(response).to have_http_status(413) + end + end + + context 'when using token as header' do + it 'authorizes posting artifacts to running job' do + authorize_artifacts_with_token_in_headers + + expect(response).to have_http_status(200) + expect(response.content_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE) + expect(json_response['TempPath']).not_to be_nil + end + + it 'fails to post too large artifact' do + stub_application_setting(max_artifacts_size: 0) + + authorize_artifacts_with_token_in_headers(filesize: 100) + + expect(response).to have_http_status(413) + end + end + + context 'when using runners token' do + it 'fails to authorize artifacts posting' do + authorize_artifacts(token: job.project.runners_token) + + expect(response).to have_http_status(403) + end + end + + it 'reject requests that did not go through gitlab-workhorse' do + headers.delete(Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER) + + authorize_artifacts + + expect(response).to have_http_status(500) + end + + context 'authorization token is invalid' do + it 'responds with forbidden' do + authorize_artifacts(token: 'invalid', filesize: 100 ) + + expect(response).to have_http_status(403) + end + end + + def authorize_artifacts(params = {}, request_headers = headers) + post api("/jobs/#{job.id}/artifacts/authorize"), params, request_headers + end + + def authorize_artifacts_with_token_in_params(params = {}, request_headers = headers) + params = params.merge(token: job.token) + authorize_artifacts(params, request_headers) + end + + def authorize_artifacts_with_token_in_headers(params = {}, request_headers = headers_with_token) + authorize_artifacts(params, request_headers) + end + end + + describe 'POST /api/v4/jobs/:id/artifacts' do + context 'when artifacts are being stored inside of tmp path' do + before do + # by configuring this path we allow to pass temp file from any path + allow(ArtifactUploader).to receive(:artifacts_upload_path).and_return('/') + end + + context 'when job has been erased' do + let(:job) { create(:ci_build, erased_at: Time.now) } + + before do + upload_artifacts(file_upload, headers_with_token) + end + + it 'responds with forbidden' do + upload_artifacts(file_upload, headers_with_token) + + expect(response).to have_http_status(403) + end + end + + context 'when job is running' do + shared_examples 'successful artifacts upload' do + it 'updates successfully' do + expect(response).to have_http_status(201) + end + end + + context 'when uses regular file post' do + before { upload_artifacts(file_upload, headers_with_token, false) } + + it_behaves_like 'successful artifacts upload' + end + + context 'when uses accelerated file post' do + before { upload_artifacts(file_upload, headers_with_token, true) } + + it_behaves_like 'successful artifacts upload' + end + + context 'when updates artifact' do + before do + upload_artifacts(file_upload2, headers_with_token) + upload_artifacts(file_upload, headers_with_token) + end + + it_behaves_like 'successful artifacts upload' + end + + context 'when using runners token' do + it 'responds with forbidden' do + upload_artifacts(file_upload, headers.merge(API::Helpers::Runner::JOB_TOKEN_HEADER => job.project.runners_token)) + + expect(response).to have_http_status(403) + end + end + end + + context 'when artifacts file is too large' do + it 'fails to post too large artifact' do + stub_application_setting(max_artifacts_size: 0) + + upload_artifacts(file_upload, headers_with_token) + + expect(response).to have_http_status(413) + end + end + + context 'when artifacts post request does not contain file' do + it 'fails to post artifacts without file' do + post api("/jobs/#{job.id}/artifacts"), {}, headers_with_token + + expect(response).to have_http_status(400) + end + end + + context 'GitLab Workhorse is not configured' do + it 'fails to post artifacts without GitLab-Workhorse' do + post api("/jobs/#{job.id}/artifacts"), { token: job.token }, {} + + expect(response).to have_http_status(403) + end + end + + context 'when setting an expire date' do + let(:default_artifacts_expire_in) {} + let(:post_data) do + { 'file.path' => file_upload.path, + 'file.name' => file_upload.original_filename, + 'expire_in' => expire_in } + end + + before do + stub_application_setting(default_artifacts_expire_in: default_artifacts_expire_in) + + post(api("/jobs/#{job.id}/artifacts"), post_data, headers_with_token) + end + + context 'when an expire_in is given' do + let(:expire_in) { '7 days' } + + it 'updates when specified' do + expect(response).to have_http_status(201) + expect(job.reload.artifacts_expire_at).to be_within(5.minutes).of(7.days.from_now) + end + end + + context 'when no expire_in is given' do + let(:expire_in) { nil } + + it 'ignores if not specified' do + expect(response).to have_http_status(201) + expect(job.reload.artifacts_expire_at).to be_nil + end + + context 'with application default' do + context 'when default is 5 days' do + let(:default_artifacts_expire_in) { '5 days' } + + it 'sets to application default' do + expect(response).to have_http_status(201) + expect(job.reload.artifacts_expire_at).to be_within(5.minutes).of(5.days.from_now) + end + end + + context 'when default is 0' do + let(:default_artifacts_expire_in) { '0' } + + it 'does not set expire_in' do + expect(response).to have_http_status(201) + expect(job.reload.artifacts_expire_at).to be_nil + end + end + end + end + end + + context 'posts artifacts file and metadata file' do + let!(:artifacts) { file_upload } + let!(:metadata) { file_upload2 } + + let(:stored_artifacts_file) { job.reload.artifacts_file.file } + let(:stored_metadata_file) { job.reload.artifacts_metadata.file } + let(:stored_artifacts_size) { job.reload.artifacts_size } + + before do + post(api("/jobs/#{job.id}/artifacts"), post_data, headers_with_token) + end + + context 'when posts data accelerated by workhorse is correct' do + let(:post_data) do + { 'file.path' => artifacts.path, + 'file.name' => artifacts.original_filename, + 'metadata.path' => metadata.path, + 'metadata.name' => metadata.original_filename } + end + + it 'stores artifacts and artifacts metadata' do + expect(response).to have_http_status(201) + expect(stored_artifacts_file.original_filename).to eq(artifacts.original_filename) + expect(stored_metadata_file.original_filename).to eq(metadata.original_filename) + expect(stored_artifacts_size).to eq(71759) + end + end + + context 'when there is no artifacts file in post data' do + let(:post_data) do + { 'metadata' => metadata } + end + + it 'is expected to respond with bad request' do + expect(response).to have_http_status(400) + end + + it 'does not store metadata' do + expect(stored_metadata_file).to be_nil + end + end + end + end + + context 'when artifacts are being stored outside of tmp path' do + before do + # by configuring this path we allow to pass file from @tmpdir only + # but all temporary files are stored in system tmp directory + @tmpdir = Dir.mktmpdir + allow(ArtifactUploader).to receive(:artifacts_upload_path).and_return(@tmpdir) + end + + after { FileUtils.remove_entry @tmpdir } + + it' "fails to post artifacts for outside of tmp path"' do + upload_artifacts(file_upload, headers_with_token) + + expect(response).to have_http_status(400) + end + end + + def upload_artifacts(file, headers = {}, accelerated = true) + params = if accelerated + { 'file.path' => file.path, 'file.name' => file.original_filename } + else + { 'file' => file } + end + post api("/jobs/#{job.id}/artifacts"), params, headers + end + end + + describe 'GET /api/v4/jobs/:id/artifacts' do + let(:token) { job.token } + + before { download_artifact } + + context 'when job has artifacts' do + let(:job) { create(:ci_build, :artifacts) } + let(:download_headers) do + { 'Content-Transfer-Encoding' => 'binary', + 'Content-Disposition' => 'attachment; filename=ci_build_artifacts.zip' } + end + + context 'when using job token' do + it 'download artifacts' do + expect(response).to have_http_status(200) + expect(response.headers).to include download_headers + end + end + + context 'when using runnners token' do + let(:token) { job.project.runners_token } + + it 'responds with forbidden' do + expect(response).to have_http_status(403) + end + end + end + + context 'when job does not has artifacts' do + it 'responds with not found' do + expect(response).to have_http_status(404) + end + end + + def download_artifact(params = {}, request_headers = headers) + params = params.merge(token: token) + get api("/jobs/#{job.id}/artifacts"), params, request_headers + end + end + end + end +end diff --git a/spec/requests/api/runners_spec.rb b/spec/requests/api/runners_spec.rb index 103d6755888..8a82543a830 100644 --- a/spec/requests/api/runners_spec.rb +++ b/spec/requests/api/runners_spec.rb @@ -277,8 +277,9 @@ describe API::Runners, api: true do it 'deletes runner' do expect do delete api("/runners/#{shared_runner.id}", admin) + + expect(response).to have_http_status(204) end.to change{ Ci::Runner.shared.count }.by(-1) - expect(response).to have_http_status(200) end end @@ -286,15 +287,17 @@ describe API::Runners, api: true do it 'deletes unused runner' do expect do delete api("/runners/#{unused_specific_runner.id}", admin) + + expect(response).to have_http_status(204) end.to change{ Ci::Runner.specific.count }.by(-1) - expect(response).to have_http_status(200) end it 'deletes used runner' do expect do delete api("/runners/#{specific_runner.id}", admin) + + expect(response).to have_http_status(204) end.to change{ Ci::Runner.specific.count }.by(-1) - expect(response).to have_http_status(200) end end @@ -327,8 +330,9 @@ describe API::Runners, api: true do it 'deletes runner for one owned project' do expect do delete api("/runners/#{specific_runner.id}", user) + + expect(response).to have_http_status(204) end.to change{ Ci::Runner.specific.count }.by(-1) - expect(response).to have_http_status(200) end end end @@ -457,8 +461,9 @@ describe API::Runners, api: true do it "disables project's runner" do expect do delete api("/projects/#{project.id}/runners/#{two_projects_runner.id}", user) + + expect(response).to have_http_status(204) end.to change{ project.runners.count }.by(-1) - expect(response).to have_http_status(200) end end diff --git a/spec/requests/api/services_spec.rb b/spec/requests/api/services_spec.rb index 776dc655650..fd334934ca5 100644 --- a/spec/requests/api/services_spec.rb +++ b/spec/requests/api/services_spec.rb @@ -55,7 +55,7 @@ describe API::Services, api: true do it "deletes #{service}" do delete api("/projects/#{project.id}/services/#{dashed_service}", user) - expect(response).to have_http_status(200) + expect(response).to have_http_status(204) project.send(service_method).reload expect(project.send(service_method).activated?).to be_falsey end diff --git a/spec/requests/api/session_spec.rb b/spec/requests/api/session_spec.rb index 794e2b5c04d..28fab2011a5 100644 --- a/spec/requests/api/session_spec.rb +++ b/spec/requests/api/session_spec.rb @@ -87,5 +87,23 @@ describe API::Session, api: true do expect(response).to have_http_status(400) end end + + context "when user is blocked" do + it "returns authentication error" do + user.block + post api("/session"), email: user.username, password: user.password + + expect(response).to have_http_status(401) + end + end + + context "when user is ldap_blocked" do + it "returns authentication error" do + user.ldap_block + post api("/session"), email: user.username, password: user.password + + expect(response).to have_http_status(401) + end + end end end diff --git a/spec/requests/api/settings_spec.rb b/spec/requests/api/settings_spec.rb index 91e3c333a02..11b4b718e2c 100644 --- a/spec/requests/api/settings_spec.rb +++ b/spec/requests/api/settings_spec.rb @@ -18,6 +18,9 @@ describe API::Settings, 'Settings', api: true do expect(json_response['koding_url']).to be_nil expect(json_response['plantuml_enabled']).to be_falsey expect(json_response['plantuml_url']).to be_nil + expect(json_response['default_project_visibility']).to be_a String + expect(json_response['default_snippet_visibility']).to be_a String + expect(json_response['default_group_visibility']).to be_a String end end @@ -30,8 +33,16 @@ describe API::Settings, 'Settings', api: true do it "updates application settings" do put api("/application/settings", admin), - default_projects_limit: 3, signin_enabled: false, repository_storage: 'custom', koding_enabled: true, koding_url: 'http://koding.example.com', - plantuml_enabled: true, plantuml_url: 'http://plantuml.example.com' + default_projects_limit: 3, + signin_enabled: false, + repository_storage: 'custom', + koding_enabled: true, + koding_url: 'http://koding.example.com', + plantuml_enabled: true, + plantuml_url: 'http://plantuml.example.com', + default_snippet_visibility: 'internal', + restricted_visibility_levels: ['public'], + default_artifacts_expire_in: '2 days' expect(response).to have_http_status(200) expect(json_response['default_projects_limit']).to eq(3) expect(json_response['signin_enabled']).to be_falsey @@ -41,6 +52,9 @@ describe API::Settings, 'Settings', api: true do expect(json_response['koding_url']).to eq('http://koding.example.com') expect(json_response['plantuml_enabled']).to be_truthy expect(json_response['plantuml_url']).to eq('http://plantuml.example.com') + expect(json_response['default_snippet_visibility']).to eq('internal') + expect(json_response['restricted_visibility_levels']).to eq(['public']) + expect(json_response['default_artifacts_expire_in']).to eq('2 days') end end diff --git a/spec/requests/api/snippets_spec.rb b/spec/requests/api/snippets_spec.rb index 41def7cd1d4..5d75b47b3cd 100644 --- a/spec/requests/api/snippets_spec.rb +++ b/spec/requests/api/snippets_spec.rb @@ -74,7 +74,7 @@ describe API::Snippets, api: true do end it 'returns 404 for invalid snippet id' do - delete api("/snippets/1234", user) + get api("/snippets/1234/raw", user) expect(response).to have_http_status(404) expect(json_response['message']).to eq('404 Snippet Not Found') @@ -87,7 +87,7 @@ describe API::Snippets, api: true do title: 'Test Title', file_name: 'test.rb', content: 'puts "hello world"', - visibility_level: Snippet::PUBLIC + visibility: 'public' } end @@ -120,14 +120,14 @@ describe API::Snippets, api: true do context 'when the snippet is private' do it 'creates the snippet' do - expect { create_snippet(visibility_level: Snippet::PRIVATE) }. + expect { create_snippet(visibility: 'private') }. to change { Snippet.count }.by(1) end end context 'when the snippet is public' do it 'rejects the shippet' do - expect { create_snippet(visibility_level: Snippet::PUBLIC) }. + expect { create_snippet(visibility: 'public') }. not_to change { Snippet.count } expect(response).to have_http_status(400) @@ -135,7 +135,7 @@ describe API::Snippets, api: true do end it 'creates a spam log' do - expect { create_snippet(visibility_level: Snippet::PUBLIC) }. + expect { create_snippet(visibility: 'public') }. to change { SpamLog.count }.by(1) end end @@ -218,12 +218,12 @@ describe API::Snippets, api: true do let(:visibility_level) { Snippet::PRIVATE } it 'rejects the snippet' do - expect { update_snippet(title: 'Foo', visibility_level: Snippet::PUBLIC) }. + expect { update_snippet(title: 'Foo', visibility: 'public') }. not_to change { snippet.reload.title } end it 'creates a spam log' do - expect { update_snippet(title: 'Foo', visibility_level: Snippet::PUBLIC) }. + expect { update_snippet(title: 'Foo', visibility: 'public') }. to change { SpamLog.count }.by(1) end end diff --git a/spec/requests/api/system_hooks_spec.rb b/spec/requests/api/system_hooks_spec.rb index b59da632c00..d1e10f12657 100644 --- a/spec/requests/api/system_hooks_spec.rb +++ b/spec/requests/api/system_hooks_spec.rb @@ -91,6 +91,8 @@ describe API::SystemHooks, api: true do it "deletes a hook" do expect do delete api("/hooks/#{hook.id}", admin) + + expect(response).to have_http_status(204) end.to change { SystemHook.count }.by(-1) end diff --git a/spec/requests/api/tags_spec.rb b/spec/requests/api/tags_spec.rb index 8a4f078182f..b132d033a61 100644 --- a/spec/requests/api/tags_spec.rb +++ b/spec/requests/api/tags_spec.rb @@ -137,8 +137,8 @@ describe API::Tags, api: true do context 'delete tag' do it 'deletes an existing tag' do delete api("/projects/#{project.id}/repository/tags/#{tag_name}", user) - expect(response).to have_http_status(200) - expect(json_response['tag_name']).to eq(tag_name) + + expect(response).to have_http_status(204) end it 'raises 404 if the tag does not exist' do diff --git a/spec/requests/api/templates_spec.rb b/spec/requests/api/templates_spec.rb index 8506e8fccde..2c83e119065 100644 --- a/spec/requests/api/templates_spec.rb +++ b/spec/requests/api/templates_spec.rb @@ -58,11 +58,11 @@ describe API::Templates, api: true do expect(json_response['popular']).to be true expect(json_response['html_url']).to eq('http://choosealicense.com/licenses/mit/') expect(json_response['source_url']).to eq('https://opensource.org/licenses/MIT') - expect(json_response['description']).to include('A permissive license that is short and to the point.') + expect(json_response['description']).to include('A short and simple permissive license with conditions') expect(json_response['conditions']).to eq(%w[include-copyright]) expect(json_response['permissions']).to eq(%w[commercial-use modifications distribution private-use]) expect(json_response['limitations']).to eq(%w[no-liability]) - expect(json_response['content']).to include('The MIT License (MIT)') + expect(json_response['content']).to include('MIT License') end end @@ -73,7 +73,7 @@ describe API::Templates, api: true do expect(response).to have_http_status(200) expect(response).to include_pagination_headers expect(json_response).to be_an Array - expect(json_response.size).to eq(15) + expect(json_response.size).to eq(12) expect(json_response.map { |l| l['key'] }).to include('agpl-3.0') end @@ -102,7 +102,7 @@ describe API::Templates, api: true do let(:license_type) { 'mit' } it 'returns the license text' do - expect(json_response['content']).to include('The MIT License (MIT)') + expect(json_response['content']).to include('MIT License') end it 'replaces placeholder values' do diff --git a/spec/requests/api/todos_spec.rb b/spec/requests/api/todos_spec.rb index f35e963a14b..b789284fa8d 100644 --- a/spec/requests/api/todos_spec.rb +++ b/spec/requests/api/todos_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe API::Todos, api: true do include ApiHelpers - let(:project_1) { create(:empty_project) } + let(:project_1) { create(:empty_project, :test_repo) } let(:project_2) { create(:empty_project) } let(:author_1) { create(:user) } let(:author_2) { create(:user) } @@ -11,7 +11,7 @@ describe API::Todos, api: true do let(:merge_request) { create(:merge_request, source_project: project_1) } let!(:pending_1) { create(:todo, :mentioned, project: project_1, author: author_1, user: john_doe) } let!(:pending_2) { create(:todo, project: project_2, author: author_2, user: john_doe) } - let!(:pending_3) { create(:todo, project: project_1, author: author_2, user: john_doe) } + let!(:pending_3) { create(:on_commit_todo, project: project_1, author: author_2, user: john_doe) } let!(:done) { create(:todo, :done, project: project_1, author: author_1, user: john_doe) } before do @@ -163,7 +163,7 @@ describe API::Todos, api: true do shared_examples 'an issuable' do |issuable_type| it 'creates a todo on an issuable' do - post api("/projects/#{project_1.id}/#{issuable_type}/#{issuable.id}/todo", john_doe) + post api("/projects/#{project_1.id}/#{issuable_type}/#{issuable.iid}/todo", john_doe) expect(response.status).to eq(201) expect(json_response['project']).to be_a Hash @@ -180,7 +180,7 @@ describe API::Todos, api: true do it 'returns 304 there already exist a todo on that issuable' do create(:todo, project: project_1, author: author_1, user: john_doe, target: issuable) - post api("/projects/#{project_1.id}/#{issuable_type}/#{issuable.id}/todo", john_doe) + post api("/projects/#{project_1.id}/#{issuable_type}/#{issuable.iid}/todo", john_doe) expect(response.status).to eq(304) end @@ -195,7 +195,7 @@ describe API::Todos, api: true do guest = create(:user) project_1.team << [guest, :guest] - post api("/projects/#{project_1.id}/#{issuable_type}/#{issuable.id}/todo", guest) + post api("/projects/#{project_1.id}/#{issuable_type}/#{issuable.iid}/todo", guest) if issuable_type == 'merge_requests' expect(response).to have_http_status(403) diff --git a/spec/requests/api/triggers_spec.rb b/spec/requests/api/triggers_spec.rb index 92dfc2aa277..d93a734f5b6 100644 --- a/spec/requests/api/triggers_spec.rb +++ b/spec/requests/api/triggers_spec.rb @@ -14,7 +14,7 @@ describe API::Triggers do let!(:trigger2) { create(:ci_trigger, project: project, token: trigger_token_2) } let!(:trigger_request) { create(:ci_trigger_request, trigger: trigger, created_at: '2015-01-01 12:13:14') } - describe 'POST /projects/:project_id/trigger' do + describe 'POST /projects/:project_id/trigger/pipeline' do let!(:project2) { create(:project) } let(:options) do { @@ -28,17 +28,20 @@ describe API::Triggers do context 'Handles errors' do it 'returns bad request if token is missing' do - post api("/projects/#{project.id}/trigger/builds"), ref: 'master' + post api("/projects/#{project.id}/trigger/pipeline"), ref: 'master' + expect(response).to have_http_status(400) end it 'returns not found if project is not found' do - post api('/projects/0/trigger/builds'), options.merge(ref: 'master') + post api('/projects/0/trigger/pipeline'), options.merge(ref: 'master') + expect(response).to have_http_status(404) end it 'returns unauthorized if token is for different project' do - post api("/projects/#{project2.id}/trigger/builds"), options.merge(ref: 'master') + post api("/projects/#{project2.id}/trigger/pipeline"), options.merge(ref: 'master') + expect(response).to have_http_status(401) end end @@ -46,25 +49,21 @@ describe API::Triggers do context 'Have a commit' do let(:pipeline) { project.pipelines.last } - it 'creates builds' do - post api("/projects/#{project.id}/trigger/builds"), options.merge(ref: 'master') + it 'creates pipeline' do + post api("/projects/#{project.id}/trigger/pipeline"), options.merge(ref: 'master') + expect(response).to have_http_status(201) + expect(json_response).to include('id' => pipeline.id) pipeline.builds.reload expect(pipeline.builds.pending.size).to eq(2) expect(pipeline.builds.size).to eq(5) end - it 'creates builds on webhook from other gitlab repository and branch' do - expect do - post api("/projects/#{project.id}/ref/master/trigger/builds?token=#{trigger_token}"), { ref: 'refs/heads/other-branch' } - end.to change(project.builds, :count).by(5) - expect(response).to have_http_status(201) - end + it 'returns bad request with no pipeline created if there\'s no commit for that ref' do + post api("/projects/#{project.id}/trigger/pipeline"), options.merge(ref: 'other-branch') - it 'returns bad request with no builds created if there\'s no commit for that ref' do - post api("/projects/#{project.id}/trigger/builds"), options.merge(ref: 'other-branch') expect(response).to have_http_status(400) - expect(json_response['message']).to eq('No builds created') + expect(json_response['message']).to eq('No pipeline created') end context 'Validates variables' do @@ -73,22 +72,46 @@ describe API::Triggers do end it 'validates variables to be a hash' do - post api("/projects/#{project.id}/trigger/builds"), options.merge(variables: 'value', ref: 'master') + post api("/projects/#{project.id}/trigger/pipeline"), options.merge(variables: 'value', ref: 'master') + expect(response).to have_http_status(400) expect(json_response['error']).to eq('variables is invalid') end it 'validates variables needs to be a map of key-valued strings' do - post api("/projects/#{project.id}/trigger/builds"), options.merge(variables: { key: %w(1 2) }, ref: 'master') + post api("/projects/#{project.id}/trigger/pipeline"), options.merge(variables: { key: %w(1 2) }, ref: 'master') + expect(response).to have_http_status(400) expect(json_response['message']).to eq('variables needs to be a map of key-valued strings') end it 'creates trigger request with variables' do - post api("/projects/#{project.id}/trigger/builds"), options.merge(variables: variables, ref: 'master') + post api("/projects/#{project.id}/trigger/pipeline"), options.merge(variables: variables, ref: 'master') + + expect(response).to have_http_status(201) + expect(pipeline.builds.reload.first.trigger_request.variables).to eq(variables) + end + end + end + + context 'when triggering a pipeline from a trigger token' do + it 'creates builds from the ref given in the URL, not in the body' do + expect do + post api("/projects/#{project.id}/ref/master/trigger/pipeline?token=#{trigger_token}"), { ref: 'refs/heads/other-branch' } + end.to change(project.builds, :count).by(5) + + expect(response).to have_http_status(201) + end + + context 'when ref contains a dot' do + it 'creates builds from the ref given in the URL, not in the body' do + project.repository.create_file(user, '.gitlab/gitlabhq/new_feature.md', 'something valid', message: 'new_feature', branch_name: 'v.1-branch') + + expect do + post api("/projects/#{project.id}/ref/v.1-branch/trigger/pipeline?token=#{trigger_token}"), { ref: 'refs/heads/other-branch' } + end.to change(project.builds, :count).by(4) + expect(response).to have_http_status(201) - pipeline.builds.reload - expect(pipeline.builds.first.trigger_request.variables).to eq(variables) end end end @@ -123,17 +146,17 @@ describe API::Triggers do end end - describe 'GET /projects/:id/triggers/:token' do + describe 'GET /projects/:id/triggers/:trigger_id' do context 'authenticated user with valid permissions' do it 'returns trigger details' do - get api("/projects/#{project.id}/triggers/#{trigger.token}", user) + get api("/projects/#{project.id}/triggers/#{trigger.id}", user) expect(response).to have_http_status(200) expect(json_response).to be_a(Hash) end it 'responds with 404 Not Found if requesting non-existing trigger' do - get api("/projects/#{project.id}/triggers/abcdef012345", user) + get api("/projects/#{project.id}/triggers/-5", user) expect(response).to have_http_status(404) end @@ -141,7 +164,7 @@ describe API::Triggers do context 'authenticated user with invalid permissions' do it 'does not return triggers list' do - get api("/projects/#{project.id}/triggers/#{trigger.token}", user2) + get api("/projects/#{project.id}/triggers/#{trigger.id}", user2) expect(response).to have_http_status(403) end @@ -149,7 +172,7 @@ describe API::Triggers do context 'unauthenticated user' do it 'does not return triggers list' do - get api("/projects/#{project.id}/triggers/#{trigger.token}") + get api("/projects/#{project.id}/triggers/#{trigger.id}") expect(response).to have_http_status(401) end @@ -158,19 +181,31 @@ describe API::Triggers do describe 'POST /projects/:id/triggers' do context 'authenticated user with valid permissions' do - it 'creates trigger' do - expect do + context 'with required parameters' do + it 'creates trigger' do + expect do + post api("/projects/#{project.id}/triggers", user), + description: 'trigger' + end.to change{project.triggers.count}.by(1) + + expect(response).to have_http_status(201) + expect(json_response).to include('description' => 'trigger') + end + end + + context 'without required parameters' do + it 'does not create trigger' do post api("/projects/#{project.id}/triggers", user) - end.to change{project.triggers.count}.by(1) - expect(response).to have_http_status(201) - expect(json_response).to be_a(Hash) + expect(response).to have_http_status(:bad_request) + end end end context 'authenticated user with invalid permissions' do it 'does not create trigger' do - post api("/projects/#{project.id}/triggers", user2) + post api("/projects/#{project.id}/triggers", user2), + description: 'trigger' expect(response).to have_http_status(403) end @@ -178,24 +213,87 @@ describe API::Triggers do context 'unauthenticated user' do it 'does not create trigger' do - post api("/projects/#{project.id}/triggers") + post api("/projects/#{project.id}/triggers"), + description: 'trigger' expect(response).to have_http_status(401) end end end - describe 'DELETE /projects/:id/triggers/:token' do + describe 'PUT /projects/:id/triggers/:trigger_id' do + context 'authenticated user with valid permissions' do + let(:new_description) { 'new description' } + + it 'updates description' do + put api("/projects/#{project.id}/triggers/#{trigger.id}", user), + description: new_description + + expect(response).to have_http_status(200) + expect(json_response).to include('description' => new_description) + expect(trigger.reload.description).to eq(new_description) + end + end + + context 'authenticated user with invalid permissions' do + it 'does not update trigger' do + put api("/projects/#{project.id}/triggers/#{trigger.id}", user2) + + expect(response).to have_http_status(403) + end + end + + context 'unauthenticated user' do + it 'does not update trigger' do + put api("/projects/#{project.id}/triggers/#{trigger.id}") + + expect(response).to have_http_status(401) + end + end + end + + describe 'POST /projects/:id/triggers/:trigger_id/take_ownership' do + context 'authenticated user with valid permissions' do + it 'updates owner' do + expect(trigger.owner).to be_nil + + post api("/projects/#{project.id}/triggers/#{trigger.id}/take_ownership", user) + + expect(response).to have_http_status(200) + expect(json_response).to include('owner') + expect(trigger.reload.owner).to eq(user) + end + end + + context 'authenticated user with invalid permissions' do + it 'does not update owner' do + post api("/projects/#{project.id}/triggers/#{trigger.id}/take_ownership", user2) + + expect(response).to have_http_status(403) + end + end + + context 'unauthenticated user' do + it 'does not update owner' do + post api("/projects/#{project.id}/triggers/#{trigger.id}/take_ownership") + + expect(response).to have_http_status(401) + end + end + end + + describe 'DELETE /projects/:id/triggers/:trigger_id' do context 'authenticated user with valid permissions' do it 'deletes trigger' do expect do - delete api("/projects/#{project.id}/triggers/#{trigger.token}", user) + delete api("/projects/#{project.id}/triggers/#{trigger.id}", user) + + expect(response).to have_http_status(204) end.to change{project.triggers.count}.by(-1) - expect(response).to have_http_status(200) end it 'responds with 404 Not Found if requesting non-existing trigger' do - delete api("/projects/#{project.id}/triggers/abcdef012345", user) + delete api("/projects/#{project.id}/triggers/-5", user) expect(response).to have_http_status(404) end @@ -203,7 +301,7 @@ describe API::Triggers do context 'authenticated user with invalid permissions' do it 'does not delete trigger' do - delete api("/projects/#{project.id}/triggers/#{trigger.token}", user2) + delete api("/projects/#{project.id}/triggers/#{trigger.id}", user2) expect(response).to have_http_status(403) end @@ -211,7 +309,7 @@ describe API::Triggers do context 'unauthenticated user' do it 'does not delete trigger' do - delete api("/projects/#{project.id}/triggers/#{trigger.token}") + delete api("/projects/#{project.id}/triggers/#{trigger.id}") expect(response).to have_http_status(401) end diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb index 603da9f49fc..04e7837fd7a 100644 --- a/spec/requests/api/users_spec.rb +++ b/spec/requests/api/users_spec.rb @@ -10,6 +10,8 @@ describe API::Users, api: true do let(:omniauth_user) { create(:omniauth_user) } let(:ldap_user) { create(:omniauth_user, provider: 'ldapmain') } let(:ldap_blocked_user) { create(:omniauth_user, provider: 'ldapmain', state: 'ldap_blocked') } + let(:not_existing_user_id) { (User.maximum('id') || 0 ) + 10 } + let(:not_existing_pat_id) { (PersonalAccessToken.maximum('id') || 0 ) + 10 } describe "GET /users" do context "when unauthenticated" do @@ -540,10 +542,12 @@ describe API::Users, api: true do it 'deletes existing key' do user.keys << key user.save + expect do delete api("/users/#{user.id}/keys/#{key.id}", admin) + + expect(response).to have_http_status(204) end.to change { user.keys.count }.by(-1) - expect(response).to have_http_status(200) end it 'returns 404 error if user not found' do @@ -637,10 +641,12 @@ describe API::Users, api: true do it 'deletes existing email' do user.emails << email user.save + expect do delete api("/users/#{user.id}/emails/#{email.id}", admin) + + expect(response).to have_http_status(204) end.to change { user.emails.count }.by(-1) - expect(response).to have_http_status(200) end it 'returns 404 error if user not found' do @@ -671,10 +677,10 @@ describe API::Users, api: true do it "deletes user" do delete api("/users/#{user.id}", admin) - expect(response).to have_http_status(200) + + expect(response).to have_http_status(204) expect { User.find(user.id) }.to raise_error ActiveRecord::RecordNotFound expect { Namespace.find(namespace.id) }.to raise_error ActiveRecord::RecordNotFound - expect(json_response['email']).to eq(user.email) end it "does not delete for unauthenticated user" do @@ -724,7 +730,7 @@ describe API::Users, api: true do get api("/user", user) expect(response).to have_http_status(200) - expect(response).to match_response_schema('user/public') + expect(response).to match_response_schema('public_api/v4/user/public') expect(json_response['id']).to eq(user.id) end end @@ -743,7 +749,7 @@ describe API::Users, api: true do get api("/user?private_token=#{admin_personal_access_token}") expect(response).to have_http_status(200) - expect(response).to match_response_schema('user/public') + expect(response).to match_response_schema('public_api/v4/user/public') expect(json_response['id']).to eq(admin.id) end end @@ -753,7 +759,7 @@ describe API::Users, api: true do get api("/user?private_token=#{admin.private_token}&sudo=#{user.id}") expect(response).to have_http_status(200) - expect(response).to match_response_schema('user/login') + expect(response).to match_response_schema('public_api/v4/user/login') expect(json_response['id']).to eq(user.id) end @@ -761,7 +767,7 @@ describe API::Users, api: true do get api("/user?private_token=#{admin.private_token}") expect(response).to have_http_status(200) - expect(response).to match_response_schema('user/public') + expect(response).to match_response_schema('public_api/v4/user/public') expect(json_response['id']).to eq(admin.id) end end @@ -869,10 +875,12 @@ describe API::Users, api: true do it "deletes existed key" do user.keys << key user.save + expect do delete api("/user/keys/#{key.id}", user) + + expect(response).to have_http_status(204) end.to change{user.keys.count}.by(-1) - expect(response).to have_http_status(200) end it "returns 404 if key ID not found" do @@ -976,10 +984,12 @@ describe API::Users, api: true do it "deletes existed email" do user.emails << email user.save + expect do delete api("/user/emails/#{email.id}", user) + + expect(response).to have_http_status(204) end.to change{user.emails.count}.by(-1) - expect(response).to have_http_status(200) end it "returns 404 if email ID not found" do @@ -1147,4 +1157,187 @@ describe API::Users, api: true do expect(json_response['message']).to eq('404 User Not Found') end end + + describe 'GET /users/:user_id/impersonation_tokens' do + let!(:active_personal_access_token) { create(:personal_access_token, user: user) } + let!(:revoked_personal_access_token) { create(:personal_access_token, :revoked, user: user) } + let!(:expired_personal_access_token) { create(:personal_access_token, :expired, user: user) } + let!(:impersonation_token) { create(:personal_access_token, :impersonation, user: user) } + let!(:revoked_impersonation_token) { create(:personal_access_token, :impersonation, :revoked, user: user) } + + it 'returns a 404 error if user not found' do + get api("/users/#{not_existing_user_id}/impersonation_tokens", admin) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 User Not Found') + end + + it 'returns a 403 error when authenticated as normal user' do + get api("/users/#{not_existing_user_id}/impersonation_tokens", user) + + expect(response).to have_http_status(403) + expect(json_response['message']).to eq('403 Forbidden') + end + + it 'returns an array of all impersonated tokens' do + get api("/users/#{user.id}/impersonation_tokens", admin) + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.size).to eq(2) + end + + it 'returns an array of active impersonation tokens if state active' do + get api("/users/#{user.id}/impersonation_tokens?state=active", admin) + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.size).to eq(1) + expect(json_response).to all(include('active' => true)) + end + + it 'returns an array of inactive personal access tokens if active is set to false' do + get api("/users/#{user.id}/impersonation_tokens?state=inactive", admin) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.size).to eq(1) + expect(json_response).to all(include('active' => false)) + end + end + + describe 'POST /users/:user_id/impersonation_tokens' do + let(:name) { 'my new pat' } + let(:expires_at) { '2016-12-28' } + let(:scopes) { %w(api read_user) } + let(:impersonation) { true } + + it 'returns validation error if impersonation token misses some attributes' do + post api("/users/#{user.id}/impersonation_tokens", admin) + + expect(response).to have_http_status(400) + expect(json_response['error']).to eq('name is missing') + end + + it 'returns a 404 error if user not found' do + post api("/users/#{not_existing_user_id}/impersonation_tokens", admin), + name: name, + expires_at: expires_at + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 User Not Found') + end + + it 'returns a 403 error when authenticated as normal user' do + post api("/users/#{user.id}/impersonation_tokens", user), + name: name, + expires_at: expires_at + + expect(response).to have_http_status(403) + expect(json_response['message']).to eq('403 Forbidden') + end + + it 'creates a impersonation token' do + post api("/users/#{user.id}/impersonation_tokens", admin), + name: name, + expires_at: expires_at, + scopes: scopes, + impersonation: impersonation + + expect(response).to have_http_status(201) + expect(json_response['name']).to eq(name) + expect(json_response['scopes']).to eq(scopes) + expect(json_response['expires_at']).to eq(expires_at) + expect(json_response['id']).to be_present + expect(json_response['created_at']).to be_present + expect(json_response['active']).to be_falsey + expect(json_response['revoked']).to be_falsey + expect(json_response['token']).to be_present + expect(json_response['impersonation']).to eq(impersonation) + end + end + + describe 'GET /users/:user_id/impersonation_tokens/:impersonation_token_id' do + let!(:personal_access_token) { create(:personal_access_token, user: user) } + let!(:impersonation_token) { create(:personal_access_token, :impersonation, user: user) } + + it 'returns 404 error if user not found' do + get api("/users/#{not_existing_user_id}/impersonation_tokens/1", admin) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 User Not Found') + end + + it 'returns a 404 error if impersonation token not found' do + get api("/users/#{user.id}/impersonation_tokens/#{not_existing_pat_id}", admin) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 Impersonation Token Not Found') + end + + it 'returns a 404 error if token is not impersonation token' do + get api("/users/#{user.id}/impersonation_tokens/#{personal_access_token.id}", admin) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 Impersonation Token Not Found') + end + + it 'returns a 403 error when authenticated as normal user' do + get api("/users/#{user.id}/impersonation_tokens/#{impersonation_token.id}", user) + + expect(response).to have_http_status(403) + expect(json_response['message']).to eq('403 Forbidden') + end + + it 'returns a personal access token' do + get api("/users/#{user.id}/impersonation_tokens/#{impersonation_token.id}", admin) + + expect(response).to have_http_status(200) + expect(json_response['token']).to be_present + expect(json_response['impersonation']).to be_truthy + end + end + + describe 'DELETE /users/:user_id/impersonation_tokens/:impersonation_token_id' do + let!(:personal_access_token) { create(:personal_access_token, user: user) } + let!(:impersonation_token) { create(:personal_access_token, :impersonation, user: user) } + + it 'returns a 404 error if user not found' do + delete api("/users/#{not_existing_user_id}/impersonation_tokens/1", admin) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 User Not Found') + end + + it 'returns a 404 error if impersonation token not found' do + delete api("/users/#{user.id}/impersonation_tokens/#{not_existing_pat_id}", admin) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 Impersonation Token Not Found') + end + + it 'returns a 404 error if token is not impersonation token' do + delete api("/users/#{user.id}/impersonation_tokens/#{personal_access_token.id}", admin) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 Impersonation Token Not Found') + end + + it 'returns a 403 error when authenticated as normal user' do + delete api("/users/#{user.id}/impersonation_tokens/#{impersonation_token.id}", user) + + expect(response).to have_http_status(403) + expect(json_response['message']).to eq('403 Forbidden') + end + + it 'revokes a impersonation token' do + delete api("/users/#{user.id}/impersonation_tokens/#{impersonation_token.id}", admin) + + expect(response).to have_http_status(204) + expect(impersonation_token.revoked).to be_falsey + expect(impersonation_token.reload.revoked).to be_truthy + end + end end diff --git a/spec/requests/api/v3/award_emoji_spec.rb b/spec/requests/api/v3/award_emoji_spec.rb new file mode 100644 index 00000000000..eeb4d128c1b --- /dev/null +++ b/spec/requests/api/v3/award_emoji_spec.rb @@ -0,0 +1,299 @@ +require 'spec_helper' + +describe API::V3::AwardEmoji, api: true do + include ApiHelpers + + let(:user) { create(:user) } + let!(:project) { create(:empty_project) } + let(:issue) { create(:issue, project: project) } + let!(:award_emoji) { create(:award_emoji, awardable: issue, user: user) } + let!(:merge_request) { create(:merge_request, source_project: project, target_project: project) } + let!(:downvote) { create(:award_emoji, :downvote, awardable: merge_request, user: user) } + let!(:note) { create(:note, project: project, noteable: issue) } + + before { project.team << [user, :master] } + + describe "GET /projects/:id/awardable/:awardable_id/award_emoji" do + context 'on an issue' do + it "returns an array of award_emoji" do + get v3_api("/projects/#{project.id}/issues/#{issue.id}/award_emoji", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.first['name']).to eq(award_emoji.name) + end + + it "returns a 404 error when issue id not found" do + get v3_api("/projects/#{project.id}/issues/12345/award_emoji", user) + + expect(response).to have_http_status(404) + end + end + + context 'on a merge request' do + it "returns an array of award_emoji" do + get v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/award_emoji", user) + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.first['name']).to eq(downvote.name) + end + end + + context 'on a snippet' do + let(:snippet) { create(:project_snippet, :public, project: project) } + let!(:award) { create(:award_emoji, awardable: snippet) } + + it 'returns the awarded emoji' do + get v3_api("/projects/#{project.id}/snippets/#{snippet.id}/award_emoji", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.first['name']).to eq(award.name) + end + end + + context 'when the user has no access' do + it 'returns a status code 404' do + user1 = create(:user) + + get v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/award_emoji", user1) + + expect(response).to have_http_status(404) + end + end + end + + describe 'GET /projects/:id/awardable/:awardable_id/notes/:note_id/award_emoji' do + let!(:rocket) { create(:award_emoji, awardable: note, name: 'rocket') } + + it 'returns an array of award emoji' do + get v3_api("/projects/#{project.id}/issues/#{issue.id}/notes/#{note.id}/award_emoji", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.first['name']).to eq(rocket.name) + end + end + + describe "GET /projects/:id/awardable/:awardable_id/award_emoji/:award_id" do + context 'on an issue' do + it "returns the award emoji" do + get v3_api("/projects/#{project.id}/issues/#{issue.id}/award_emoji/#{award_emoji.id}", user) + + expect(response).to have_http_status(200) + expect(json_response['name']).to eq(award_emoji.name) + expect(json_response['awardable_id']).to eq(issue.id) + expect(json_response['awardable_type']).to eq("Issue") + end + + it "returns a 404 error if the award is not found" do + get v3_api("/projects/#{project.id}/issues/#{issue.id}/award_emoji/12345", user) + + expect(response).to have_http_status(404) + end + end + + context 'on a merge request' do + it 'returns the award emoji' do + get v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/award_emoji/#{downvote.id}", user) + + expect(response).to have_http_status(200) + expect(json_response['name']).to eq(downvote.name) + expect(json_response['awardable_id']).to eq(merge_request.id) + expect(json_response['awardable_type']).to eq("MergeRequest") + end + end + + context 'on a snippet' do + let(:snippet) { create(:project_snippet, :public, project: project) } + let!(:award) { create(:award_emoji, awardable: snippet) } + + it 'returns the awarded emoji' do + get v3_api("/projects/#{project.id}/snippets/#{snippet.id}/award_emoji/#{award.id}", user) + + expect(response).to have_http_status(200) + expect(json_response['name']).to eq(award.name) + expect(json_response['awardable_id']).to eq(snippet.id) + expect(json_response['awardable_type']).to eq("Snippet") + end + end + + context 'when the user has no access' do + it 'returns a status code 404' do + user1 = create(:user) + + get v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/award_emoji/#{downvote.id}", user1) + + expect(response).to have_http_status(404) + end + end + end + + describe 'GET /projects/:id/awardable/:awardable_id/notes/:note_id/award_emoji/:award_id' do + let!(:rocket) { create(:award_emoji, awardable: note, name: 'rocket') } + + it 'returns an award emoji' do + get v3_api("/projects/#{project.id}/issues/#{issue.id}/notes/#{note.id}/award_emoji/#{rocket.id}", user) + + expect(response).to have_http_status(200) + expect(json_response).not_to be_an Array + expect(json_response['name']).to eq(rocket.name) + end + end + + describe "POST /projects/:id/awardable/:awardable_id/award_emoji" do + let(:issue2) { create(:issue, project: project, author: user) } + + context "on an issue" do + it "creates a new award emoji" do + post v3_api("/projects/#{project.id}/issues/#{issue.id}/award_emoji", user), name: 'blowfish' + + expect(response).to have_http_status(201) + expect(json_response['name']).to eq('blowfish') + expect(json_response['user']['username']).to eq(user.username) + end + + it "returns a 400 bad request error if the name is not given" do + post v3_api("/projects/#{project.id}/issues/#{issue.id}/award_emoji", user) + + expect(response).to have_http_status(400) + end + + it "returns a 401 unauthorized error if the user is not authenticated" do + post v3_api("/projects/#{project.id}/issues/#{issue.id}/award_emoji"), name: 'thumbsup' + + expect(response).to have_http_status(401) + end + + it "returns a 404 error if the user authored issue" do + post v3_api("/projects/#{project.id}/issues/#{issue2.id}/award_emoji", user), name: 'thumbsup' + + expect(response).to have_http_status(404) + end + + it "normalizes +1 as thumbsup award" do + post v3_api("/projects/#{project.id}/issues/#{issue.id}/award_emoji", user), name: '+1' + + expect(issue.award_emoji.last.name).to eq("thumbsup") + end + + context 'when the emoji already has been awarded' do + it 'returns a 404 status code' do + post v3_api("/projects/#{project.id}/issues/#{issue.id}/award_emoji", user), name: 'thumbsup' + post v3_api("/projects/#{project.id}/issues/#{issue.id}/award_emoji", user), name: 'thumbsup' + + expect(response).to have_http_status(404) + expect(json_response["message"]).to match("has already been taken") + end + end + end + + context 'on a snippet' do + it 'creates a new award emoji' do + snippet = create(:project_snippet, :public, project: project) + + post v3_api("/projects/#{project.id}/snippets/#{snippet.id}/award_emoji", user), name: 'blowfish' + + expect(response).to have_http_status(201) + expect(json_response['name']).to eq('blowfish') + expect(json_response['user']['username']).to eq(user.username) + end + end + end + + describe "POST /projects/:id/awardable/:awardable_id/notes/:note_id/award_emoji" do + let(:note2) { create(:note, project: project, noteable: issue, author: user) } + + it 'creates a new award emoji' do + expect do + post v3_api("/projects/#{project.id}/issues/#{issue.id}/notes/#{note.id}/award_emoji", user), name: 'rocket' + end.to change { note.award_emoji.count }.from(0).to(1) + + expect(response).to have_http_status(201) + expect(json_response['user']['username']).to eq(user.username) + end + + it "it returns 404 error when user authored note" do + post v3_api("/projects/#{project.id}/issues/#{issue.id}/notes/#{note2.id}/award_emoji", user), name: 'thumbsup' + + expect(response).to have_http_status(404) + end + + it "normalizes +1 as thumbsup award" do + post v3_api("/projects/#{project.id}/issues/#{issue.id}/notes/#{note.id}/award_emoji", user), name: '+1' + + expect(note.award_emoji.last.name).to eq("thumbsup") + end + + context 'when the emoji already has been awarded' do + it 'returns a 404 status code' do + post v3_api("/projects/#{project.id}/issues/#{issue.id}/notes/#{note.id}/award_emoji", user), name: 'rocket' + post v3_api("/projects/#{project.id}/issues/#{issue.id}/notes/#{note.id}/award_emoji", user), name: 'rocket' + + expect(response).to have_http_status(404) + expect(json_response["message"]).to match("has already been taken") + end + end + end + + describe 'DELETE /projects/:id/awardable/:awardable_id/award_emoji/:award_id' do + context 'when the awardable is an Issue' do + it 'deletes the award' do + expect do + delete v3_api("/projects/#{project.id}/issues/#{issue.id}/award_emoji/#{award_emoji.id}", user) + + expect(response).to have_http_status(200) + end.to change { issue.award_emoji.count }.from(1).to(0) + end + + it 'returns a 404 error when the award emoji can not be found' do + delete v3_api("/projects/#{project.id}/issues/#{issue.id}/award_emoji/12345", user) + + expect(response).to have_http_status(404) + end + end + + context 'when the awardable is a Merge Request' do + it 'deletes the award' do + expect do + delete v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/award_emoji/#{downvote.id}", user) + + expect(response).to have_http_status(200) + end.to change { merge_request.award_emoji.count }.from(1).to(0) + end + + it 'returns a 404 error when note id not found' do + delete v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/notes/12345", user) + + expect(response).to have_http_status(404) + end + end + + context 'when the awardable is a Snippet' do + let(:snippet) { create(:project_snippet, :public, project: project) } + let!(:award) { create(:award_emoji, awardable: snippet, user: user) } + + it 'deletes the award' do + expect do + delete v3_api("/projects/#{project.id}/snippets/#{snippet.id}/award_emoji/#{award.id}", user) + + expect(response).to have_http_status(200) + end.to change { snippet.award_emoji.count }.from(1).to(0) + end + end + end + + describe 'DELETE /projects/:id/awardable/:awardable_id/award_emoji/:award_emoji_id' do + let!(:rocket) { create(:award_emoji, awardable: note, name: 'rocket', user: user) } + + it 'deletes the award' do + expect do + delete v3_api("/projects/#{project.id}/issues/#{issue.id}/notes/#{note.id}/award_emoji/#{rocket.id}", user) + + expect(response).to have_http_status(200) + end.to change { note.award_emoji.count }.from(1).to(0) + end + end +end diff --git a/spec/requests/api/v3/boards_spec.rb b/spec/requests/api/v3/boards_spec.rb index 8aaf3be4f87..eb95934f354 100644 --- a/spec/requests/api/v3/boards_spec.rb +++ b/spec/requests/api/v3/boards_spec.rb @@ -5,6 +5,7 @@ describe API::V3::Boards, api: true do let(:user) { create(:user) } let(:guest) { create(:user) } + let(:non_member) { create(:user) } let!(:project) { create(:empty_project, :public, creator_id: user.id, namespace: user.namespace ) } let!(:dev_label) do @@ -76,4 +77,37 @@ describe API::V3::Boards, api: true do expect(response).to have_http_status(404) end end + + describe "DELETE /projects/:id/board/lists/:list_id" do + let(:base_url) { "/projects/#{project.id}/boards/#{board.id}/lists" } + + it "rejects a non member from deleting a list" do + delete v3_api("#{base_url}/#{dev_list.id}", non_member) + + expect(response).to have_http_status(403) + end + + it "rejects a user with guest role from deleting a list" do + delete v3_api("#{base_url}/#{dev_list.id}", guest) + + expect(response).to have_http_status(403) + end + + it "returns 404 error if list id not found" do + delete v3_api("#{base_url}/44444", user) + + expect(response).to have_http_status(404) + end + + context "when the user is project owner" do + let(:owner) { create(:user) } + let(:project) { create(:empty_project, namespace: owner.namespace) } + + it "deletes the list if an admin requests it" do + delete v3_api("#{base_url}/#{dev_list.id}", owner) + + expect(response).to have_http_status(200) + end + end + end end diff --git a/spec/requests/api/v3/branches_spec.rb b/spec/requests/api/v3/branches_spec.rb index 0e4c6bc3bc6..5dcd4f21f4e 100644 --- a/spec/requests/api/v3/branches_spec.rb +++ b/spec/requests/api/v3/branches_spec.rb @@ -5,8 +5,13 @@ describe API::V3::Branches, api: true do include ApiHelpers let(:user) { create(:user) } + let(:user2) { create(:user) } let!(:project) { create(:project, :repository, creator: user) } let!(:master) { create(:project_member, :master, user: user, project: project) } + let!(:guest) { create(:project_member, :guest, user: user2, project: project) } + let!(:branch_name) { 'feature' } + let!(:branch_sha) { '0b4bc9a49b562e85de7cc9e834518ea6828729b9' } + let!(:branch_with_dot) { CreateBranchService.new(project, user).execute("with.1.2.3", "master") } describe "GET /projects/:id/repository/branches" do it "returns an array of project branches" do @@ -20,4 +25,111 @@ describe API::V3::Branches, api: true do expect(branch_names).to match_array(project.repository.branch_names) end end + + describe "DELETE /projects/:id/repository/branches/:branch" do + before do + allow_any_instance_of(Repository).to receive(:rm_branch).and_return(true) + end + + it "removes branch" do + delete v3_api("/projects/#{project.id}/repository/branches/#{branch_name}", user) + + expect(response).to have_http_status(200) + expect(json_response['branch_name']).to eq(branch_name) + end + + it "removes a branch with dots in the branch name" do + delete v3_api("/projects/#{project.id}/repository/branches/with.1.2.3", user) + + expect(response).to have_http_status(200) + expect(json_response['branch_name']).to eq("with.1.2.3") + end + + it 'returns 404 if branch not exists' do + delete v3_api("/projects/#{project.id}/repository/branches/foobar", user) + expect(response).to have_http_status(404) + end + + it "removes protected branch" do + create(:protected_branch, project: project, name: branch_name) + delete v3_api("/projects/#{project.id}/repository/branches/#{branch_name}", user) + expect(response).to have_http_status(405) + expect(json_response['message']).to eq('Protected branch cant be removed') + end + + it "does not remove HEAD branch" do + delete v3_api("/projects/#{project.id}/repository/branches/master", user) + expect(response).to have_http_status(405) + expect(json_response['message']).to eq('Cannot remove HEAD branch') + end + end + + describe "DELETE /projects/:id/repository/merged_branches" do + before do + allow_any_instance_of(Repository).to receive(:rm_branch).and_return(true) + end + + it 'returns 200' do + delete v3_api("/projects/#{project.id}/repository/merged_branches", user) + + expect(response).to have_http_status(200) + end + + it 'returns a 403 error if guest' do + delete v3_api("/projects/#{project.id}/repository/merged_branches", user2) + + expect(response).to have_http_status(403) + end + end + + describe "POST /projects/:id/repository/branches" do + it "creates a new branch" do + post v3_api("/projects/#{project.id}/repository/branches", user), + branch_name: 'feature1', + ref: branch_sha + + expect(response).to have_http_status(201) + + expect(json_response['name']).to eq('feature1') + expect(json_response['commit']['id']).to eq(branch_sha) + end + + it "denies for user without push access" do + post v3_api("/projects/#{project.id}/repository/branches", user2), + branch_name: branch_name, + ref: branch_sha + expect(response).to have_http_status(403) + end + + it 'returns 400 if branch name is invalid' do + post v3_api("/projects/#{project.id}/repository/branches", user), + branch_name: 'new design', + ref: branch_sha + expect(response).to have_http_status(400) + expect(json_response['message']).to eq('Branch name is invalid') + end + + it 'returns 400 if branch already exists' do + post v3_api("/projects/#{project.id}/repository/branches", user), + branch_name: 'new_design1', + ref: branch_sha + expect(response).to have_http_status(201) + + post v3_api("/projects/#{project.id}/repository/branches", user), + branch_name: 'new_design1', + ref: branch_sha + + expect(response).to have_http_status(400) + expect(json_response['message']).to eq('Branch already exists') + end + + it 'returns 400 if ref name is invalid' do + post v3_api("/projects/#{project.id}/repository/branches", user), + branch_name: 'new_design3', + ref: 'foo' + + expect(response).to have_http_status(400) + expect(json_response['message']).to eq('Invalid reference name') + end + end end diff --git a/spec/requests/api/v3/broadcast_messages_spec.rb b/spec/requests/api/v3/broadcast_messages_spec.rb new file mode 100644 index 00000000000..06556401a29 --- /dev/null +++ b/spec/requests/api/v3/broadcast_messages_spec.rb @@ -0,0 +1,34 @@ +require 'spec_helper' + +describe API::V3::BroadcastMessages, api: true do + include ApiHelpers + + let(:user) { create(:user) } + let(:admin) { create(:admin) } + + describe 'DELETE /broadcast_messages/:id' do + let!(:message) { create(:broadcast_message) } + + it 'returns a 401 for anonymous users' do + delete v3_api("/broadcast_messages/#{message.id}"), + attributes_for(:broadcast_message) + + expect(response).to have_http_status(401) + end + + it 'returns a 403 for users' do + delete v3_api("/broadcast_messages/#{message.id}", user), + attributes_for(:broadcast_message) + + expect(response).to have_http_status(403) + end + + it 'deletes the broadcast message for admins' do + expect do + delete v3_api("/broadcast_messages/#{message.id}", admin) + + expect(response).to have_http_status(200) + end.to change { BroadcastMessage.count }.by(-1) + end + end +end diff --git a/spec/requests/api/builds_spec.rb b/spec/requests/api/v3/builds_spec.rb index 38aef7f2767..a50c22a6dd1 100644 --- a/spec/requests/api/builds_spec.rb +++ b/spec/requests/api/v3/builds_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe API::Builds, api: true do +describe API::V3::Builds, api: true do include ApiHelpers let(:user) { create(:user) } @@ -16,7 +16,9 @@ describe API::Builds, api: true do let(:query) { '' } before do - get api("/projects/#{project.id}/builds?#{query}", api_user) + create(:ci_build, :skipped, pipeline: pipeline) + + get v3_api("/projects/#{project.id}/builds?#{query}", api_user) end context 'authorized user' do @@ -49,6 +51,18 @@ describe API::Builds, api: true do end end + context 'filter project with scope skipped' do + let(:query) { 'scope=skipped' } + let(:json_build) { json_response.first } + + it 'return builds with status skipped' do + expect(response).to have_http_status 200 + expect(json_response).to be_an Array + expect(json_response.length).to eq 1 + expect(json_build['status']).to eq 'skipped' + end + end + context 'filter project with array of scope elements' do let(:query) { 'scope[0]=pending&scope[1]=running' } @@ -77,7 +91,7 @@ describe API::Builds, api: true do describe 'GET /projects/:id/repository/commits/:sha/builds' do context 'when commit does not exist in repository' do before do - get api("/projects/#{project.id}/repository/commits/1a271fd1/builds", api_user) + get v3_api("/projects/#{project.id}/repository/commits/1a271fd1/builds", api_user) end it 'responds with 404' do @@ -93,7 +107,7 @@ describe API::Builds, api: true do create(:ci_build, pipeline: pipeline) create(:ci_build) - get api("/projects/#{project.id}/repository/commits/#{project.commit.id}/builds", api_user) + get v3_api("/projects/#{project.id}/repository/commits/#{project.commit.id}/builds", api_user) end it 'returns project jobs for specific commit' do @@ -116,7 +130,7 @@ describe API::Builds, api: true do context 'when pipeline has no jobs' do before do branch_head = project.commit('feature').id - get api("/projects/#{project.id}/repository/commits/#{branch_head}/builds", api_user) + get v3_api("/projects/#{project.id}/repository/commits/#{branch_head}/builds", api_user) end it 'returns an empty array' do @@ -132,7 +146,7 @@ describe API::Builds, api: true do create(:ci_pipeline, project: project, sha: project.commit.id) create(:ci_build, pipeline: pipeline) - get api("/projects/#{project.id}/repository/commits/#{project.commit.id}/builds", nil) + get v3_api("/projects/#{project.id}/repository/commits/#{project.commit.id}/builds", nil) end it 'does not return project jobs' do @@ -145,7 +159,7 @@ describe API::Builds, api: true do describe 'GET /projects/:id/builds/:build_id' do before do - get api("/projects/#{project.id}/builds/#{build.id}", api_user) + get v3_api("/projects/#{project.id}/builds/#{build.id}", api_user) end context 'authorized user' do @@ -175,7 +189,7 @@ describe API::Builds, api: true do describe 'GET /projects/:id/builds/:build_id/artifacts' do before do - get api("/projects/#{project.id}/builds/#{build.id}/artifacts", api_user) + get v3_api("/projects/#{project.id}/builds/#{build.id}/artifacts", api_user) end context 'job with artifacts' do @@ -217,7 +231,7 @@ describe API::Builds, api: true do end def path_for_ref(ref = pipeline.ref, job = build.name) - api("/projects/#{project.id}/builds/artifacts/#{ref}/download?job=#{job}", api_user) + v3_api("/projects/#{project.id}/builds/artifacts/#{ref}/download?job=#{job}", api_user) end context 'when not logged in' do @@ -310,7 +324,7 @@ describe API::Builds, api: true do let(:build) { create(:ci_build, :trace, pipeline: pipeline) } before do - get api("/projects/#{project.id}/builds/#{build.id}/trace", api_user) + get v3_api("/projects/#{project.id}/builds/#{build.id}/trace", api_user) end context 'authorized user' do @@ -331,7 +345,7 @@ describe API::Builds, api: true do describe 'POST /projects/:id/builds/:build_id/cancel' do before do - post api("/projects/#{project.id}/builds/#{build.id}/cancel", api_user) + post v3_api("/projects/#{project.id}/builds/#{build.id}/cancel", api_user) end context 'authorized user' do @@ -364,7 +378,7 @@ describe API::Builds, api: true do let(:build) { create(:ci_build, :canceled, pipeline: pipeline) } before do - post api("/projects/#{project.id}/builds/#{build.id}/retry", api_user) + post v3_api("/projects/#{project.id}/builds/#{build.id}/retry", api_user) end context 'authorized user' do @@ -396,7 +410,7 @@ describe API::Builds, api: true do describe 'POST /projects/:id/builds/:build_id/erase' do before do - post api("/projects/#{project.id}/builds/#{build.id}/erase", user) + post v3_api("/projects/#{project.id}/builds/#{build.id}/erase", user) end context 'job is erasable' do @@ -426,7 +440,7 @@ describe API::Builds, api: true do describe 'POST /projects/:id/builds/:build_id/artifacts/keep' do before do - post api("/projects/#{project.id}/builds/#{build.id}/artifacts/keep", user) + post v3_api("/projects/#{project.id}/builds/#{build.id}/artifacts/keep", user) end context 'artifacts did not expire' do @@ -452,7 +466,7 @@ describe API::Builds, api: true do describe 'POST /projects/:id/builds/:build_id/play' do before do - post api("/projects/#{project.id}/builds/#{build.id}/play", user) + post v3_api("/projects/#{project.id}/builds/#{build.id}/play", user) end context 'on an playable job' do diff --git a/spec/requests/api/v3/commits_spec.rb b/spec/requests/api/v3/commits_spec.rb index 2d7584c3e59..adba3a787aa 100644 --- a/spec/requests/api/v3/commits_spec.rb +++ b/spec/requests/api/v3/commits_spec.rb @@ -88,7 +88,7 @@ describe API::V3::Commits, api: true do end end - describe "Create a commit with multiple files and actions" do + describe "POST /projects/:id/repository/commits" do let!(:url) { "/projects/#{project.id}/repository/commits" } it 'returns a 403 unauthorized for user without permissions' do @@ -103,7 +103,7 @@ describe API::V3::Commits, api: true do expect(response).to have_http_status(400) end - context :create do + describe 'create' do let(:message) { 'Created file' } let!(:invalid_c_params) do { @@ -147,8 +147,9 @@ describe API::V3::Commits, api: true do expect(response).to have_http_status(400) end - context 'with project path in URL' do - let(:url) { "/projects/#{project.namespace.path}%2F#{project.path}/repository/commits" } + context 'with project path containing a dot in URL' do + let!(:user) { create(:user, username: 'foo.bar') } + let(:url) { "/projects/#{CGI.escape(project.full_path)}/repository/commits" } it 'a new file in project repo' do post v3_api(url, user), valid_c_params @@ -158,7 +159,7 @@ describe API::V3::Commits, api: true do end end - context :delete do + describe 'delete' do let(:message) { 'Deleted file' } let!(:invalid_d_params) do { @@ -199,7 +200,7 @@ describe API::V3::Commits, api: true do end end - context :move do + describe 'move' do let(:message) { 'Moved file' } let!(:invalid_m_params) do { @@ -244,7 +245,7 @@ describe API::V3::Commits, api: true do end end - context :update do + describe 'update' do let(:message) { 'Updated file' } let!(:invalid_u_params) do { diff --git a/spec/requests/api/v3/deployments_spec.rb b/spec/requests/api/v3/deployments_spec.rb new file mode 100644 index 00000000000..3c5ce407b32 --- /dev/null +++ b/spec/requests/api/v3/deployments_spec.rb @@ -0,0 +1,71 @@ +require 'spec_helper' + +describe API::Deployments, api: true do + include ApiHelpers + + let(:user) { create(:user) } + let(:non_member) { create(:user) } + let(:project) { deployment.environment.project } + let!(:deployment) { create(:deployment) } + + before do + project.team << [user, :master] + end + + shared_examples 'a paginated resources' do + before do + # Fires the request + request + end + + it 'has pagination headers' do + expect(response).to include_pagination_headers + end + end + + describe 'GET /projects/:id/deployments' do + context 'as member of the project' do + it_behaves_like 'a paginated resources' do + let(:request) { get api("/projects/#{project.id}/deployments", user) } + end + + it 'returns projects deployments' do + get api("/projects/#{project.id}/deployments", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.size).to eq(1) + expect(json_response.first['iid']).to eq(deployment.iid) + expect(json_response.first['sha']).to match /\A\h{40}\z/ + end + end + + context 'as non member' do + it 'returns a 404 status code' do + get api("/projects/#{project.id}/deployments", non_member) + + expect(response).to have_http_status(404) + end + end + end + + describe 'GET /projects/:id/deployments/:deployment_id' do + context 'as a member of the project' do + it 'returns the projects deployment' do + get api("/projects/#{project.id}/deployments/#{deployment.id}", user) + + expect(response).to have_http_status(200) + expect(json_response['sha']).to match /\A\h{40}\z/ + expect(json_response['id']).to eq(deployment.id) + end + end + + context 'as non member' do + it 'returns a 404 status code' do + get api("/projects/#{project.id}/deployments/#{deployment.id}", non_member) + + expect(response).to have_http_status(404) + end + end + end +end diff --git a/spec/requests/api/v3/environments_spec.rb b/spec/requests/api/v3/environments_spec.rb new file mode 100644 index 00000000000..216192c9d34 --- /dev/null +++ b/spec/requests/api/v3/environments_spec.rb @@ -0,0 +1,165 @@ +require 'spec_helper' + +describe API::V3::Environments, api: true do + include ApiHelpers + + let(:user) { create(:user) } + let(:non_member) { create(:user) } + let(:project) { create(:empty_project, :private, namespace: user.namespace) } + let!(:environment) { create(:environment, project: project) } + + before do + project.team << [user, :master] + end + + shared_examples 'a paginated resources' do + before do + # Fires the request + request + end + + it 'has pagination headers' do + expect(response.headers).to include('X-Total') + expect(response.headers).to include('X-Total-Pages') + expect(response.headers).to include('X-Per-Page') + expect(response.headers).to include('X-Page') + expect(response.headers).to include('X-Next-Page') + expect(response.headers).to include('X-Prev-Page') + expect(response.headers).to include('Link') + end + end + + describe 'GET /projects/:id/environments' do + context 'as member of the project' do + it_behaves_like 'a paginated resources' do + let(:request) { get v3_api("/projects/#{project.id}/environments", user) } + end + + it 'returns project environments' do + get v3_api("/projects/#{project.id}/environments", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.size).to eq(1) + expect(json_response.first['name']).to eq(environment.name) + expect(json_response.first['external_url']).to eq(environment.external_url) + expect(json_response.first['project']['id']).to eq(project.id) + expect(json_response.first['project']['visibility_level']).to be_present + end + end + + context 'as non member' do + it 'returns a 404 status code' do + get v3_api("/projects/#{project.id}/environments", non_member) + + expect(response).to have_http_status(404) + end + end + end + + describe 'POST /projects/:id/environments' do + context 'as a member' do + it 'creates a environment with valid params' do + post v3_api("/projects/#{project.id}/environments", user), name: "mepmep" + + expect(response).to have_http_status(201) + expect(json_response['name']).to eq('mepmep') + expect(json_response['slug']).to eq('mepmep') + expect(json_response['external']).to be nil + end + + it 'requires name to be passed' do + post v3_api("/projects/#{project.id}/environments", user), external_url: 'test.gitlab.com' + + expect(response).to have_http_status(400) + end + + it 'returns a 400 if environment already exists' do + post v3_api("/projects/#{project.id}/environments", user), name: environment.name + + expect(response).to have_http_status(400) + end + + it 'returns a 400 if slug is specified' do + post v3_api("/projects/#{project.id}/environments", user), name: "foo", slug: "foo" + + expect(response).to have_http_status(400) + expect(json_response["error"]).to eq("slug is automatically generated and cannot be changed") + end + end + + context 'a non member' do + it 'rejects the request' do + post v3_api("/projects/#{project.id}/environments", non_member), name: 'gitlab.com' + + expect(response).to have_http_status(404) + end + + it 'returns a 400 when the required params are missing' do + post v3_api("/projects/12345/environments", non_member), external_url: 'http://env.git.com' + end + end + end + + describe 'PUT /projects/:id/environments/:environment_id' do + it 'returns a 200 if name and external_url are changed' do + url = 'https://mepmep.whatever.ninja' + put v3_api("/projects/#{project.id}/environments/#{environment.id}", user), + name: 'Mepmep', external_url: url + + expect(response).to have_http_status(200) + expect(json_response['name']).to eq('Mepmep') + expect(json_response['external_url']).to eq(url) + end + + it "won't allow slug to be changed" do + slug = environment.slug + api_url = v3_api("/projects/#{project.id}/environments/#{environment.id}", user) + put api_url, slug: slug + "-foo" + + expect(response).to have_http_status(400) + expect(json_response["error"]).to eq("slug is automatically generated and cannot be changed") + end + + it "won't update the external_url if only the name is passed" do + url = environment.external_url + put v3_api("/projects/#{project.id}/environments/#{environment.id}", user), + name: 'Mepmep' + + expect(response).to have_http_status(200) + expect(json_response['name']).to eq('Mepmep') + expect(json_response['external_url']).to eq(url) + end + + it 'returns a 404 if the environment does not exist' do + put v3_api("/projects/#{project.id}/environments/12345", user) + + expect(response).to have_http_status(404) + end + end + + describe 'DELETE /projects/:id/environments/:environment_id' do + context 'as a master' do + it 'returns a 200 for an existing environment' do + delete v3_api("/projects/#{project.id}/environments/#{environment.id}", user) + + expect(response).to have_http_status(200) + end + + it 'returns a 404 for non existing id' do + delete v3_api("/projects/#{project.id}/environments/12345", user) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 Not found') + end + end + + context 'a non member' do + it 'rejects the request' do + delete v3_api("/projects/#{project.id}/environments/#{environment.id}", non_member) + + expect(response).to have_http_status(404) + end + end + end +end diff --git a/spec/requests/api/v3/files_spec.rb b/spec/requests/api/v3/files_spec.rb index 4af05605ec6..3b61139a2cd 100644 --- a/spec/requests/api/v3/files_spec.rb +++ b/spec/requests/api/v3/files_spec.rb @@ -2,17 +2,6 @@ require 'spec_helper' describe API::V3::Files, api: true do include ApiHelpers - let(:user) { create(:user) } - let!(:project) { create(:project, :repository, namespace: user.namespace ) } - let(:guest) { create(:user) { |u| project.add_guest(u) } } - let(:file_path) { 'files/ruby/popen.rb' } - let(:params) do - { - file_path: file_path, - ref: 'master' - } - end - let(:author_email) { FFaker::Internet.email } # I have to remove periods from the end of the name # This happened when the user's name had a suffix (i.e. "Sr.") @@ -26,6 +15,18 @@ describe API::V3::Files, api: true do # ... # Author: Foo Sr <foo@example.com> # ... + + let(:user) { create(:user) } + let!(:project) { create(:project, :repository, namespace: user.namespace ) } + let(:guest) { create(:user) { |u| project.add_guest(u) } } + let(:file_path) { 'files/ruby/popen.rb' } + let(:params) do + { + file_path: file_path, + ref: 'master' + } + end + let(:author_email) { FFaker::Internet.email } let(:author_name) { FFaker::Name.name.chomp("\.") } before { project.team << [user, :developer] } @@ -127,7 +128,7 @@ describe API::V3::Files, api: true do end it "returns a 400 if editor fails to create file" do - allow_any_instance_of(Repository).to receive(:commit_file). + allow_any_instance_of(Repository).to receive(:create_file). and_return(false) post v3_api("/projects/#{project.id}/repository/files", user), valid_params @@ -147,6 +148,20 @@ describe API::V3::Files, api: true do expect(last_commit.author_name).to eq(author_name) end end + + context 'when the repo is empty' do + let!(:project) { create(:project_empty_repo, namespace: user.namespace ) } + + it "creates a new file in project repo" do + post v3_api("/projects/#{project.id}/repository/files", user), valid_params + + expect(response).to have_http_status(201) + expect(json_response['file_path']).to eq('newfile.rb') + last_commit = project.repository.commit.raw + expect(last_commit.author_email).to eq(user.email) + expect(last_commit.author_name).to eq(user.name) + end + end end describe "PUT /projects/:id/repository/files" do @@ -215,7 +230,7 @@ describe API::V3::Files, api: true do end it "returns a 400 if fails to create file" do - allow_any_instance_of(Repository).to receive(:remove_file).and_return(false) + allow_any_instance_of(Repository).to receive(:delete_file).and_return(false) delete v3_api("/projects/#{project.id}/repository/files", user), valid_params diff --git a/spec/requests/api/v3/groups_spec.rb b/spec/requests/api/v3/groups_spec.rb new file mode 100644 index 00000000000..a71b7d4b008 --- /dev/null +++ b/spec/requests/api/v3/groups_spec.rb @@ -0,0 +1,565 @@ +require 'spec_helper' + +describe API::V3::Groups, api: true do + include ApiHelpers + include UploadHelpers + + let(:user1) { create(:user, can_create_group: false) } + let(:user2) { create(:user) } + let(:user3) { create(:user) } + let(:admin) { create(:admin) } + let!(:group1) { create(:group, avatar: File.open(uploaded_image_temp_path)) } + let!(:group2) { create(:group, :private) } + let!(:project1) { create(:empty_project, namespace: group1) } + let!(:project2) { create(:empty_project, namespace: group2) } + let!(:project3) { create(:empty_project, namespace: group1, path: 'test', visibility_level: Gitlab::VisibilityLevel::PRIVATE) } + + before do + group1.add_owner(user1) + group2.add_owner(user2) + end + + describe "GET /groups" do + context "when unauthenticated" do + it "returns authentication error" do + get v3_api("/groups") + + expect(response).to have_http_status(401) + end + end + + context "when authenticated as user" do + it "normal user: returns an array of groups of user1" do + get v3_api("/groups", user1) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(1) + expect(json_response) + .to satisfy_one { |group| group['name'] == group1.name } + end + + it "does not include statistics" do + get v3_api("/groups", user1), statistics: true + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.first).not_to include 'statistics' + end + end + + context "when authenticated as admin" do + it "admin: returns an array of all groups" do + get v3_api("/groups", admin) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(2) + end + + it "does not include statistics by default" do + get v3_api("/groups", admin) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.first).not_to include('statistics') + end + + it "includes statistics if requested" do + attributes = { + storage_size: 702, + repository_size: 123, + lfs_objects_size: 234, + build_artifacts_size: 345, + }.stringify_keys + + project1.statistics.update!(attributes) + + get v3_api("/groups", admin), statistics: true + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response) + .to satisfy_one { |group| group['statistics'] == attributes } + end + end + + context "when using skip_groups in request" do + it "returns all groups excluding skipped groups" do + get v3_api("/groups", admin), skip_groups: [group2.id] + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(1) + end + end + + context "when using all_available in request" do + let(:response_groups) { json_response.map { |group| group['name'] } } + + it "returns all groups you have access to" do + public_group = create :group, :public + + get v3_api("/groups", user1), all_available: true + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(response_groups).to contain_exactly(public_group.name, group1.name) + end + end + + context "when using sorting" do + let(:group3) { create(:group, name: "a#{group1.name}", path: "z#{group1.path}") } + let(:response_groups) { json_response.map { |group| group['name'] } } + + before do + group3.add_owner(user1) + end + + it "sorts by name ascending by default" do + get v3_api("/groups", user1) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(response_groups).to eq([group3.name, group1.name]) + end + + it "sorts in descending order when passed" do + get v3_api("/groups", user1), sort: "desc" + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(response_groups).to eq([group1.name, group3.name]) + end + + it "sorts by the order_by param" do + get v3_api("/groups", user1), order_by: "path" + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(response_groups).to eq([group1.name, group3.name]) + end + end + end + + describe 'GET /groups/owned' do + context 'when unauthenticated' do + it 'returns authentication error' do + get v3_api('/groups/owned') + + expect(response).to have_http_status(401) + end + end + + context 'when authenticated as group owner' do + it 'returns an array of groups the user owns' do + get v3_api('/groups/owned', user2) + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.first['name']).to eq(group2.name) + end + end + end + + describe "GET /groups/:id" do + context "when authenticated as user" do + it "returns one of user1's groups" do + project = create(:empty_project, namespace: group2, path: 'Foo') + create(:project_group_link, project: project, group: group1) + + get v3_api("/groups/#{group1.id}", user1) + + expect(response).to have_http_status(200) + expect(json_response['id']).to eq(group1.id) + expect(json_response['name']).to eq(group1.name) + expect(json_response['path']).to eq(group1.path) + expect(json_response['description']).to eq(group1.description) + expect(json_response['visibility_level']).to eq(group1.visibility_level) + expect(json_response['avatar_url']).to eq(group1.avatar_url) + expect(json_response['web_url']).to eq(group1.web_url) + expect(json_response['request_access_enabled']).to eq(group1.request_access_enabled) + expect(json_response['full_name']).to eq(group1.full_name) + expect(json_response['full_path']).to eq(group1.full_path) + expect(json_response['parent_id']).to eq(group1.parent_id) + expect(json_response['projects']).to be_an Array + expect(json_response['projects'].length).to eq(2) + expect(json_response['shared_projects']).to be_an Array + expect(json_response['shared_projects'].length).to eq(1) + expect(json_response['shared_projects'][0]['id']).to eq(project.id) + end + + it "does not return a non existing group" do + get v3_api("/groups/1328", user1) + + expect(response).to have_http_status(404) + end + + it "does not return a group not attached to user1" do + get v3_api("/groups/#{group2.id}", user1) + + expect(response).to have_http_status(404) + end + end + + context "when authenticated as admin" do + it "returns any existing group" do + get v3_api("/groups/#{group2.id}", admin) + + expect(response).to have_http_status(200) + expect(json_response['name']).to eq(group2.name) + end + + it "does not return a non existing group" do + get v3_api("/groups/1328", admin) + + expect(response).to have_http_status(404) + end + end + + context 'when using group path in URL' do + it 'returns any existing group' do + get v3_api("/groups/#{group1.path}", admin) + + expect(response).to have_http_status(200) + expect(json_response['name']).to eq(group1.name) + end + + it 'does not return a non existing group' do + get v3_api('/groups/unknown', admin) + + expect(response).to have_http_status(404) + end + + it 'does not return a group not attached to user1' do + get v3_api("/groups/#{group2.path}", user1) + + expect(response).to have_http_status(404) + end + end + end + + describe 'PUT /groups/:id' do + let(:new_group_name) { 'New Group'} + + context 'when authenticated as the group owner' do + it 'updates the group' do + put v3_api("/groups/#{group1.id}", user1), name: new_group_name, request_access_enabled: true + + expect(response).to have_http_status(200) + expect(json_response['name']).to eq(new_group_name) + expect(json_response['request_access_enabled']).to eq(true) + end + + it 'returns 404 for a non existing group' do + put v3_api('/groups/1328', user1), name: new_group_name + + expect(response).to have_http_status(404) + end + end + + context 'when authenticated as the admin' do + it 'updates the group' do + put v3_api("/groups/#{group1.id}", admin), name: new_group_name + + expect(response).to have_http_status(200) + expect(json_response['name']).to eq(new_group_name) + end + end + + context 'when authenticated as an user that can see the group' do + it 'does not updates the group' do + put v3_api("/groups/#{group1.id}", user2), name: new_group_name + + expect(response).to have_http_status(403) + end + end + + context 'when authenticated as an user that cannot see the group' do + it 'returns 404 when trying to update the group' do + put v3_api("/groups/#{group2.id}", user1), name: new_group_name + + expect(response).to have_http_status(404) + end + end + end + + describe "GET /groups/:id/projects" do + context "when authenticated as user" do + it "returns the group's projects" do + get v3_api("/groups/#{group1.id}/projects", user1) + + expect(response).to have_http_status(200) + expect(json_response.length).to eq(2) + project_names = json_response.map { |proj| proj['name'] } + expect(project_names).to match_array([project1.name, project3.name]) + expect(json_response.first['visibility_level']).to be_present + end + + it "returns the group's projects with simple representation" do + get v3_api("/groups/#{group1.id}/projects", user1), simple: true + + expect(response).to have_http_status(200) + expect(json_response.length).to eq(2) + project_names = json_response.map { |proj| proj['name'] } + expect(project_names).to match_array([project1.name, project3.name]) + expect(json_response.first['visibility_level']).not_to be_present + end + + it 'filters the groups projects' do + public_project = create(:empty_project, :public, path: 'test1', group: group1) + + get v3_api("/groups/#{group1.id}/projects", user1), visibility: 'public' + + expect(response).to have_http_status(200) + expect(json_response).to be_an(Array) + expect(json_response.length).to eq(1) + expect(json_response.first['name']).to eq(public_project.name) + end + + it "does not return a non existing group" do + get v3_api("/groups/1328/projects", user1) + + expect(response).to have_http_status(404) + end + + it "does not return a group not attached to user1" do + get v3_api("/groups/#{group2.id}/projects", user1) + + expect(response).to have_http_status(404) + end + + it "only returns projects to which user has access" do + project3.team << [user3, :developer] + + get v3_api("/groups/#{group1.id}/projects", user3) + + expect(response).to have_http_status(200) + expect(json_response.length).to eq(1) + expect(json_response.first['name']).to eq(project3.name) + end + + it 'only returns the projects owned by user' do + project2.group.add_owner(user3) + + get v3_api("/groups/#{project2.group.id}/projects", user3), owned: true + + expect(response).to have_http_status(200) + expect(json_response.length).to eq(1) + expect(json_response.first['name']).to eq(project2.name) + end + + it 'only returns the projects starred by user' do + user1.starred_projects = [project1] + + get v3_api("/groups/#{group1.id}/projects", user1), starred: true + + expect(response).to have_http_status(200) + expect(json_response.length).to eq(1) + expect(json_response.first['name']).to eq(project1.name) + end + end + + context "when authenticated as admin" do + it "returns any existing group" do + get v3_api("/groups/#{group2.id}/projects", admin) + + expect(response).to have_http_status(200) + expect(json_response.length).to eq(1) + expect(json_response.first['name']).to eq(project2.name) + end + + it "does not return a non existing group" do + get v3_api("/groups/1328/projects", admin) + + expect(response).to have_http_status(404) + end + end + + context 'when using group path in URL' do + it 'returns any existing group' do + get v3_api("/groups/#{group1.path}/projects", admin) + + expect(response).to have_http_status(200) + project_names = json_response.map { |proj| proj['name'] } + expect(project_names).to match_array([project1.name, project3.name]) + end + + it 'does not return a non existing group' do + get v3_api('/groups/unknown/projects', admin) + + expect(response).to have_http_status(404) + end + + it 'does not return a group not attached to user1' do + get v3_api("/groups/#{group2.path}/projects", user1) + + expect(response).to have_http_status(404) + end + end + end + + describe "POST /groups" do + context "when authenticated as user without group permissions" do + it "does not create group" do + post v3_api("/groups", user1), attributes_for(:group) + + expect(response).to have_http_status(403) + end + end + + context "when authenticated as user with group permissions" do + it "creates group" do + group = attributes_for(:group, { request_access_enabled: false }) + + post v3_api("/groups", user3), group + + expect(response).to have_http_status(201) + + expect(json_response["name"]).to eq(group[:name]) + expect(json_response["path"]).to eq(group[:path]) + expect(json_response["request_access_enabled"]).to eq(group[:request_access_enabled]) + end + + it "creates a nested group" do + parent = create(:group) + parent.add_owner(user3) + group = attributes_for(:group, { parent_id: parent.id }) + + post v3_api("/groups", user3), group + + expect(response).to have_http_status(201) + + expect(json_response["full_path"]).to eq("#{parent.path}/#{group[:path]}") + expect(json_response["parent_id"]).to eq(parent.id) + end + + it "does not create group, duplicate" do + post v3_api("/groups", user3), { name: 'Duplicate Test', path: group2.path } + + expect(response).to have_http_status(400) + expect(response.message).to eq("Bad Request") + end + + it "returns 400 bad request error if name not given" do + post v3_api("/groups", user3), { path: group2.path } + + expect(response).to have_http_status(400) + end + + it "returns 400 bad request error if path not given" do + post v3_api("/groups", user3), { name: 'test' } + + expect(response).to have_http_status(400) + end + end + end + + describe "DELETE /groups/:id" do + context "when authenticated as user" do + it "removes group" do + delete v3_api("/groups/#{group1.id}", user1) + + expect(response).to have_http_status(200) + end + + it "does not remove a group if not an owner" do + user4 = create(:user) + group1.add_master(user4) + + delete v3_api("/groups/#{group1.id}", user3) + + expect(response).to have_http_status(403) + end + + it "does not remove a non existing group" do + delete v3_api("/groups/1328", user1) + + expect(response).to have_http_status(404) + end + + it "does not remove a group not attached to user1" do + delete v3_api("/groups/#{group2.id}", user1) + + expect(response).to have_http_status(404) + end + end + + context "when authenticated as admin" do + it "removes any existing group" do + delete v3_api("/groups/#{group2.id}", admin) + + expect(response).to have_http_status(200) + end + + it "does not remove a non existing group" do + delete v3_api("/groups/1328", admin) + + expect(response).to have_http_status(404) + end + end + end + + describe "POST /groups/:id/projects/:project_id" do + let(:project) { create(:empty_project) } + let(:project_path) { "#{project.namespace.path}%2F#{project.path}" } + + before(:each) do + allow_any_instance_of(Projects::TransferService). + to receive(:execute).and_return(true) + end + + context "when authenticated as user" do + it "does not transfer project to group" do + post v3_api("/groups/#{group1.id}/projects/#{project.id}", user2) + + expect(response).to have_http_status(403) + end + end + + context "when authenticated as admin" do + it "transfers project to group" do + post v3_api("/groups/#{group1.id}/projects/#{project.id}", admin) + + expect(response).to have_http_status(201) + end + + context 'when using project path in URL' do + context 'with a valid project path' do + it "transfers project to group" do + post v3_api("/groups/#{group1.id}/projects/#{project_path}", admin) + + expect(response).to have_http_status(201) + end + end + + context 'with a non-existent project path' do + it "does not transfer project to group" do + post v3_api("/groups/#{group1.id}/projects/nogroup%2Fnoproject", admin) + + expect(response).to have_http_status(404) + end + end + end + + context 'when using a group path in URL' do + context 'with a valid group path' do + it "transfers project to group" do + post v3_api("/groups/#{group1.path}/projects/#{project_path}", admin) + + expect(response).to have_http_status(201) + end + end + + context 'with a non-existent group path' do + it "does not transfer project to group" do + post v3_api("/groups/noexist/projects/#{project_path}", admin) + + expect(response).to have_http_status(404) + end + end + end + end + end +end diff --git a/spec/requests/api/v3/issues_spec.rb b/spec/requests/api/v3/issues_spec.rb index 8e6732fe23e..51021eec63c 100644 --- a/spec/requests/api/v3/issues_spec.rb +++ b/spec/requests/api/v3/issues_spec.rb @@ -232,6 +232,13 @@ describe API::V3::Issues, api: true do expect(json_response).to be_an Array expect(response_dates).to eq(response_dates.sort) end + + it 'matches V3 response schema' do + get v3_api('/issues', user) + + expect(response).to have_http_status(200) + expect(response).to match_response_schema('public_api/v3/issues') + end end end @@ -432,6 +439,12 @@ describe API::V3::Issues, api: true do describe "GET /projects/:id/issues" do let(:base_url) { "/projects/#{project.id}" } + it 'returns 404 when project does not exist' do + get v3_api('/projects/1000/issues', non_member) + + expect(response).to have_http_status(404) + end + it "returns 404 on private projects for other users" do private_project = create(:empty_project, :private) create(:issue, project: private_project) @@ -722,7 +735,7 @@ describe API::V3::Issues, api: true do expect(response).to have_http_status(201) expect(json_response['title']).to eq('new issue') expect(json_response['description']).to be_nil - expect(json_response['labels']).to eq(['label', 'label2']) + expect(json_response['labels']).to eq(%w(label label2)) expect(json_response['confidential']).to be_falsy end @@ -1281,6 +1294,6 @@ describe API::V3::Issues, api: true do describe 'time tracking endpoints' do let(:issuable) { issue } - include_examples 'time tracking endpoints', 'issue' + include_examples 'V3 time tracking endpoints', 'issue' end end diff --git a/spec/requests/api/v3/labels_spec.rb b/spec/requests/api/v3/labels_spec.rb index bcb0c6b9449..dfac357d37c 100644 --- a/spec/requests/api/v3/labels_spec.rb +++ b/spec/requests/api/v3/labels_spec.rb @@ -21,11 +21,11 @@ describe API::V3::Labels, api: true do create(:labeled_issue, project: project, labels: [label1], author: user, state: :closed) create(:labeled_merge_request, labels: [priority_label], author: user, source_project: project ) - expected_keys = [ - 'id', 'name', 'color', 'description', - 'open_issues_count', 'closed_issues_count', 'open_merge_requests_count', - 'subscribed', 'priority' - ] + expected_keys = %w( + id name color description + open_issues_count closed_issues_count open_merge_requests_count + subscribed priority + ) get v3_api("/projects/#{project.id}/labels", user) @@ -149,4 +149,23 @@ describe API::V3::Labels, api: true do end end end + + describe 'DELETE /projects/:id/labels' do + it 'returns 200 for existing label' do + delete v3_api("/projects/#{project.id}/labels", user), name: 'label1' + + expect(response).to have_http_status(200) + end + + it 'returns 404 for non existing label' do + delete v3_api("/projects/#{project.id}/labels", user), name: 'label2' + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 Label Not Found') + end + + it 'returns 400 for wrong parameters' do + delete v3_api("/projects/#{project.id}/labels", user) + expect(response).to have_http_status(400) + end + end end diff --git a/spec/requests/api/v3/members_spec.rb b/spec/requests/api/v3/members_spec.rb index 28c3ca03960..13814ed10c3 100644 --- a/spec/requests/api/v3/members_spec.rb +++ b/spec/requests/api/v3/members_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe API::Members, api: true do +describe API::V3::Members, api: true do include ApiHelpers let(:master) { create(:user) } diff --git a/spec/requests/api/v3/merge_request_diffs_spec.rb b/spec/requests/api/v3/merge_request_diffs_spec.rb new file mode 100644 index 00000000000..c53800eef30 --- /dev/null +++ b/spec/requests/api/v3/merge_request_diffs_spec.rb @@ -0,0 +1,50 @@ +require "spec_helper" + +describe API::V3::MergeRequestDiffs, 'MergeRequestDiffs', api: true do + include ApiHelpers + + let!(:user) { create(:user) } + let!(:merge_request) { create(:merge_request, importing: true) } + let!(:project) { merge_request.target_project } + + before do + merge_request.merge_request_diffs.create(head_commit_sha: '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9') + merge_request.merge_request_diffs.create(head_commit_sha: '5937ac0a7beb003549fc5fd26fc247adbce4a52e') + project.team << [user, :master] + end + + describe 'GET /projects/:id/merge_requests/:merge_request_id/versions' do + it 'returns 200 for a valid merge request' do + get v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/versions", user) + merge_request_diff = merge_request.merge_request_diffs.first + + expect(response.status).to eq 200 + expect(json_response.size).to eq(merge_request.merge_request_diffs.size) + expect(json_response.first['id']).to eq(merge_request_diff.id) + expect(json_response.first['head_commit_sha']).to eq(merge_request_diff.head_commit_sha) + end + + it 'returns a 404 when merge_request_id not found' do + get v3_api("/projects/#{project.id}/merge_requests/999/versions", user) + expect(response).to have_http_status(404) + end + end + + describe 'GET /projects/:id/merge_requests/:merge_request_id/versions/:version_id' do + it 'returns a 200 for a valid merge request' do + merge_request_diff = merge_request.merge_request_diffs.first + get v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/versions/#{merge_request_diff.id}", user) + + expect(response.status).to eq 200 + expect(json_response['id']).to eq(merge_request_diff.id) + expect(json_response['head_commit_sha']).to eq(merge_request_diff.head_commit_sha) + expect(json_response['diffs'].size).to eq(merge_request_diff.diffs.size) + end + + it 'returns a 404 when merge_request_id not found' do + get v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/versions/999", user) + + expect(response).to have_http_status(404) + end + end +end diff --git a/spec/requests/api/v3/merge_requests_spec.rb b/spec/requests/api/v3/merge_requests_spec.rb index b94e1ef4ced..d73e9635c9b 100644 --- a/spec/requests/api/v3/merge_requests_spec.rb +++ b/spec/requests/api/v3/merge_requests_spec.rb @@ -73,6 +73,13 @@ describe API::MergeRequests, api: true do expect(json_response.first['title']).to eq(merge_request_merged.title) end + it 'matches V3 response schema' do + get v3_api("/projects/#{project.id}/merge_requests", user) + + expect(response).to have_http_status(200) + expect(response).to match_response_schema('public_api/v3/merge_requests') + end + context "with ordering" do before do @mr_later = mr_with_later_created_and_updated_at_time @@ -237,7 +244,7 @@ describe API::MergeRequests, api: true do expect(response).to have_http_status(201) expect(json_response['title']).to eq('Test merge_request') - expect(json_response['labels']).to eq(['label', 'label2']) + expect(json_response['labels']).to eq(%w(label label2)) expect(json_response['milestone']['id']).to eq(milestone.id) expect(json_response['force_remove_source_branch']).to be_truthy end @@ -705,7 +712,7 @@ describe API::MergeRequests, api: true do describe 'Time tracking' do let(:issuable) { merge_request } - include_examples 'time tracking endpoints', 'merge_request' + include_examples 'V3 time tracking endpoints', 'merge_request' end def mr_with_later_created_and_updated_at_time diff --git a/spec/requests/api/v3/milestones_spec.rb b/spec/requests/api/v3/milestones_spec.rb new file mode 100644 index 00000000000..127c0eec881 --- /dev/null +++ b/spec/requests/api/v3/milestones_spec.rb @@ -0,0 +1,239 @@ +require 'spec_helper' + +describe API::V3::Milestones, api: true do + include ApiHelpers + let(:user) { create(:user) } + let!(:project) { create(:empty_project, namespace: user.namespace ) } + let!(:closed_milestone) { create(:closed_milestone, project: project) } + let!(:milestone) { create(:milestone, project: project) } + + before { project.team << [user, :developer] } + + describe 'GET /projects/:id/milestones' do + it 'returns project milestones' do + get v3_api("/projects/#{project.id}/milestones", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.first['title']).to eq(milestone.title) + end + + it 'returns a 401 error if user not authenticated' do + get v3_api("/projects/#{project.id}/milestones") + + expect(response).to have_http_status(401) + end + + it 'returns an array of active milestones' do + get v3_api("/projects/#{project.id}/milestones?state=active", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(1) + expect(json_response.first['id']).to eq(milestone.id) + end + + it 'returns an array of closed milestones' do + get v3_api("/projects/#{project.id}/milestones?state=closed", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(1) + expect(json_response.first['id']).to eq(closed_milestone.id) + end + end + + describe 'GET /projects/:id/milestones/:milestone_id' do + it 'returns a project milestone by id' do + get v3_api("/projects/#{project.id}/milestones/#{milestone.id}", user) + + expect(response).to have_http_status(200) + expect(json_response['title']).to eq(milestone.title) + expect(json_response['iid']).to eq(milestone.iid) + end + + it 'returns a project milestone by iid' do + get v3_api("/projects/#{project.id}/milestones?iid=#{closed_milestone.iid}", user) + + expect(response.status).to eq 200 + expect(json_response.size).to eq(1) + expect(json_response.first['title']).to eq closed_milestone.title + expect(json_response.first['id']).to eq closed_milestone.id + end + + it 'returns a project milestone by iid array' do + get v3_api("/projects/#{project.id}/milestones", user), iid: [milestone.iid, closed_milestone.iid] + + expect(response).to have_http_status(200) + expect(json_response.size).to eq(2) + expect(json_response.first['title']).to eq milestone.title + expect(json_response.first['id']).to eq milestone.id + end + + it 'returns 401 error if user not authenticated' do + get v3_api("/projects/#{project.id}/milestones/#{milestone.id}") + + expect(response).to have_http_status(401) + end + + it 'returns a 404 error if milestone id not found' do + get v3_api("/projects/#{project.id}/milestones/1234", user) + + expect(response).to have_http_status(404) + end + end + + describe 'POST /projects/:id/milestones' do + it 'creates a new project milestone' do + post v3_api("/projects/#{project.id}/milestones", user), title: 'new milestone' + + expect(response).to have_http_status(201) + expect(json_response['title']).to eq('new milestone') + expect(json_response['description']).to be_nil + end + + it 'creates a new project milestone with description and dates' do + post v3_api("/projects/#{project.id}/milestones", user), + title: 'new milestone', description: 'release', due_date: '2013-03-02', start_date: '2013-02-02' + + expect(response).to have_http_status(201) + expect(json_response['description']).to eq('release') + expect(json_response['due_date']).to eq('2013-03-02') + expect(json_response['start_date']).to eq('2013-02-02') + end + + it 'returns a 400 error if title is missing' do + post v3_api("/projects/#{project.id}/milestones", user) + + expect(response).to have_http_status(400) + end + + it 'returns a 400 error if params are invalid (duplicate title)' do + post v3_api("/projects/#{project.id}/milestones", user), + title: milestone.title, description: 'release', due_date: '2013-03-02' + + expect(response).to have_http_status(400) + end + + it 'creates a new project with reserved html characters' do + post v3_api("/projects/#{project.id}/milestones", user), title: 'foo & bar 1.1 -> 2.2' + + expect(response).to have_http_status(201) + expect(json_response['title']).to eq('foo & bar 1.1 -> 2.2') + expect(json_response['description']).to be_nil + end + end + + describe 'PUT /projects/:id/milestones/:milestone_id' do + it 'updates a project milestone' do + put v3_api("/projects/#{project.id}/milestones/#{milestone.id}", user), + title: 'updated title' + + expect(response).to have_http_status(200) + expect(json_response['title']).to eq('updated title') + end + + it 'removes a due date if nil is passed' do + milestone.update!(due_date: "2016-08-05") + + put v3_api("/projects/#{project.id}/milestones/#{milestone.id}", user), due_date: nil + + expect(response).to have_http_status(200) + expect(json_response['due_date']).to be_nil + end + + it 'returns a 404 error if milestone id not found' do + put v3_api("/projects/#{project.id}/milestones/1234", user), + title: 'updated title' + + expect(response).to have_http_status(404) + end + end + + describe 'PUT /projects/:id/milestones/:milestone_id to close milestone' do + it 'updates a project milestone' do + put v3_api("/projects/#{project.id}/milestones/#{milestone.id}", user), + state_event: 'close' + expect(response).to have_http_status(200) + + expect(json_response['state']).to eq('closed') + end + end + + describe 'PUT /projects/:id/milestones/:milestone_id to test observer on close' do + it 'creates an activity event when an milestone is closed' do + expect(Event).to receive(:create) + + put v3_api("/projects/#{project.id}/milestones/#{milestone.id}", user), + state_event: 'close' + end + end + + describe 'GET /projects/:id/milestones/:milestone_id/issues' do + before do + milestone.issues << create(:issue, project: project) + end + it 'returns project issues for a particular milestone' do + get v3_api("/projects/#{project.id}/milestones/#{milestone.id}/issues", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.first['milestone']['title']).to eq(milestone.title) + end + + it 'matches V3 response schema for a list of issues' do + get v3_api("/projects/#{project.id}/milestones/#{milestone.id}/issues", user) + + expect(response).to have_http_status(200) + expect(response).to match_response_schema('public_api/v3/issues') + end + + it 'returns a 401 error if user not authenticated' do + get v3_api("/projects/#{project.id}/milestones/#{milestone.id}/issues") + + expect(response).to have_http_status(401) + end + + describe 'confidential issues' do + let(:public_project) { create(:empty_project, :public) } + let(:milestone) { create(:milestone, project: public_project) } + let(:issue) { create(:issue, project: public_project) } + let(:confidential_issue) { create(:issue, confidential: true, project: public_project) } + + before do + public_project.team << [user, :developer] + milestone.issues << issue << confidential_issue + end + + it 'returns confidential issues to team members' do + get v3_api("/projects/#{public_project.id}/milestones/#{milestone.id}/issues", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.size).to eq(2) + expect(json_response.map { |issue| issue['id'] }).to include(issue.id, confidential_issue.id) + end + + it 'does not return confidential issues to team members with guest role' do + member = create(:user) + project.team << [member, :guest] + + get v3_api("/projects/#{public_project.id}/milestones/#{milestone.id}/issues", member) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.size).to eq(1) + expect(json_response.map { |issue| issue['id'] }).to include(issue.id) + end + + it 'does not return confidential issues to regular users' do + get v3_api("/projects/#{public_project.id}/milestones/#{milestone.id}/issues", create(:user)) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.size).to eq(1) + expect(json_response.map { |issue| issue['id'] }).to include(issue.id) + end + end + end +end diff --git a/spec/requests/api/v3/notes_spec.rb b/spec/requests/api/v3/notes_spec.rb new file mode 100644 index 00000000000..ddef2d5eb04 --- /dev/null +++ b/spec/requests/api/v3/notes_spec.rb @@ -0,0 +1,433 @@ +require 'spec_helper' + +describe API::V3::Notes, api: true do + include ApiHelpers + + let(:user) { create(:user) } + let!(:project) { create(:empty_project, :public, namespace: user.namespace) } + let!(:issue) { create(:issue, project: project, author: user) } + let!(:merge_request) { create(:merge_request, source_project: project, target_project: project, author: user) } + let!(:snippet) { create(:project_snippet, project: project, author: user) } + let!(:issue_note) { create(:note, noteable: issue, project: project, author: user) } + let!(:merge_request_note) { create(:note, noteable: merge_request, project: project, author: user) } + let!(:snippet_note) { create(:note, noteable: snippet, project: project, author: user) } + + # For testing the cross-reference of a private issue in a public issue + let(:private_user) { create(:user) } + let(:private_project) do + create(:empty_project, namespace: private_user.namespace). + tap { |p| p.team << [private_user, :master] } + end + let(:private_issue) { create(:issue, project: private_project) } + + let(:ext_proj) { create(:empty_project, :public) } + let(:ext_issue) { create(:issue, project: ext_proj) } + + let!(:cross_reference_note) do + create :note, + noteable: ext_issue, project: ext_proj, + note: "mentioned in issue #{private_issue.to_reference(ext_proj)}", + system: true + end + + before { project.team << [user, :reporter] } + + describe "GET /projects/:id/noteable/:noteable_id/notes" do + context "when noteable is an Issue" do + it "returns an array of issue notes" do + get v3_api("/projects/#{project.id}/issues/#{issue.id}/notes", user) + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.first['body']).to eq(issue_note.note) + expect(json_response.first['upvote']).to be_falsey + expect(json_response.first['downvote']).to be_falsey + end + + it "returns a 404 error when issue id not found" do + get v3_api("/projects/#{project.id}/issues/12345/notes", user) + + expect(response).to have_http_status(404) + end + + context "and current user cannot view the notes" do + it "returns an empty array" do + get v3_api("/projects/#{ext_proj.id}/issues/#{ext_issue.id}/notes", user) + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response).to be_empty + end + + context "and issue is confidential" do + before { ext_issue.update_attributes(confidential: true) } + + it "returns 404" do + get v3_api("/projects/#{ext_proj.id}/issues/#{ext_issue.id}/notes", user) + + expect(response).to have_http_status(404) + end + end + + context "and current user can view the note" do + it "returns an empty array" do + get v3_api("/projects/#{ext_proj.id}/issues/#{ext_issue.id}/notes", private_user) + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.first['body']).to eq(cross_reference_note.note) + end + end + end + end + + context "when noteable is a Snippet" do + it "returns an array of snippet notes" do + get v3_api("/projects/#{project.id}/snippets/#{snippet.id}/notes", user) + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.first['body']).to eq(snippet_note.note) + end + + it "returns a 404 error when snippet id not found" do + get v3_api("/projects/#{project.id}/snippets/42/notes", user) + + expect(response).to have_http_status(404) + end + + it "returns 404 when not authorized" do + get v3_api("/projects/#{project.id}/snippets/#{snippet.id}/notes", private_user) + + expect(response).to have_http_status(404) + end + end + + context "when noteable is a Merge Request" do + it "returns an array of merge_requests notes" do + get v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/notes", user) + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.first['body']).to eq(merge_request_note.note) + end + + it "returns a 404 error if merge request id not found" do + get v3_api("/projects/#{project.id}/merge_requests/4444/notes", user) + + expect(response).to have_http_status(404) + end + + it "returns 404 when not authorized" do + get v3_api("/projects/#{project.id}/merge_requests/4444/notes", private_user) + + expect(response).to have_http_status(404) + end + end + end + + describe "GET /projects/:id/noteable/:noteable_id/notes/:note_id" do + context "when noteable is an Issue" do + it "returns an issue note by id" do + get v3_api("/projects/#{project.id}/issues/#{issue.id}/notes/#{issue_note.id}", user) + + expect(response).to have_http_status(200) + expect(json_response['body']).to eq(issue_note.note) + end + + it "returns a 404 error if issue note not found" do + get v3_api("/projects/#{project.id}/issues/#{issue.id}/notes/12345", user) + + expect(response).to have_http_status(404) + end + + context "and current user cannot view the note" do + it "returns a 404 error" do + get v3_api("/projects/#{ext_proj.id}/issues/#{ext_issue.id}/notes/#{cross_reference_note.id}", user) + + expect(response).to have_http_status(404) + end + + context "when issue is confidential" do + before { issue.update_attributes(confidential: true) } + + it "returns 404" do + get v3_api("/projects/#{project.id}/issues/#{issue.id}/notes/#{issue_note.id}", private_user) + + expect(response).to have_http_status(404) + end + end + + context "and current user can view the note" do + it "returns an issue note by id" do + get v3_api("/projects/#{ext_proj.id}/issues/#{ext_issue.id}/notes/#{cross_reference_note.id}", private_user) + + expect(response).to have_http_status(200) + expect(json_response['body']).to eq(cross_reference_note.note) + end + end + end + end + + context "when noteable is a Snippet" do + it "returns a snippet note by id" do + get v3_api("/projects/#{project.id}/snippets/#{snippet.id}/notes/#{snippet_note.id}", user) + + expect(response).to have_http_status(200) + expect(json_response['body']).to eq(snippet_note.note) + end + + it "returns a 404 error if snippet note not found" do + get v3_api("/projects/#{project.id}/snippets/#{snippet.id}/notes/12345", user) + + expect(response).to have_http_status(404) + end + end + end + + describe "POST /projects/:id/noteable/:noteable_id/notes" do + context "when noteable is an Issue" do + it "creates a new issue note" do + post v3_api("/projects/#{project.id}/issues/#{issue.id}/notes", user), body: 'hi!' + + expect(response).to have_http_status(201) + expect(json_response['body']).to eq('hi!') + expect(json_response['author']['username']).to eq(user.username) + end + + it "returns a 400 bad request error if body not given" do + post v3_api("/projects/#{project.id}/issues/#{issue.id}/notes", user) + + expect(response).to have_http_status(400) + end + + it "returns a 401 unauthorized error if user not authenticated" do + post v3_api("/projects/#{project.id}/issues/#{issue.id}/notes"), body: 'hi!' + + expect(response).to have_http_status(401) + end + + context 'when an admin or owner makes the request' do + it 'accepts the creation date to be set' do + creation_time = 2.weeks.ago + post v3_api("/projects/#{project.id}/issues/#{issue.id}/notes", user), + body: 'hi!', created_at: creation_time + + expect(response).to have_http_status(201) + expect(json_response['body']).to eq('hi!') + expect(json_response['author']['username']).to eq(user.username) + expect(Time.parse(json_response['created_at'])).to be_like_time(creation_time) + end + end + + context 'when the user is posting an award emoji on an issue created by someone else' do + let(:issue2) { create(:issue, project: project) } + + it 'creates a new issue note' do + post v3_api("/projects/#{project.id}/issues/#{issue2.id}/notes", user), body: ':+1:' + + expect(response).to have_http_status(201) + expect(json_response['body']).to eq(':+1:') + end + end + + context 'when the user is posting an award emoji on his/her own issue' do + it 'creates a new issue note' do + post v3_api("/projects/#{project.id}/issues/#{issue.id}/notes", user), body: ':+1:' + + expect(response).to have_http_status(201) + expect(json_response['body']).to eq(':+1:') + end + end + end + + context "when noteable is a Snippet" do + it "creates a new snippet note" do + post v3_api("/projects/#{project.id}/snippets/#{snippet.id}/notes", user), body: 'hi!' + + expect(response).to have_http_status(201) + expect(json_response['body']).to eq('hi!') + expect(json_response['author']['username']).to eq(user.username) + end + + it "returns a 400 bad request error if body not given" do + post v3_api("/projects/#{project.id}/snippets/#{snippet.id}/notes", user) + + expect(response).to have_http_status(400) + end + + it "returns a 401 unauthorized error if user not authenticated" do + post v3_api("/projects/#{project.id}/snippets/#{snippet.id}/notes"), body: 'hi!' + + expect(response).to have_http_status(401) + end + end + + context 'when user does not have access to read the noteable' do + it 'responds with 404' do + project = create(:empty_project, :private) { |p| p.add_guest(user) } + issue = create(:issue, :confidential, project: project) + + post v3_api("/projects/#{project.id}/issues/#{issue.id}/notes", user), + body: 'Foo' + + expect(response).to have_http_status(404) + end + end + + context 'when user does not have access to create noteable' do + let(:private_issue) { create(:issue, project: create(:empty_project, :private)) } + + ## + # We are posting to project user has access to, but we use issue id + # from a different project, see #15577 + # + before do + post v3_api("/projects/#{project.id}/issues/#{private_issue.id}/notes", user), + body: 'Hi!' + end + + it 'responds with resource not found error' do + expect(response.status).to eq 404 + end + + it 'does not create new note' do + expect(private_issue.notes.reload).to be_empty + end + end + end + + describe "POST /projects/:id/noteable/:noteable_id/notes to test observer on create" do + it "creates an activity event when an issue note is created" do + expect(Event).to receive(:create) + + post v3_api("/projects/#{project.id}/issues/#{issue.id}/notes", user), body: 'hi!' + end + end + + describe 'PUT /projects/:id/noteable/:noteable_id/notes/:note_id' do + context 'when noteable is an Issue' do + it 'returns modified note' do + put v3_api("/projects/#{project.id}/issues/#{issue.id}/"\ + "notes/#{issue_note.id}", user), body: 'Hello!' + + expect(response).to have_http_status(200) + expect(json_response['body']).to eq('Hello!') + end + + it 'returns a 404 error when note id not found' do + put v3_api("/projects/#{project.id}/issues/#{issue.id}/notes/12345", user), + body: 'Hello!' + + expect(response).to have_http_status(404) + end + + it 'returns a 400 bad request error if body not given' do + put v3_api("/projects/#{project.id}/issues/#{issue.id}/"\ + "notes/#{issue_note.id}", user) + + expect(response).to have_http_status(400) + end + end + + context 'when noteable is a Snippet' do + it 'returns modified note' do + put v3_api("/projects/#{project.id}/snippets/#{snippet.id}/"\ + "notes/#{snippet_note.id}", user), body: 'Hello!' + + expect(response).to have_http_status(200) + expect(json_response['body']).to eq('Hello!') + end + + it 'returns a 404 error when note id not found' do + put v3_api("/projects/#{project.id}/snippets/#{snippet.id}/"\ + "notes/12345", user), body: "Hello!" + + expect(response).to have_http_status(404) + end + end + + context 'when noteable is a Merge Request' do + it 'returns modified note' do + put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/"\ + "notes/#{merge_request_note.id}", user), body: 'Hello!' + + expect(response).to have_http_status(200) + expect(json_response['body']).to eq('Hello!') + end + + it 'returns a 404 error when note id not found' do + put v3_api("/projects/#{project.id}/merge_requests/#{merge_request.id}/"\ + "notes/12345", user), body: "Hello!" + + expect(response).to have_http_status(404) + end + end + end + + describe 'DELETE /projects/:id/noteable/:noteable_id/notes/:note_id' do + context 'when noteable is an Issue' do + it 'deletes a note' do + delete v3_api("/projects/#{project.id}/issues/#{issue.id}/"\ + "notes/#{issue_note.id}", user) + + expect(response).to have_http_status(200) + # Check if note is really deleted + delete v3_api("/projects/#{project.id}/issues/#{issue.id}/"\ + "notes/#{issue_note.id}", user) + expect(response).to have_http_status(404) + end + + it 'returns a 404 error when note id not found' do + delete v3_api("/projects/#{project.id}/issues/#{issue.id}/notes/12345", user) + + expect(response).to have_http_status(404) + end + end + + context 'when noteable is a Snippet' do + it 'deletes a note' do + delete v3_api("/projects/#{project.id}/snippets/#{snippet.id}/"\ + "notes/#{snippet_note.id}", user) + + expect(response).to have_http_status(200) + # Check if note is really deleted + delete v3_api("/projects/#{project.id}/snippets/#{snippet.id}/"\ + "notes/#{snippet_note.id}", user) + expect(response).to have_http_status(404) + end + + it 'returns a 404 error when note id not found' do + delete v3_api("/projects/#{project.id}/snippets/#{snippet.id}/"\ + "notes/12345", user) + + expect(response).to have_http_status(404) + end + end + + context 'when noteable is a Merge Request' do + it 'deletes a note' do + delete v3_api("/projects/#{project.id}/merge_requests/"\ + "#{merge_request.id}/notes/#{merge_request_note.id}", user) + + expect(response).to have_http_status(200) + # Check if note is really deleted + delete v3_api("/projects/#{project.id}/merge_requests/"\ + "#{merge_request.id}/notes/#{merge_request_note.id}", user) + expect(response).to have_http_status(404) + end + + it 'returns a 404 error when note id not found' do + delete v3_api("/projects/#{project.id}/merge_requests/"\ + "#{merge_request.id}/notes/12345", user) + + expect(response).to have_http_status(404) + end + end + end +end diff --git a/spec/requests/api/v3/pipelines_spec.rb b/spec/requests/api/v3/pipelines_spec.rb new file mode 100644 index 00000000000..3786eb06932 --- /dev/null +++ b/spec/requests/api/v3/pipelines_spec.rb @@ -0,0 +1,203 @@ +require 'spec_helper' + +describe API::V3::Pipelines, api: true do + include ApiHelpers + + let(:user) { create(:user) } + let(:non_member) { create(:user) } + let(:project) { create(:project, :repository, creator: user) } + + let!(:pipeline) do + create(:ci_empty_pipeline, project: project, sha: project.commit.id, + ref: project.default_branch) + end + + before { project.team << [user, :master] } + + shared_examples 'a paginated resources' do + before do + # Fires the request + request + end + + it 'has pagination headers' do + expect(response).to include_pagination_headers + end + end + + describe 'GET /projects/:id/pipelines ' do + it_behaves_like 'a paginated resources' do + let(:request) { get v3_api("/projects/#{project.id}/pipelines", user) } + end + + context 'authorized user' do + it 'returns project pipelines' do + get v3_api("/projects/#{project.id}/pipelines", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.first['sha']).to match(/\A\h{40}\z/) + expect(json_response.first['id']).to eq pipeline.id + expect(json_response.first.keys).to contain_exactly(*%w[id sha ref status before_sha tag yaml_errors user created_at updated_at started_at finished_at committed_at duration coverage]) + end + end + + context 'unauthorized user' do + it 'does not return project pipelines' do + get v3_api("/projects/#{project.id}/pipelines", non_member) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq '404 Project Not Found' + expect(json_response).not_to be_an Array + end + end + end + + describe 'POST /projects/:id/pipeline ' do + context 'authorized user' do + context 'with gitlab-ci.yml' do + before { stub_ci_pipeline_to_return_yaml_file } + + it 'creates and returns a new pipeline' do + expect do + post v3_api("/projects/#{project.id}/pipeline", user), ref: project.default_branch + end.to change { Ci::Pipeline.count }.by(1) + + expect(response).to have_http_status(201) + expect(json_response).to be_a Hash + expect(json_response['sha']).to eq project.commit.id + end + + it 'fails when using an invalid ref' do + post v3_api("/projects/#{project.id}/pipeline", user), ref: 'invalid_ref' + + expect(response).to have_http_status(400) + expect(json_response['message']['base'].first).to eq 'Reference not found' + expect(json_response).not_to be_an Array + end + end + + context 'without gitlab-ci.yml' do + it 'fails to create pipeline' do + post v3_api("/projects/#{project.id}/pipeline", user), ref: project.default_branch + + expect(response).to have_http_status(400) + expect(json_response['message']['base'].first).to eq 'Missing .gitlab-ci.yml file' + expect(json_response).not_to be_an Array + end + end + end + + context 'unauthorized user' do + it 'does not create pipeline' do + post v3_api("/projects/#{project.id}/pipeline", non_member), ref: project.default_branch + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq '404 Project Not Found' + expect(json_response).not_to be_an Array + end + end + end + + describe 'GET /projects/:id/pipelines/:pipeline_id' do + context 'authorized user' do + it 'returns project pipelines' do + get v3_api("/projects/#{project.id}/pipelines/#{pipeline.id}", user) + + expect(response).to have_http_status(200) + expect(json_response['sha']).to match /\A\h{40}\z/ + end + + it 'returns 404 when it does not exist' do + get v3_api("/projects/#{project.id}/pipelines/123456", user) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq '404 Not found' + expect(json_response['id']).to be nil + end + + context 'with coverage' do + before do + create(:ci_build, coverage: 30, pipeline: pipeline) + end + + it 'exposes the coverage' do + get v3_api("/projects/#{project.id}/pipelines/#{pipeline.id}", user) + + expect(json_response["coverage"].to_i).to eq(30) + end + end + end + + context 'unauthorized user' do + it 'should not return a project pipeline' do + get v3_api("/projects/#{project.id}/pipelines/#{pipeline.id}", non_member) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq '404 Project Not Found' + expect(json_response['id']).to be nil + end + end + end + + describe 'POST /projects/:id/pipelines/:pipeline_id/retry' do + context 'authorized user' do + let!(:pipeline) do + create(:ci_pipeline, project: project, sha: project.commit.id, + ref: project.default_branch) + end + + let!(:build) { create(:ci_build, :failed, pipeline: pipeline) } + + it 'retries failed builds' do + expect do + post v3_api("/projects/#{project.id}/pipelines/#{pipeline.id}/retry", user) + end.to change { pipeline.builds.count }.from(1).to(2) + + expect(response).to have_http_status(201) + expect(build.reload.retried?).to be true + end + end + + context 'unauthorized user' do + it 'should not return a project pipeline' do + post v3_api("/projects/#{project.id}/pipelines/#{pipeline.id}/retry", non_member) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq '404 Project Not Found' + expect(json_response['id']).to be nil + end + end + end + + describe 'POST /projects/:id/pipelines/:pipeline_id/cancel' do + let!(:pipeline) do + create(:ci_empty_pipeline, project: project, sha: project.commit.id, + ref: project.default_branch) + end + + let!(:build) { create(:ci_build, :running, pipeline: pipeline) } + + context 'authorized user' do + it 'retries failed builds' do + post v3_api("/projects/#{project.id}/pipelines/#{pipeline.id}/cancel", user) + + expect(response).to have_http_status(200) + expect(json_response['status']).to eq('canceled') + end + end + + context 'user without proper access rights' do + let!(:reporter) { create(:user) } + + before { project.team << [reporter, :reporter] } + + it 'rejects the action' do + post v3_api("/projects/#{project.id}/pipelines/#{pipeline.id}/cancel", reporter) + + expect(response).to have_http_status(403) + expect(pipeline.reload.status).to eq('pending') + end + end + end +end diff --git a/spec/requests/api/v3/project_hooks_spec.rb b/spec/requests/api/v3/project_hooks_spec.rb new file mode 100644 index 00000000000..a981119dc5a --- /dev/null +++ b/spec/requests/api/v3/project_hooks_spec.rb @@ -0,0 +1,216 @@ +require 'spec_helper' + +describe API::ProjectHooks, 'ProjectHooks', api: true do + include ApiHelpers + let(:user) { create(:user) } + let(:user3) { create(:user) } + let!(:project) { create(:project, creator_id: user.id, namespace: user.namespace) } + let!(:hook) do + create(:project_hook, + :all_events_enabled, + project: project, + url: 'http://example.com', + enable_ssl_verification: true) + end + + before do + project.team << [user, :master] + project.team << [user3, :developer] + end + + describe "GET /projects/:id/hooks" do + context "authorized user" do + it "returns project hooks" do + get v3_api("/projects/#{project.id}/hooks", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.count).to eq(1) + expect(json_response.first['url']).to eq("http://example.com") + expect(json_response.first['issues_events']).to eq(true) + expect(json_response.first['push_events']).to eq(true) + expect(json_response.first['merge_requests_events']).to eq(true) + expect(json_response.first['tag_push_events']).to eq(true) + expect(json_response.first['note_events']).to eq(true) + expect(json_response.first['build_events']).to eq(true) + expect(json_response.first['pipeline_events']).to eq(true) + expect(json_response.first['wiki_page_events']).to eq(true) + expect(json_response.first['enable_ssl_verification']).to eq(true) + end + end + + context "unauthorized user" do + it "does not access project hooks" do + get v3_api("/projects/#{project.id}/hooks", user3) + + expect(response).to have_http_status(403) + end + end + end + + describe "GET /projects/:id/hooks/:hook_id" do + context "authorized user" do + it "returns a project hook" do + get v3_api("/projects/#{project.id}/hooks/#{hook.id}", user) + expect(response).to have_http_status(200) + expect(json_response['url']).to eq(hook.url) + expect(json_response['issues_events']).to eq(hook.issues_events) + expect(json_response['push_events']).to eq(hook.push_events) + expect(json_response['merge_requests_events']).to eq(hook.merge_requests_events) + expect(json_response['tag_push_events']).to eq(hook.tag_push_events) + expect(json_response['note_events']).to eq(hook.note_events) + expect(json_response['build_events']).to eq(hook.build_events) + expect(json_response['pipeline_events']).to eq(hook.pipeline_events) + expect(json_response['wiki_page_events']).to eq(hook.wiki_page_events) + expect(json_response['enable_ssl_verification']).to eq(hook.enable_ssl_verification) + end + + it "returns a 404 error if hook id is not available" do + get v3_api("/projects/#{project.id}/hooks/1234", user) + expect(response).to have_http_status(404) + end + end + + context "unauthorized user" do + it "does not access an existing hook" do + get v3_api("/projects/#{project.id}/hooks/#{hook.id}", user3) + expect(response).to have_http_status(403) + end + end + + it "returns a 404 error if hook id is not available" do + get v3_api("/projects/#{project.id}/hooks/1234", user) + expect(response).to have_http_status(404) + end + end + + describe "POST /projects/:id/hooks" do + it "adds hook to project" do + expect do + post v3_api("/projects/#{project.id}/hooks", user), + url: "http://example.com", issues_events: true, wiki_page_events: true + end.to change {project.hooks.count}.by(1) + + expect(response).to have_http_status(201) + expect(json_response['url']).to eq('http://example.com') + expect(json_response['issues_events']).to eq(true) + expect(json_response['push_events']).to eq(true) + expect(json_response['merge_requests_events']).to eq(false) + expect(json_response['tag_push_events']).to eq(false) + expect(json_response['note_events']).to eq(false) + expect(json_response['build_events']).to eq(false) + expect(json_response['pipeline_events']).to eq(false) + expect(json_response['wiki_page_events']).to eq(true) + expect(json_response['enable_ssl_verification']).to eq(true) + expect(json_response).not_to include('token') + end + + it "adds the token without including it in the response" do + token = "secret token" + + expect do + post v3_api("/projects/#{project.id}/hooks", user), url: "http://example.com", token: token + end.to change {project.hooks.count}.by(1) + + expect(response).to have_http_status(201) + expect(json_response["url"]).to eq("http://example.com") + expect(json_response).not_to include("token") + + hook = project.hooks.find(json_response["id"]) + + expect(hook.url).to eq("http://example.com") + expect(hook.token).to eq(token) + end + + it "returns a 400 error if url not given" do + post v3_api("/projects/#{project.id}/hooks", user) + expect(response).to have_http_status(400) + end + + it "returns a 422 error if url not valid" do + post v3_api("/projects/#{project.id}/hooks", user), "url" => "ftp://example.com" + expect(response).to have_http_status(422) + end + end + + describe "PUT /projects/:id/hooks/:hook_id" do + it "updates an existing project hook" do + put v3_api("/projects/#{project.id}/hooks/#{hook.id}", user), + url: 'http://example.org', push_events: false + expect(response).to have_http_status(200) + expect(json_response['url']).to eq('http://example.org') + expect(json_response['issues_events']).to eq(hook.issues_events) + expect(json_response['push_events']).to eq(false) + expect(json_response['merge_requests_events']).to eq(hook.merge_requests_events) + expect(json_response['tag_push_events']).to eq(hook.tag_push_events) + expect(json_response['note_events']).to eq(hook.note_events) + expect(json_response['build_events']).to eq(hook.build_events) + expect(json_response['pipeline_events']).to eq(hook.pipeline_events) + expect(json_response['wiki_page_events']).to eq(hook.wiki_page_events) + expect(json_response['enable_ssl_verification']).to eq(hook.enable_ssl_verification) + end + + it "adds the token without including it in the response" do + token = "secret token" + + put v3_api("/projects/#{project.id}/hooks/#{hook.id}", user), url: "http://example.org", token: token + + expect(response).to have_http_status(200) + expect(json_response["url"]).to eq("http://example.org") + expect(json_response).not_to include("token") + + expect(hook.reload.url).to eq("http://example.org") + expect(hook.reload.token).to eq(token) + end + + it "returns 404 error if hook id not found" do + put v3_api("/projects/#{project.id}/hooks/1234", user), url: 'http://example.org' + expect(response).to have_http_status(404) + end + + it "returns 400 error if url is not given" do + put v3_api("/projects/#{project.id}/hooks/#{hook.id}", user) + expect(response).to have_http_status(400) + end + + it "returns a 422 error if url is not valid" do + put v3_api("/projects/#{project.id}/hooks/#{hook.id}", user), url: 'ftp://example.com' + expect(response).to have_http_status(422) + end + end + + describe "DELETE /projects/:id/hooks/:hook_id" do + it "deletes hook from project" do + expect do + delete v3_api("/projects/#{project.id}/hooks/#{hook.id}", user) + end.to change {project.hooks.count}.by(-1) + expect(response).to have_http_status(200) + end + + it "returns success when deleting hook" do + delete v3_api("/projects/#{project.id}/hooks/#{hook.id}", user) + expect(response).to have_http_status(200) + end + + it "returns a 404 error when deleting non existent hook" do + delete v3_api("/projects/#{project.id}/hooks/42", user) + expect(response).to have_http_status(404) + end + + it "returns a 404 error if hook id not given" do + delete v3_api("/projects/#{project.id}/hooks", user) + + expect(response).to have_http_status(404) + end + + it "returns a 404 if a user attempts to delete project hooks he/she does not own" do + test_user = create(:user) + other_project = create(:project) + other_project.team << [test_user, :master] + + delete v3_api("/projects/#{other_project.id}/hooks/#{hook.id}", test_user) + expect(response).to have_http_status(404) + expect(WebHook.exists?(hook.id)).to be_truthy + end + end +end diff --git a/spec/requests/api/v3/projects_spec.rb b/spec/requests/api/v3/projects_spec.rb index 36d99d80e79..d8bb562587d 100644 --- a/spec/requests/api/v3/projects_spec.rb +++ b/spec/requests/api/v3/projects_spec.rb @@ -84,7 +84,7 @@ describe API::V3::Projects, api: true do context 'GET /projects?simple=true' do it 'returns a simplified version of all the projects' do - expected_keys = ["id", "http_url_to_repo", "web_url", "name", "name_with_namespace", "path", "path_with_namespace"] + expected_keys = %w(id http_url_to_repo web_url name name_with_namespace path path_with_namespace) get v3_api('/projects?simple=true', user) @@ -309,10 +309,37 @@ describe API::V3::Projects, api: true do end end - it 'creates new project without path and return 201' do - expect { post v3_api('/projects', user), name: 'foo' }. + it 'creates new project without path but with name and returns 201' do + expect { post v3_api('/projects', user), name: 'Foo Project' }. to change { Project.count }.by(1) expect(response).to have_http_status(201) + + project = Project.first + + expect(project.name).to eq('Foo Project') + expect(project.path).to eq('foo-project') + end + + it 'creates new project without name but with path and returns 201' do + expect { post v3_api('/projects', user), path: 'foo_project' }. + to change { Project.count }.by(1) + expect(response).to have_http_status(201) + + project = Project.first + + expect(project.name).to eq('foo_project') + expect(project.path).to eq('foo_project') + end + + it 'creates new project name and path and returns 201' do + expect { post v3_api('/projects', user), path: 'foo-Project', name: 'Foo Project' }. + to change { Project.count }.by(1) + expect(response).to have_http_status(201) + + project = Project.first + + expect(project.name).to eq('Foo Project') + expect(project.path).to eq('foo-Project') end it 'creates last project before reaching project limit' do @@ -321,7 +348,7 @@ describe API::V3::Projects, api: true do expect(response).to have_http_status(201) end - it 'does not create new project without name and return 400' do + it 'does not create new project without name or path and return 400' do expect { post v3_api('/projects', user) }.not_to change { Project.count } expect(response).to have_http_status(400) end @@ -400,7 +427,7 @@ describe API::V3::Projects, api: true do expect(json_response['only_allow_merge_if_build_succeeds']).to be_falsey end - it 'sets a project as allowing merge only if build succeeds' do + it 'sets a project as allowing merge only if merge_when_pipeline_succeeds' do project = attributes_for(:project, { only_allow_merge_if_build_succeeds: true }) post v3_api('/projects', user), project expect(json_response['only_allow_merge_if_build_succeeds']).to be_truthy @@ -545,7 +572,7 @@ describe API::V3::Projects, api: true do expect(json_response['only_allow_merge_if_build_succeeds']).to be_falsey end - it 'sets a project as allowing merge only if build succeeds' do + it 'sets a project as allowing merge only if merge_when_pipeline_succeeds' do project = attributes_for(:project, { only_allow_merge_if_build_succeeds: true }) post v3_api("/projects/user/#{user.id}", admin), project expect(json_response['only_allow_merge_if_build_succeeds']).to be_truthy @@ -642,7 +669,7 @@ describe API::V3::Projects, api: true do expect(json_response['shared_with_groups'][0]['group_id']).to eq(group.id) expect(json_response['shared_with_groups'][0]['group_name']).to eq(group.name) expect(json_response['shared_with_groups'][0]['group_access_level']).to eq(link.group_access) - expect(json_response['only_allow_merge_if_build_succeeds']).to eq(project.only_allow_merge_if_build_succeeds) + expect(json_response['only_allow_merge_if_build_succeeds']).to eq(project.only_allow_merge_if_pipeline_succeeds) expect(json_response['only_allow_merge_if_all_discussions_are_resolved']).to eq(project.only_allow_merge_if_all_discussions_are_resolved) end diff --git a/spec/requests/api/v3/repositories_spec.rb b/spec/requests/api/v3/repositories_spec.rb index c696721c1c9..fef6fb641fa 100644 --- a/spec/requests/api/v3/repositories_spec.rb +++ b/spec/requests/api/v3/repositories_spec.rb @@ -3,6 +3,8 @@ require 'mime/types' describe API::V3::Repositories, api: true do include ApiHelpers + include RepoHelpers + include WorkhorseHelpers let(:user) { create(:user) } let(:guest) { create(:user).tap { |u| create(:project_member, :guest, user: u, project: project) } } @@ -96,6 +98,226 @@ describe API::V3::Repositories, api: true do end end + { + 'blobs/:sha' => 'blobs/master', + 'commits/:sha/blob' => 'commits/master/blob' + }.each do |desc_path, example_path| + describe "GET /projects/:id/repository/#{desc_path}" do + let(:route) { "/projects/#{project.id}/repository/#{example_path}?filepath=README.md" } + shared_examples_for 'repository blob' do + it 'returns the repository blob' do + get v3_api(route, current_user) + expect(response).to have_http_status(200) + end + context 'when sha does not exist' do + it_behaves_like '404 response' do + let(:request) { get v3_api(route.sub('master', 'invalid_branch_name'), current_user) } + let(:message) { '404 Commit Not Found' } + end + end + context 'when filepath does not exist' do + it_behaves_like '404 response' do + let(:request) { get v3_api(route.sub('README.md', 'README.invalid'), current_user) } + let(:message) { '404 File Not Found' } + end + end + context 'when no filepath is given' do + it_behaves_like '400 response' do + let(:request) { get v3_api(route.sub('?filepath=README.md', ''), current_user) } + end + end + context 'when repository is disabled' do + include_context 'disabled repository' + it_behaves_like '403 response' do + let(:request) { get v3_api(route, current_user) } + end + end + end + context 'when unauthenticated', 'and project is public' do + it_behaves_like 'repository blob' do + let(:project) { create(:project, :public, :repository) } + let(:current_user) { nil } + end + end + context 'when unauthenticated', 'and project is private' do + it_behaves_like '404 response' do + let(:request) { get v3_api(route) } + let(:message) { '404 Project Not Found' } + end + end + context 'when authenticated', 'as a developer' do + it_behaves_like 'repository blob' do + let(:current_user) { user } + end + end + context 'when authenticated', 'as a guest' do + it_behaves_like '403 response' do + let(:request) { get v3_api(route, guest) } + end + end + end + end + describe "GET /projects/:id/repository/raw_blobs/:sha" do + let(:route) { "/projects/#{project.id}/repository/raw_blobs/#{sample_blob.oid}" } + shared_examples_for 'repository raw blob' do + it 'returns the repository raw blob' do + get v3_api(route, current_user) + expect(response).to have_http_status(200) + end + context 'when sha does not exist' do + it_behaves_like '404 response' do + let(:request) { get v3_api(route.sub(sample_blob.oid, '123456'), current_user) } + let(:message) { '404 Blob Not Found' } + end + end + context 'when repository is disabled' do + include_context 'disabled repository' + it_behaves_like '403 response' do + let(:request) { get v3_api(route, current_user) } + end + end + end + context 'when unauthenticated', 'and project is public' do + it_behaves_like 'repository raw blob' do + let(:project) { create(:project, :public, :repository) } + let(:current_user) { nil } + end + end + context 'when unauthenticated', 'and project is private' do + it_behaves_like '404 response' do + let(:request) { get v3_api(route) } + let(:message) { '404 Project Not Found' } + end + end + context 'when authenticated', 'as a developer' do + it_behaves_like 'repository raw blob' do + let(:current_user) { user } + end + end + context 'when authenticated', 'as a guest' do + it_behaves_like '403 response' do + let(:request) { get v3_api(route, guest) } + end + end + end + describe "GET /projects/:id/repository/archive(.:format)?:sha" do + let(:route) { "/projects/#{project.id}/repository/archive" } + shared_examples_for 'repository archive' do + it 'returns the repository archive' do + get v3_api(route, current_user) + expect(response).to have_http_status(200) + repo_name = project.repository.name.gsub("\.git", "") + type, params = workhorse_send_data + expect(type).to eq('git-archive') + expect(params['ArchivePath']).to match(/#{repo_name}\-[^\.]+\.tar.gz/) + end + it 'returns the repository archive archive.zip' do + get v3_api("/projects/#{project.id}/repository/archive.zip", user) + expect(response).to have_http_status(200) + repo_name = project.repository.name.gsub("\.git", "") + type, params = workhorse_send_data + expect(type).to eq('git-archive') + expect(params['ArchivePath']).to match(/#{repo_name}\-[^\.]+\.zip/) + end + it 'returns the repository archive archive.tar.bz2' do + get v3_api("/projects/#{project.id}/repository/archive.tar.bz2", user) + expect(response).to have_http_status(200) + repo_name = project.repository.name.gsub("\.git", "") + type, params = workhorse_send_data + expect(type).to eq('git-archive') + expect(params['ArchivePath']).to match(/#{repo_name}\-[^\.]+\.tar.bz2/) + end + context 'when sha does not exist' do + it_behaves_like '404 response' do + let(:request) { get v3_api("#{route}?sha=xxx", current_user) } + let(:message) { '404 File Not Found' } + end + end + end + context 'when unauthenticated', 'and project is public' do + it_behaves_like 'repository archive' do + let(:project) { create(:project, :public, :repository) } + let(:current_user) { nil } + end + end + context 'when unauthenticated', 'and project is private' do + it_behaves_like '404 response' do + let(:request) { get v3_api(route) } + let(:message) { '404 Project Not Found' } + end + end + context 'when authenticated', 'as a developer' do + it_behaves_like 'repository archive' do + let(:current_user) { user } + end + end + context 'when authenticated', 'as a guest' do + it_behaves_like '403 response' do + let(:request) { get v3_api(route, guest) } + end + end + end + + describe 'GET /projects/:id/repository/compare' do + let(:route) { "/projects/#{project.id}/repository/compare" } + shared_examples_for 'repository compare' do + it "compares branches" do + get v3_api(route, current_user), from: 'master', to: 'feature' + expect(response).to have_http_status(200) + expect(json_response['commits']).to be_present + expect(json_response['diffs']).to be_present + end + it "compares tags" do + get v3_api(route, current_user), from: 'v1.0.0', to: 'v1.1.0' + expect(response).to have_http_status(200) + expect(json_response['commits']).to be_present + expect(json_response['diffs']).to be_present + end + it "compares commits" do + get v3_api(route, current_user), from: sample_commit.id, to: sample_commit.parent_id + expect(response).to have_http_status(200) + expect(json_response['commits']).to be_empty + expect(json_response['diffs']).to be_empty + expect(json_response['compare_same_ref']).to be_falsey + end + it "compares commits in reverse order" do + get v3_api(route, current_user), from: sample_commit.parent_id, to: sample_commit.id + expect(response).to have_http_status(200) + expect(json_response['commits']).to be_present + expect(json_response['diffs']).to be_present + end + it "compares same refs" do + get v3_api(route, current_user), from: 'master', to: 'master' + expect(response).to have_http_status(200) + expect(json_response['commits']).to be_empty + expect(json_response['diffs']).to be_empty + expect(json_response['compare_same_ref']).to be_truthy + end + end + context 'when unauthenticated', 'and project is public' do + it_behaves_like 'repository compare' do + let(:project) { create(:project, :public, :repository) } + let(:current_user) { nil } + end + end + context 'when unauthenticated', 'and project is private' do + it_behaves_like '404 response' do + let(:request) { get v3_api(route) } + let(:message) { '404 Project Not Found' } + end + end + context 'when authenticated', 'as a developer' do + it_behaves_like 'repository compare' do + let(:current_user) { user } + end + end + context 'when authenticated', 'as a guest' do + it_behaves_like '403 response' do + let(:request) { get v3_api(route, guest) } + end + end + end + describe 'GET /projects/:id/repository/contributors' do let(:route) { "/projects/#{project.id}/repository/contributors" } diff --git a/spec/requests/api/v3/runners_spec.rb b/spec/requests/api/v3/runners_spec.rb new file mode 100644 index 00000000000..ca335ce9cf0 --- /dev/null +++ b/spec/requests/api/v3/runners_spec.rb @@ -0,0 +1,154 @@ +require 'spec_helper' + +describe API::V3::Runners, api: true do + include ApiHelpers + + let(:admin) { create(:user, :admin) } + let(:user) { create(:user) } + let(:user2) { create(:user) } + + let(:project) { create(:empty_project, creator_id: user.id) } + let(:project2) { create(:empty_project, creator_id: user.id) } + + let!(:shared_runner) { create(:ci_runner, :shared) } + let!(:unused_specific_runner) { create(:ci_runner) } + + let!(:specific_runner) do + create(:ci_runner).tap do |runner| + create(:ci_runner_project, runner: runner, project: project) + end + end + + let!(:two_projects_runner) do + create(:ci_runner).tap do |runner| + create(:ci_runner_project, runner: runner, project: project) + create(:ci_runner_project, runner: runner, project: project2) + end + end + + before do + # Set project access for users + create(:project_member, :master, user: user, project: project) + create(:project_member, :reporter, user: user2, project: project) + end + + describe 'DELETE /runners/:id' do + context 'admin user' do + context 'when runner is shared' do + it 'deletes runner' do + expect do + delete v3_api("/runners/#{shared_runner.id}", admin) + + expect(response).to have_http_status(200) + end.to change{ Ci::Runner.shared.count }.by(-1) + end + end + + context 'when runner is not shared' do + it 'deletes unused runner' do + expect do + delete v3_api("/runners/#{unused_specific_runner.id}", admin) + + expect(response).to have_http_status(200) + end.to change{ Ci::Runner.specific.count }.by(-1) + end + + it 'deletes used runner' do + expect do + delete v3_api("/runners/#{specific_runner.id}", admin) + + expect(response).to have_http_status(200) + end.to change{ Ci::Runner.specific.count }.by(-1) + end + end + + it 'returns 404 if runner does not exists' do + delete v3_api('/runners/9999', admin) + + expect(response).to have_http_status(404) + end + end + + context 'authorized user' do + context 'when runner is shared' do + it 'does not delete runner' do + delete v3_api("/runners/#{shared_runner.id}", user) + expect(response).to have_http_status(403) + end + end + + context 'when runner is not shared' do + it 'does not delete runner without access to it' do + delete v3_api("/runners/#{specific_runner.id}", user2) + expect(response).to have_http_status(403) + end + + it 'does not delete runner with more than one associated project' do + delete v3_api("/runners/#{two_projects_runner.id}", user) + expect(response).to have_http_status(403) + end + + it 'deletes runner for one owned project' do + expect do + delete v3_api("/runners/#{specific_runner.id}", user) + + expect(response).to have_http_status(200) + end.to change{ Ci::Runner.specific.count }.by(-1) + end + end + end + + context 'unauthorized user' do + it 'does not delete runner' do + delete v3_api("/runners/#{specific_runner.id}") + + expect(response).to have_http_status(401) + end + end + end + + describe 'DELETE /projects/:id/runners/:runner_id' do + context 'authorized user' do + context 'when runner have more than one associated projects' do + it "disables project's runner" do + expect do + delete v3_api("/projects/#{project.id}/runners/#{two_projects_runner.id}", user) + + expect(response).to have_http_status(200) + end.to change{ project.runners.count }.by(-1) + end + end + + context 'when runner have one associated projects' do + it "does not disable project's runner" do + expect do + delete v3_api("/projects/#{project.id}/runners/#{specific_runner.id}", user) + end.to change{ project.runners.count }.by(0) + expect(response).to have_http_status(403) + end + end + + it 'returns 404 is runner is not found' do + delete v3_api("/projects/#{project.id}/runners/9999", user) + + expect(response).to have_http_status(404) + end + end + + context 'authorized user without permissions' do + it "does not disable project's runner" do + delete v3_api("/projects/#{project.id}/runners/#{specific_runner.id}", user2) + + expect(response).to have_http_status(403) + end + end + + context 'unauthorized user' do + it "does not disable project's runner" do + delete v3_api("/projects/#{project.id}/runners/#{specific_runner.id}") + + expect(response).to have_http_status(401) + end + end + end +end diff --git a/spec/requests/api/v3/services_spec.rb b/spec/requests/api/v3/services_spec.rb new file mode 100644 index 00000000000..3a760a8f25c --- /dev/null +++ b/spec/requests/api/v3/services_spec.rb @@ -0,0 +1,24 @@ +require "spec_helper" + +describe API::V3::Services, api: true do + include ApiHelpers + + let(:user) { create(:user) } + let(:project) { create(:empty_project, creator_id: user.id, namespace: user.namespace) } + + available_services = Service.available_services_names + available_services.delete('prometheus') + available_services.each do |service| + describe "DELETE /projects/:id/services/#{service.dasherize}" do + include_context service + + it "deletes #{service}" do + delete v3_api("/projects/#{project.id}/services/#{dashed_service}", user) + + expect(response).to have_http_status(200) + project.send(service_method).reload + expect(project.send(service_method).activated?).to be_falsey + end + end + end +end diff --git a/spec/requests/api/v3/settings_spec.rb b/spec/requests/api/v3/settings_spec.rb new file mode 100644 index 00000000000..a9fa5adac17 --- /dev/null +++ b/spec/requests/api/v3/settings_spec.rb @@ -0,0 +1,65 @@ +require 'spec_helper' + +describe API::V3::Settings, 'Settings', api: true do + include ApiHelpers + + let(:user) { create(:user) } + let(:admin) { create(:admin) } + + describe "GET /application/settings" do + it "returns application settings" do + get v3_api("/application/settings", admin) + expect(response).to have_http_status(200) + expect(json_response).to be_an Hash + expect(json_response['default_projects_limit']).to eq(42) + expect(json_response['signin_enabled']).to be_truthy + expect(json_response['repository_storage']).to eq('default') + expect(json_response['koding_enabled']).to be_falsey + expect(json_response['koding_url']).to be_nil + expect(json_response['plantuml_enabled']).to be_falsey + expect(json_response['plantuml_url']).to be_nil + end + end + + describe "PUT /application/settings" do + context "custom repository storage type set in the config" do + before do + storages = { 'custom' => 'tmp/tests/custom_repositories' } + allow(Gitlab.config.repositories).to receive(:storages).and_return(storages) + end + + it "updates application settings" do + put v3_api("/application/settings", admin), + default_projects_limit: 3, signin_enabled: false, repository_storage: 'custom', koding_enabled: true, koding_url: 'http://koding.example.com', + plantuml_enabled: true, plantuml_url: 'http://plantuml.example.com' + expect(response).to have_http_status(200) + expect(json_response['default_projects_limit']).to eq(3) + expect(json_response['signin_enabled']).to be_falsey + expect(json_response['repository_storage']).to eq('custom') + expect(json_response['repository_storages']).to eq(['custom']) + expect(json_response['koding_enabled']).to be_truthy + expect(json_response['koding_url']).to eq('http://koding.example.com') + expect(json_response['plantuml_enabled']).to be_truthy + expect(json_response['plantuml_url']).to eq('http://plantuml.example.com') + end + end + + context "missing koding_url value when koding_enabled is true" do + it "returns a blank parameter error message" do + put v3_api("/application/settings", admin), koding_enabled: true + + expect(response).to have_http_status(400) + expect(json_response['error']).to eq('koding_url is missing') + end + end + + context "missing plantuml_url value when plantuml_enabled is true" do + it "returns a blank parameter error message" do + put v3_api("/application/settings", admin), plantuml_enabled: true + + expect(response).to have_http_status(400) + expect(json_response['error']).to eq('plantuml_url is missing') + end + end + end +end diff --git a/spec/requests/api/v3/snippets_spec.rb b/spec/requests/api/v3/snippets_spec.rb new file mode 100644 index 00000000000..05653bd0d51 --- /dev/null +++ b/spec/requests/api/v3/snippets_spec.rb @@ -0,0 +1,187 @@ +require 'rails_helper' + +describe API::V3::Snippets, api: true do + include ApiHelpers + let!(:user) { create(:user) } + + describe 'GET /snippets/' do + it 'returns snippets available' do + public_snippet = create(:personal_snippet, :public, author: user) + private_snippet = create(:personal_snippet, :private, author: user) + internal_snippet = create(:personal_snippet, :internal, author: user) + + get v3_api("/snippets/", user) + + expect(response).to have_http_status(200) + expect(json_response.map { |snippet| snippet['id']} ).to contain_exactly( + public_snippet.id, + internal_snippet.id, + private_snippet.id) + expect(json_response.last).to have_key('web_url') + expect(json_response.last).to have_key('raw_url') + end + + it 'hides private snippets from regular user' do + create(:personal_snippet, :private) + + get v3_api("/snippets/", user) + expect(response).to have_http_status(200) + expect(json_response.size).to eq(0) + end + end + + describe 'GET /snippets/public' do + let!(:other_user) { create(:user) } + let!(:public_snippet) { create(:personal_snippet, :public, author: user) } + let!(:private_snippet) { create(:personal_snippet, :private, author: user) } + let!(:internal_snippet) { create(:personal_snippet, :internal, author: user) } + let!(:public_snippet_other) { create(:personal_snippet, :public, author: other_user) } + let!(:private_snippet_other) { create(:personal_snippet, :private, author: other_user) } + let!(:internal_snippet_other) { create(:personal_snippet, :internal, author: other_user) } + + it 'returns all snippets with public visibility from all users' do + get v3_api("/snippets/public", user) + + expect(response).to have_http_status(200) + expect(json_response.map { |snippet| snippet['id']} ).to contain_exactly( + public_snippet.id, + public_snippet_other.id) + expect(json_response.map{ |snippet| snippet['web_url']} ).to include( + "http://localhost/snippets/#{public_snippet.id}", + "http://localhost/snippets/#{public_snippet_other.id}") + expect(json_response.map{ |snippet| snippet['raw_url']} ).to include( + "http://localhost/snippets/#{public_snippet.id}/raw", + "http://localhost/snippets/#{public_snippet_other.id}/raw") + end + end + + describe 'GET /snippets/:id/raw' do + let(:snippet) { create(:personal_snippet, author: user) } + + it 'returns raw text' do + get v3_api("/snippets/#{snippet.id}/raw", user) + + expect(response).to have_http_status(200) + expect(response.content_type).to eq 'text/plain' + expect(response.body).to eq(snippet.content) + end + + it 'returns 404 for invalid snippet id' do + delete v3_api("/snippets/1234", user) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 Snippet Not Found') + end + end + + describe 'POST /snippets/' do + let(:params) do + { + title: 'Test Title', + file_name: 'test.rb', + content: 'puts "hello world"', + visibility_level: Snippet::PUBLIC + } + end + + it 'creates a new snippet' do + expect do + post v3_api("/snippets/", user), params + end.to change { PersonalSnippet.count }.by(1) + + expect(response).to have_http_status(201) + expect(json_response['title']).to eq(params[:title]) + expect(json_response['file_name']).to eq(params[:file_name]) + end + + it 'returns 400 for missing parameters' do + params.delete(:title) + + post v3_api("/snippets/", user), params + + expect(response).to have_http_status(400) + end + + context 'when the snippet is spam' do + def create_snippet(snippet_params = {}) + post v3_api('/snippets', user), params.merge(snippet_params) + end + + before do + allow_any_instance_of(AkismetService).to receive(:is_spam?).and_return(true) + end + + context 'when the snippet is private' do + it 'creates the snippet' do + expect { create_snippet(visibility_level: Snippet::PRIVATE) }. + to change { Snippet.count }.by(1) + end + end + + context 'when the snippet is public' do + it 'rejects the shippet' do + expect { create_snippet(visibility_level: Snippet::PUBLIC) }. + not_to change { Snippet.count } + expect(response).to have_http_status(400) + end + + it 'creates a spam log' do + expect { create_snippet(visibility_level: Snippet::PUBLIC) }. + to change { SpamLog.count }.by(1) + end + end + end + end + + describe 'PUT /snippets/:id' do + let(:other_user) { create(:user) } + let(:public_snippet) { create(:personal_snippet, :public, author: user) } + it 'updates snippet' do + new_content = 'New content' + + put v3_api("/snippets/#{public_snippet.id}", user), content: new_content + + expect(response).to have_http_status(200) + public_snippet.reload + expect(public_snippet.content).to eq(new_content) + end + + it 'returns 404 for invalid snippet id' do + put v3_api("/snippets/1234", user), title: 'foo' + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 Snippet Not Found') + end + + it "returns 404 for another user's snippet" do + put v3_api("/snippets/#{public_snippet.id}", other_user), title: 'fubar' + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 Snippet Not Found') + end + + it 'returns 400 for missing parameters' do + put v3_api("/snippets/1234", user) + + expect(response).to have_http_status(400) + end + end + + describe 'DELETE /snippets/:id' do + let!(:public_snippet) { create(:personal_snippet, :public, author: user) } + it 'deletes snippet' do + expect do + delete v3_api("/snippets/#{public_snippet.id}", user) + + expect(response).to have_http_status(204) + end.to change { PersonalSnippet.count }.by(-1) + end + + it 'returns 404 for invalid snippet id' do + delete v3_api("/snippets/1234", user) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 Snippet Not Found') + end + end +end diff --git a/spec/requests/api/v3/system_hooks_spec.rb b/spec/requests/api/v3/system_hooks_spec.rb index da58efb6ebf..91038977c82 100644 --- a/spec/requests/api/v3/system_hooks_spec.rb +++ b/spec/requests/api/v3/system_hooks_spec.rb @@ -38,4 +38,20 @@ describe API::V3::SystemHooks, api: true do end end end + + describe "DELETE /hooks/:id" do + it "deletes a hook" do + expect do + delete v3_api("/hooks/#{hook.id}", admin) + + expect(response).to have_http_status(200) + end.to change { SystemHook.count }.by(-1) + end + + it 'returns 404 if the system hook does not exist' do + delete v3_api('/hooks/12345', admin) + + expect(response).to have_http_status(404) + end + end end diff --git a/spec/requests/api/v3/tags_spec.rb b/spec/requests/api/v3/tags_spec.rb index 6722789d928..6870cfd2668 100644 --- a/spec/requests/api/v3/tags_spec.rb +++ b/spec/requests/api/v3/tags_spec.rb @@ -64,4 +64,26 @@ describe API::V3::Tags, api: true do end end end + + describe 'DELETE /projects/:id/repository/tags/:tag_name' do + let(:tag_name) { project.repository.tag_names.sort.reverse.first } + + before do + allow_any_instance_of(Repository).to receive(:rm_tag).and_return(true) + end + + context 'delete tag' do + it 'deletes an existing tag' do + delete v3_api("/projects/#{project.id}/repository/tags/#{tag_name}", user) + + expect(response).to have_http_status(200) + expect(json_response['tag_name']).to eq(tag_name) + end + + it 'raises 404 if the tag does not exist' do + delete v3_api("/projects/#{project.id}/repository/tags/foobar", user) + expect(response).to have_http_status(404) + end + end + end end diff --git a/spec/requests/api/v3/templates_spec.rb b/spec/requests/api/v3/templates_spec.rb index 4fd4e70bedd..f1e554b98cc 100644 --- a/spec/requests/api/v3/templates_spec.rb +++ b/spec/requests/api/v3/templates_spec.rb @@ -56,11 +56,11 @@ describe API::V3::Templates, api: true do expect(json_response['popular']).to be true expect(json_response['html_url']).to eq('http://choosealicense.com/licenses/mit/') expect(json_response['source_url']).to eq('https://opensource.org/licenses/MIT') - expect(json_response['description']).to include('A permissive license that is short and to the point.') + expect(json_response['description']).to include('A short and simple permissive license with conditions') expect(json_response['conditions']).to eq(%w[include-copyright]) expect(json_response['permissions']).to eq(%w[commercial-use modifications distribution private-use]) expect(json_response['limitations']).to eq(%w[no-liability]) - expect(json_response['content']).to include('The MIT License (MIT)') + expect(json_response['content']).to include('MIT License') end end @@ -70,7 +70,7 @@ describe API::V3::Templates, api: true do expect(response).to have_http_status(200) expect(json_response).to be_an Array - expect(json_response.size).to eq(15) + expect(json_response.size).to eq(12) expect(json_response.map { |l| l['key'] }).to include('agpl-3.0') end @@ -98,7 +98,7 @@ describe API::V3::Templates, api: true do let(:license_type) { 'mit' } it 'returns the license text' do - expect(json_response['content']).to include('The MIT License (MIT)') + expect(json_response['content']).to include('MIT License') end it 'replaces placeholder values' do diff --git a/spec/requests/api/v3/triggers_spec.rb b/spec/requests/api/v3/triggers_spec.rb new file mode 100644 index 00000000000..9233e9621bf --- /dev/null +++ b/spec/requests/api/v3/triggers_spec.rb @@ -0,0 +1,232 @@ +require 'spec_helper' + +describe API::V3::Triggers do + include ApiHelpers + + let(:user) { create(:user) } + let(:user2) { create(:user) } + let!(:trigger_token) { 'secure_token' } + let!(:project) { create(:project, :repository, creator: user) } + let!(:master) { create(:project_member, :master, user: user, project: project) } + let!(:developer) { create(:project_member, :developer, user: user2, project: project) } + let!(:trigger) { create(:ci_trigger, project: project, token: trigger_token) } + + describe 'POST /projects/:project_id/trigger' do + let!(:project2) { create(:project) } + let(:options) do + { + token: trigger_token + } + end + + before do + stub_ci_pipeline_to_return_yaml_file + end + + context 'Handles errors' do + it 'returns bad request if token is missing' do + post v3_api("/projects/#{project.id}/trigger/builds"), ref: 'master' + expect(response).to have_http_status(400) + end + + it 'returns not found if project is not found' do + post v3_api('/projects/0/trigger/builds'), options.merge(ref: 'master') + expect(response).to have_http_status(404) + end + + it 'returns unauthorized if token is for different project' do + post v3_api("/projects/#{project2.id}/trigger/builds"), options.merge(ref: 'master') + expect(response).to have_http_status(401) + end + end + + context 'Have a commit' do + let(:pipeline) { project.pipelines.last } + + it 'creates builds' do + post v3_api("/projects/#{project.id}/trigger/builds"), options.merge(ref: 'master') + expect(response).to have_http_status(201) + pipeline.builds.reload + expect(pipeline.builds.pending.size).to eq(2) + expect(pipeline.builds.size).to eq(5) + end + + it 'returns bad request with no builds created if there\'s no commit for that ref' do + post v3_api("/projects/#{project.id}/trigger/builds"), options.merge(ref: 'other-branch') + expect(response).to have_http_status(400) + expect(json_response['message']).to eq('No builds created') + end + + context 'Validates variables' do + let(:variables) do + { 'TRIGGER_KEY' => 'TRIGGER_VALUE' } + end + + it 'validates variables to be a hash' do + post v3_api("/projects/#{project.id}/trigger/builds"), options.merge(variables: 'value', ref: 'master') + expect(response).to have_http_status(400) + expect(json_response['error']).to eq('variables is invalid') + end + + it 'validates variables needs to be a map of key-valued strings' do + post v3_api("/projects/#{project.id}/trigger/builds"), options.merge(variables: { key: %w(1 2) }, ref: 'master') + expect(response).to have_http_status(400) + expect(json_response['message']).to eq('variables needs to be a map of key-valued strings') + end + + it 'creates trigger request with variables' do + post v3_api("/projects/#{project.id}/trigger/builds"), options.merge(variables: variables, ref: 'master') + expect(response).to have_http_status(201) + pipeline.builds.reload + expect(pipeline.builds.first.trigger_request.variables).to eq(variables) + end + end + end + + context 'when triggering a pipeline from a trigger token' do + it 'creates builds from the ref given in the URL, not in the body' do + expect do + post v3_api("/projects/#{project.id}/ref/master/trigger/builds?token=#{trigger_token}"), { ref: 'refs/heads/other-branch' } + end.to change(project.builds, :count).by(5) + expect(response).to have_http_status(201) + end + + context 'when ref contains a dot' do + it 'creates builds from the ref given in the URL, not in the body' do + project.repository.create_file(user, '.gitlab/gitlabhq/new_feature.md', 'something valid', message: 'new_feature', branch_name: 'v.1-branch') + + expect do + post v3_api("/projects/#{project.id}/ref/v.1-branch/trigger/builds?token=#{trigger_token}"), { ref: 'refs/heads/other-branch' } + end.to change(project.builds, :count).by(4) + + expect(response).to have_http_status(201) + end + end + end + end + + describe 'GET /projects/:id/triggers' do + context 'authenticated user with valid permissions' do + it 'returns list of triggers' do + get v3_api("/projects/#{project.id}/triggers", user) + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_a(Array) + expect(json_response[0]).to have_key('token') + end + end + + context 'authenticated user with invalid permissions' do + it 'does not return triggers list' do + get v3_api("/projects/#{project.id}/triggers", user2) + + expect(response).to have_http_status(403) + end + end + + context 'unauthenticated user' do + it 'does not return triggers list' do + get v3_api("/projects/#{project.id}/triggers") + + expect(response).to have_http_status(401) + end + end + end + + describe 'GET /projects/:id/triggers/:token' do + context 'authenticated user with valid permissions' do + it 'returns trigger details' do + get v3_api("/projects/#{project.id}/triggers/#{trigger.token}", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_a(Hash) + end + + it 'responds with 404 Not Found if requesting non-existing trigger' do + get v3_api("/projects/#{project.id}/triggers/abcdef012345", user) + + expect(response).to have_http_status(404) + end + end + + context 'authenticated user with invalid permissions' do + it 'does not return triggers list' do + get v3_api("/projects/#{project.id}/triggers/#{trigger.token}", user2) + + expect(response).to have_http_status(403) + end + end + + context 'unauthenticated user' do + it 'does not return triggers list' do + get v3_api("/projects/#{project.id}/triggers/#{trigger.token}") + + expect(response).to have_http_status(401) + end + end + end + + describe 'POST /projects/:id/triggers' do + context 'authenticated user with valid permissions' do + it 'creates trigger' do + expect do + post v3_api("/projects/#{project.id}/triggers", user) + end.to change{project.triggers.count}.by(1) + + expect(response).to have_http_status(201) + expect(json_response).to be_a(Hash) + end + end + + context 'authenticated user with invalid permissions' do + it 'does not create trigger' do + post v3_api("/projects/#{project.id}/triggers", user2) + + expect(response).to have_http_status(403) + end + end + + context 'unauthenticated user' do + it 'does not create trigger' do + post v3_api("/projects/#{project.id}/triggers") + + expect(response).to have_http_status(401) + end + end + end + + describe 'DELETE /projects/:id/triggers/:token' do + context 'authenticated user with valid permissions' do + it 'deletes trigger' do + expect do + delete v3_api("/projects/#{project.id}/triggers/#{trigger.token}", user) + + expect(response).to have_http_status(200) + end.to change{project.triggers.count}.by(-1) + end + + it 'responds with 404 Not Found if requesting non-existing trigger' do + delete v3_api("/projects/#{project.id}/triggers/abcdef012345", user) + + expect(response).to have_http_status(404) + end + end + + context 'authenticated user with invalid permissions' do + it 'does not delete trigger' do + delete v3_api("/projects/#{project.id}/triggers/#{trigger.token}", user2) + + expect(response).to have_http_status(403) + end + end + + context 'unauthenticated user' do + it 'does not delete trigger' do + delete v3_api("/projects/#{project.id}/triggers/#{trigger.token}") + + expect(response).to have_http_status(401) + end + end + end +end diff --git a/spec/requests/api/v3/users_spec.rb b/spec/requests/api/v3/users_spec.rb index 5020ef18a3a..17bbb0b53c1 100644 --- a/spec/requests/api/v3/users_spec.rb +++ b/spec/requests/api/v3/users_spec.rb @@ -186,4 +186,81 @@ describe API::V3::Users, api: true do expect(response).to have_http_status(404) end end + + describe 'GET /users/:id/events' do + let(:user) { create(:user) } + let(:project) { create(:empty_project) } + let(:note) { create(:note_on_issue, note: 'What an awesome day!', project: project) } + + before do + project.add_user(user, :developer) + EventCreateService.new.leave_note(note, user) + end + + context "as a user than cannot see the event's project" do + it 'returns no events' do + other_user = create(:user) + + get api("/users/#{user.id}/events", other_user) + + expect(response).to have_http_status(200) + expect(json_response).to be_empty + end + end + + context "as a user than can see the event's project" do + context 'joined event' do + it 'returns the "joined" event' do + get v3_api("/users/#{user.id}/events", user) + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + + comment_event = json_response.find { |e| e['action_name'] == 'commented on' } + + expect(comment_event['project_id'].to_i).to eq(project.id) + expect(comment_event['author_username']).to eq(user.username) + expect(comment_event['note']['id']).to eq(note.id) + expect(comment_event['note']['body']).to eq('What an awesome day!') + + joined_event = json_response.find { |e| e['action_name'] == 'joined' } + + expect(joined_event['project_id'].to_i).to eq(project.id) + expect(joined_event['author_username']).to eq(user.username) + expect(joined_event['author']['name']).to eq(user.name) + end + end + + context 'when there are multiple events from different projects' do + let(:second_note) { create(:note_on_issue, project: create(:empty_project)) } + let(:third_note) { create(:note_on_issue, project: project) } + + before do + second_note.project.add_user(user, :developer) + + [second_note, third_note].each do |note| + EventCreateService.new.leave_note(note, user) + end + end + + it 'returns events in the correct order (from newest to oldest)' do + get v3_api("/users/#{user.id}/events", user) + + comment_events = json_response.select { |e| e['action_name'] == 'commented on' } + + expect(comment_events[0]['target_id']).to eq(third_note.id) + expect(comment_events[1]['target_id']).to eq(second_note.id) + expect(comment_events[2]['target_id']).to eq(note.id) + end + end + end + + it 'returns a 404 error if not found' do + get v3_api('/users/42/events', user) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq('404 User Not Found') + end + end end diff --git a/spec/requests/api/variables_spec.rb b/spec/requests/api/variables_spec.rb index 769f04c5057..0c1413119e0 100644 --- a/spec/requests/api/variables_spec.rb +++ b/spec/requests/api/variables_spec.rb @@ -152,8 +152,9 @@ describe API::Variables, api: true do it 'deletes variable' do expect do delete api("/projects/#{project.id}/variables/#{variable.key}", user) + + expect(response).to have_http_status(204) end.to change{project.variables.count}.by(-1) - expect(response).to have_http_status(200) end it 'responds with 404 Not Found if requesting non-existing variable' do diff --git a/spec/requests/ci/api/builds_spec.rb b/spec/requests/ci/api/builds_spec.rb index d85afdeab42..c879f37f50d 100644 --- a/spec/requests/ci/api/builds_spec.rb +++ b/spec/requests/ci/api/builds_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe Ci::API::Builds do include ApiHelpers - let(:runner) { FactoryGirl.create(:ci_runner, tag_list: ["mysql", "ruby"]) } + let(:runner) { FactoryGirl.create(:ci_runner, tag_list: %w(mysql ruby)) } let(:project) { FactoryGirl.create(:empty_project, shared_runners_enabled: false) } let(:last_update) { nil } @@ -81,8 +81,8 @@ describe Ci::API::Builds do expect(runner.reload.platform).to eq("darwin") expect(json_response["options"]).to eq({ "image" => "ruby:2.1", "services" => ["postgres"] }) expect(json_response["variables"]).to include( - { "key" => "CI_BUILD_NAME", "value" => "spinach", "public" => true }, - { "key" => "CI_BUILD_STAGE", "value" => "test", "public" => true }, + { "key" => "CI_JOB_NAME", "value" => "spinach", "public" => true }, + { "key" => "CI_JOB_STAGE", "value" => "test", "public" => true }, { "key" => "DB_NAME", "value" => "postgres", "public" => true } ) end @@ -182,9 +182,9 @@ describe Ci::API::Builds do expect(response).to have_http_status(201) expect(json_response["variables"]).to include( - { "key" => "CI_BUILD_NAME", "value" => "spinach", "public" => true }, - { "key" => "CI_BUILD_STAGE", "value" => "test", "public" => true }, - { "key" => "CI_BUILD_TRIGGERED", "value" => "true", "public" => true }, + { "key" => "CI_JOB_NAME", "value" => "spinach", "public" => true }, + { "key" => "CI_JOB_STAGE", "value" => "test", "public" => true }, + { "key" => "CI_PIPELINE_TRIGGERED", "value" => "true", "public" => true }, { "key" => "DB_NAME", "value" => "postgres", "public" => true }, { "key" => "SECRET_KEY", "value" => "secret_value", "public" => false }, { "key" => "TRIGGER_KEY_1", "value" => "TRIGGER_VALUE_1", "public" => false }, @@ -630,6 +630,7 @@ describe Ci::API::Builds do context 'with an expire date' do let!(:artifacts) { file_upload } + let(:default_artifacts_expire_in) {} let(:post_data) do { 'file.path' => artifacts.path, @@ -638,6 +639,9 @@ describe Ci::API::Builds do end before do + stub_application_setting( + default_artifacts_expire_in: default_artifacts_expire_in) + post(post_url, post_data, headers_with_token) end @@ -648,7 +652,8 @@ describe Ci::API::Builds do build.reload expect(response).to have_http_status(201) expect(json_response['artifacts_expire_at']).not_to be_empty - expect(build.artifacts_expire_at).to be_within(5.minutes).of(Time.now + 7.days) + expect(build.artifacts_expire_at). + to be_within(5.minutes).of(7.days.from_now) end end @@ -661,6 +666,32 @@ describe Ci::API::Builds do expect(json_response['artifacts_expire_at']).to be_nil expect(build.artifacts_expire_at).to be_nil end + + context 'with application default' do + context 'default to 5 days' do + let(:default_artifacts_expire_in) { '5 days' } + + it 'sets to application default' do + build.reload + expect(response).to have_http_status(201) + expect(json_response['artifacts_expire_at']) + .not_to be_empty + expect(build.artifacts_expire_at) + .to be_within(5.minutes).of(5.days.from_now) + end + end + + context 'default to 0' do + let(:default_artifacts_expire_in) { '0' } + + it 'does not set expire_in' do + build.reload + expect(response).to have_http_status(201) + expect(json_response['artifacts_expire_at']).to be_nil + expect(build.artifacts_expire_at).to be_nil + end + end + end end end diff --git a/spec/requests/ci/api/runners_spec.rb b/spec/requests/ci/api/runners_spec.rb index bd55934d0c8..d50cdfdc2d6 100644 --- a/spec/requests/ci/api/runners_spec.rb +++ b/spec/requests/ci/api/runners_spec.rb @@ -18,6 +18,7 @@ describe Ci::API::Runners do it 'creates runner with default values' do expect(response).to have_http_status 201 expect(Ci::Runner.first.run_untagged).to be true + expect(Ci::Runner.first.token).not_to eq(registration_token) end end @@ -41,7 +42,7 @@ describe Ci::API::Runners do it 'creates runner' do expect(response).to have_http_status 201 - expect(Ci::Runner.first.tag_list.sort).to eq(["tag1", "tag2"]) + expect(Ci::Runner.first.tag_list.sort).to eq(%w(tag1 tag2)) end end @@ -74,6 +75,8 @@ describe Ci::API::Runners do it 'creates runner' do expect(response).to have_http_status 201 expect(project.runners.size).to eq(1) + expect(Ci::Runner.first.token).not_to eq(registration_token) + expect(Ci::Runner.first.token).not_to eq(project.runners_token) end end diff --git a/spec/requests/git_http_spec.rb b/spec/requests/git_http_spec.rb index 87786e85621..006d6a6af1c 100644 --- a/spec/requests/git_http_spec.rb +++ b/spec/requests/git_http_spec.rb @@ -221,12 +221,20 @@ describe 'Git HTTP requests', lib: true do end context "when the user is blocked" do - it "responds with status 404" do + it "responds with status 401" do user.block project.team << [user, :master] download(path, env) do |response| - expect(response).to have_http_status(404) + expect(response).to have_http_status(401) + end + end + + it "responds with status 401 for unknown projects (no project existence information leak)" do + user.block + + download('doesnt/exist.git', env) do |response| + expect(response).to have_http_status(401) end end end diff --git a/spec/requests/lfs_http_spec.rb b/spec/requests/lfs_http_spec.rb index c0e7bab8199..5d495bc9e7d 100644 --- a/spec/requests/lfs_http_spec.rb +++ b/spec/requests/lfs_http_spec.rb @@ -25,11 +25,9 @@ describe 'Git LFS API and storage' do { 'objects' => [ { 'oid' => '91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897', - 'size' => 1575078 - }, + 'size' => 1575078 }, { 'oid' => sample_oid, - 'size' => sample_size - } + 'size' => sample_size } ], 'operation' => 'upload' } @@ -53,11 +51,9 @@ describe 'Git LFS API and storage' do { 'objects' => [ { 'oid' => '91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897', - 'size' => 1575078 - }, + 'size' => 1575078 }, { 'oid' => sample_oid, - 'size' => sample_size - } + 'size' => sample_size } ], 'operation' => 'upload' } @@ -374,11 +370,12 @@ describe 'Git LFS API and storage' do describe 'download' do let(:project) { create(:empty_project) } let(:body) do - { 'operation' => 'download', + { + 'operation' => 'download', 'objects' => [ { 'oid' => sample_oid, - 'size' => sample_size - }] + 'size' => sample_size } + ] } end @@ -393,16 +390,20 @@ describe 'Git LFS API and storage' do end it 'with href to download' do - expect(json_response).to eq('objects' => [ - { 'oid' => sample_oid, - 'size' => sample_size, - 'actions' => { - 'download' => { - 'href' => "#{project.http_url_to_repo}/gitlab-lfs/objects/#{sample_oid}", - 'header' => { 'Authorization' => authorization } + expect(json_response).to eq({ + 'objects' => [ + { + 'oid' => sample_oid, + 'size' => sample_size, + 'actions' => { + 'download' => { + 'href' => "#{project.http_url_to_repo}/gitlab-lfs/objects/#{sample_oid}", + 'header' => { 'Authorization' => authorization } + } } } - }]) + ] + }) end end @@ -417,24 +418,29 @@ describe 'Git LFS API and storage' do end it 'with href to download' do - expect(json_response).to eq('objects' => [ - { 'oid' => sample_oid, - 'size' => sample_size, - 'error' => { - 'code' => 404, - 'message' => "Object does not exist on the server or you don't have permissions to access it", + expect(json_response).to eq({ + 'objects' => [ + { + 'oid' => sample_oid, + 'size' => sample_size, + 'error' => { + 'code' => 404, + 'message' => "Object does not exist on the server or you don't have permissions to access it", + } } - }]) + ] + }) end end context 'when downloading a lfs object that does not exist' do let(:body) do - { 'operation' => 'download', + { + 'operation' => 'download', 'objects' => [ { 'oid' => '91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897', - 'size' => 1575078 - }] + 'size' => 1575078 } + ] } end @@ -443,27 +449,30 @@ describe 'Git LFS API and storage' do end it 'with an 404 for specific object' do - expect(json_response).to eq('objects' => [ - { 'oid' => '91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897', - 'size' => 1575078, - 'error' => { - 'code' => 404, - 'message' => "Object does not exist on the server or you don't have permissions to access it", + expect(json_response).to eq({ + 'objects' => [ + { + 'oid' => '91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897', + 'size' => 1575078, + 'error' => { + 'code' => 404, + 'message' => "Object does not exist on the server or you don't have permissions to access it", + } } - }]) + ] + }) end end context 'when downloading one new and one existing lfs object' do let(:body) do - { 'operation' => 'download', + { + 'operation' => 'download', 'objects' => [ { 'oid' => '91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897', - 'size' => 1575078 - }, + 'size' => 1575078 }, { 'oid' => sample_oid, - 'size' => sample_size - } + 'size' => sample_size } ] } end @@ -477,23 +486,28 @@ describe 'Git LFS API and storage' do end it 'responds with upload hypermedia link for the new object' do - expect(json_response).to eq('objects' => [ - { 'oid' => '91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897', - 'size' => 1575078, - 'error' => { - 'code' => 404, - 'message' => "Object does not exist on the server or you don't have permissions to access it", - } - }, - { 'oid' => sample_oid, - 'size' => sample_size, - 'actions' => { - 'download' => { - 'href' => "#{project.http_url_to_repo}/gitlab-lfs/objects/#{sample_oid}", - 'header' => { 'Authorization' => authorization } + expect(json_response).to eq({ + 'objects' => [ + { + 'oid' => '91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897', + 'size' => 1575078, + 'error' => { + 'code' => 404, + 'message' => "Object does not exist on the server or you don't have permissions to access it", + } + }, + { + 'oid' => sample_oid, + 'size' => sample_size, + 'actions' => { + 'download' => { + 'href' => "#{project.http_url_to_repo}/gitlab-lfs/objects/#{sample_oid}", + 'header' => { 'Authorization' => authorization } + } } } - }]) + ] + }) end end end @@ -597,17 +611,21 @@ describe 'Git LFS API and storage' do end it 'responds with status 200 and href to download' do - expect(json_response).to eq('objects' => [ - { 'oid' => sample_oid, - 'size' => sample_size, - 'authenticated' => true, - 'actions' => { - 'download' => { - 'href' => "#{project.http_url_to_repo}/gitlab-lfs/objects/#{sample_oid}", - 'header' => {} + expect(json_response).to eq({ + 'objects' => [ + { + 'oid' => sample_oid, + 'size' => sample_size, + 'authenticated' => true, + 'actions' => { + 'download' => { + 'href' => "#{project.http_url_to_repo}/gitlab-lfs/objects/#{sample_oid}", + 'header' => {} + } } } - }]) + ] + }) end end @@ -626,11 +644,12 @@ describe 'Git LFS API and storage' do describe 'upload' do let(:project) { create(:project, :public) } let(:body) do - { 'operation' => 'upload', + { + 'operation' => 'upload', 'objects' => [ { 'oid' => sample_oid, - 'size' => sample_size - }] + 'size' => sample_size } + ] } end @@ -665,11 +684,12 @@ describe 'Git LFS API and storage' do context 'when pushing a lfs object that does not exist' do let(:body) do - { 'operation' => 'upload', + { + 'operation' => 'upload', 'objects' => [ { 'oid' => '91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897', - 'size' => 1575078 - }] + 'size' => 1575078 } + ] } end @@ -688,14 +708,13 @@ describe 'Git LFS API and storage' do context 'when pushing one new and one existing lfs object' do let(:body) do - { 'operation' => 'upload', + { + 'operation' => 'upload', 'objects' => [ { 'oid' => '91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897', - 'size' => 1575078 - }, + 'size' => 1575078 }, { 'oid' => sample_oid, - 'size' => sample_size - } + 'size' => sample_size } ] } end @@ -789,11 +808,12 @@ describe 'Git LFS API and storage' do let(:project) { create(:empty_project) } let(:authorization) { authorize_user } let(:body) do - { 'operation' => 'other', + { + 'operation' => 'other', 'objects' => [ { 'oid' => sample_oid, - 'size' => sample_size - }] + 'size' => sample_size } + ] } end diff --git a/spec/requests/openid_connect_spec.rb b/spec/requests/openid_connect_spec.rb new file mode 100644 index 00000000000..5206634bca5 --- /dev/null +++ b/spec/requests/openid_connect_spec.rb @@ -0,0 +1,134 @@ +require 'spec_helper' + +describe 'OpenID Connect requests' do + include ApiHelpers + + let(:user) { create :user } + let(:access_grant) { create :oauth_access_grant, application: application, resource_owner_id: user.id } + let(:access_token) { create :oauth_access_token, application: application, resource_owner_id: user.id } + + def request_access_token + login_as user + + post '/oauth/token', + grant_type: 'authorization_code', + code: access_grant.token, + redirect_uri: application.redirect_uri, + client_id: application.uid, + client_secret: application.secret + end + + def request_user_info + get '/oauth/userinfo', nil, 'Authorization' => "Bearer #{access_token.token}" + end + + def hashed_subject + Digest::SHA256.hexdigest("#{user.id}-#{Rails.application.secrets.secret_key_base}") + end + + context 'Application without OpenID scope' do + let(:application) { create :oauth_application, scopes: 'api' } + + it 'token response does not include an ID token' do + request_access_token + + expect(json_response).to include 'access_token' + expect(json_response).not_to include 'id_token' + end + + it 'userinfo response is unauthorized' do + request_user_info + + expect(response).to have_http_status 403 + expect(response.body).to be_blank + end + end + + context 'Application with OpenID scope' do + let(:application) { create :oauth_application, scopes: 'openid' } + + it 'token response includes an ID token' do + request_access_token + + expect(json_response).to include 'id_token' + end + + context 'UserInfo payload' do + let(:user) do + create( + :user, + name: 'Alice', + username: 'alice', + emails: [private_email, public_email], + email: private_email.email, + public_email: public_email.email, + website_url: 'https://example.com', + avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png"), + ) + end + + let(:public_email) { build :email, email: 'public@example.com' } + let(:private_email) { build :email, email: 'private@example.com' } + + it 'includes all user information' do + request_user_info + + expect(json_response).to eq({ + 'sub' => hashed_subject, + 'name' => 'Alice', + 'nickname' => 'alice', + 'email' => 'public@example.com', + 'email_verified' => true, + 'website' => 'https://example.com', + 'profile' => 'http://localhost/alice', + 'picture' => "http://localhost/uploads/user/avatar/#{user.id}/dk.png", + }) + end + end + + context 'ID token payload' do + before do + request_access_token + @payload = JSON::JWT.decode(json_response['id_token'], :skip_verification) + end + + it 'includes the Gitlab root URL' do + expect(@payload['iss']).to eq Gitlab.config.gitlab.url + end + + it 'includes the hashed user ID' do + expect(@payload['sub']).to eq hashed_subject + end + + it 'includes the time of the last authentication' do + expect(@payload['auth_time']).to eq user.current_sign_in_at.to_i + end + + it 'does not include any unknown properties' do + expect(@payload.keys).to eq %w[iss sub aud exp iat auth_time] + end + end + + context 'when user is blocked' do + it 'returns authentication error' do + access_grant + user.block + + expect do + request_access_token + end.to throw_symbol :warden + end + end + + context 'when user is ldap_blocked' do + it 'returns authentication error' do + access_grant + user.ldap_block + + expect do + request_access_token + end.to throw_symbol :warden + end + end + end +end diff --git a/spec/routing/openid_connect_spec.rb b/spec/routing/openid_connect_spec.rb new file mode 100644 index 00000000000..2c3bc08f1a1 --- /dev/null +++ b/spec/routing/openid_connect_spec.rb @@ -0,0 +1,30 @@ +require 'spec_helper' + +# oauth_discovery_keys GET /oauth/discovery/keys(.:format) doorkeeper/openid_connect/discovery#keys +# oauth_discovery_provider GET /.well-known/openid-configuration(.:format) doorkeeper/openid_connect/discovery#provider +# oauth_discovery_webfinger GET /.well-known/webfinger(.:format) doorkeeper/openid_connect/discovery#webfinger +describe Doorkeeper::OpenidConnect::DiscoveryController, 'routing' do + it "to #provider" do + expect(get('/.well-known/openid-configuration')).to route_to('doorkeeper/openid_connect/discovery#provider') + end + + it "to #webfinger" do + expect(get('/.well-known/webfinger')).to route_to('doorkeeper/openid_connect/discovery#webfinger') + end + + it "to #keys" do + expect(get('/oauth/discovery/keys')).to route_to('doorkeeper/openid_connect/discovery#keys') + end +end + +# oauth_userinfo GET /oauth/userinfo(.:format) doorkeeper/openid_connect/userinfo#show +# POST /oauth/userinfo(.:format) doorkeeper/openid_connect/userinfo#show +describe Doorkeeper::OpenidConnect::UserinfoController, 'routing' do + it "to #show" do + expect(get('/oauth/userinfo')).to route_to('doorkeeper/openid_connect/userinfo#show') + end + + it "to #show" do + expect(post('/oauth/userinfo')).to route_to('doorkeeper/openid_connect/userinfo#show') + end +end diff --git a/spec/routing/project_routing_spec.rb b/spec/routing/project_routing_spec.rb index a5bc62ef6c2..4baccacd448 100644 --- a/spec/routing/project_routing_spec.rb +++ b/spec/routing/project_routing_spec.rb @@ -120,7 +120,6 @@ describe 'project routing' do end end - # emojis_namespace_project_autocomplete_sources_path GET /:project_id/autocomplete_sources/emojis(.:format) projects/autocomplete_sources#emojis # members_namespace_project_autocomplete_sources_path GET /:project_id/autocomplete_sources/members(.:format) projects/autocomplete_sources#members # issues_namespace_project_autocomplete_sources_path GET /:project_id/autocomplete_sources/issues(.:format) projects/autocomplete_sources#issues # merge_requests_namespace_project_autocomplete_sources_path GET /:project_id/autocomplete_sources/merge_requests(.:format) projects/autocomplete_sources#merge_requests @@ -128,7 +127,7 @@ describe 'project routing' do # milestones_namespace_project_autocomplete_sources_path GET /:project_id/autocomplete_sources/milestones(.:format) projects/autocomplete_sources#milestones # commands_namespace_project_autocomplete_sources_path GET /:project_id/autocomplete_sources/commands(.:format) projects/autocomplete_sources#commands describe Projects::AutocompleteSourcesController, 'routing' do - [:emojis, :members, :issues, :merge_requests, :labels, :milestones, :commands].each do |action| + [:members, :issues, :merge_requests, :labels, :milestones, :commands].each do |action| it "to ##{action}" do expect(get("/gitlab/gitlabhq/autocomplete_sources/#{action}")).to route_to("projects/autocomplete_sources##{action}", namespace_id: 'gitlab', project_id: 'gitlabhq') end @@ -431,12 +430,22 @@ describe 'project routing' do end end - # project_notes GET /:project_id/notes(.:format) notes#index - # POST /:project_id/notes(.:format) notes#create - # project_note DELETE /:project_id/notes/:id(.:format) notes#destroy + # project_noteable_notes GET /:project_id/noteable/:target_type/:target_id/notes notes#index + # POST /:project_id/notes(.:format) notes#create + # project_note DELETE /:project_id/notes/:id(.:format) notes#destroy describe Projects::NotesController, 'routing' do + it 'to #index' do + expect(get('/gitlab/gitlabhq/noteable/issue/1/notes')).to route_to( + 'projects/notes#index', + namespace_id: 'gitlab', + project_id: 'gitlabhq', + target_type: 'issue', + target_id: '1' + ) + end + it_behaves_like 'RESTful project resources' do - let(:actions) { [:index, :create, :destroy] } + let(:actions) { [:create, :destroy] } let(:controller) { 'notes' } end end diff --git a/spec/rubocop/cop/custom_error_class_spec.rb b/spec/rubocop/cop/custom_error_class_spec.rb new file mode 100644 index 00000000000..381d7871a40 --- /dev/null +++ b/spec/rubocop/cop/custom_error_class_spec.rb @@ -0,0 +1,111 @@ +require 'spec_helper' + +require 'rubocop' +require 'rubocop/rspec/support' + +require_relative '../../../rubocop/cop/custom_error_class' + +describe RuboCop::Cop::CustomErrorClass do + include CopHelper + + subject(:cop) { described_class.new } + + context 'when a class has a body' do + it 'does nothing' do + inspect_source(cop, 'class CustomError < StandardError; def foo; end; end') + + expect(cop.offenses).to be_empty + end + end + + context 'when a class has no explicit superclass' do + it 'does nothing' do + inspect_source(cop, 'class CustomError; end') + + expect(cop.offenses).to be_empty + end + end + + context 'when a class has a superclass that does not end in Error' do + it 'does nothing' do + inspect_source(cop, 'class CustomError < BasicObject; end') + + expect(cop.offenses).to be_empty + end + end + + context 'when a class is empty and inherits from a class ending in Error' do + context 'when the class is on a single line' do + let(:source) do + <<-SOURCE + module Foo + class CustomError < Bar::Baz::BaseError; end + end + SOURCE + end + + let(:expected) do + <<-EXPECTED + module Foo + CustomError = Class.new(Bar::Baz::BaseError) + end + EXPECTED + end + + it 'registers an offense' do + expected_highlights = source.split("\n")[1].strip + + inspect_source(cop, source) + + aggregate_failures do + expect(cop.offenses.size).to eq(1) + expect(cop.offenses.map(&:line)).to eq([2]) + expect(cop.highlights).to contain_exactly(expected_highlights) + end + end + + it 'autocorrects to the right version' do + autocorrected = autocorrect_source(cop, source, 'foo/custom_error.rb') + + expect(autocorrected).to eq(expected) + end + end + + context 'when the class is on multiple lines' do + let(:source) do + <<-SOURCE + module Foo + class CustomError < Bar::Baz::BaseError + end + end + SOURCE + end + + let(:expected) do + <<-EXPECTED + module Foo + CustomError = Class.new(Bar::Baz::BaseError) + end + EXPECTED + end + + it 'registers an offense' do + expected_highlights = source.split("\n")[1..2].join("\n").strip + + inspect_source(cop, source) + + aggregate_failures do + expect(cop.offenses.size).to eq(1) + expect(cop.offenses.map(&:line)).to eq([2]) + expect(cop.highlights).to contain_exactly(expected_highlights) + end + end + + it 'autocorrects to the right version' do + autocorrected = autocorrect_source(cop, source, 'foo/custom_error.rb') + + expect(autocorrected).to eq(expected) + end + end + end +end diff --git a/spec/rubocop/cop/migration/add_concurrent_index_spec.rb b/spec/rubocop/cop/migration/add_concurrent_index_spec.rb new file mode 100644 index 00000000000..19a5718b0b1 --- /dev/null +++ b/spec/rubocop/cop/migration/add_concurrent_index_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +require 'rubocop' +require 'rubocop/rspec/support' + +require_relative '../../../../rubocop/cop/migration/add_concurrent_index' + +describe RuboCop::Cop::Migration::AddConcurrentIndex do + include CopHelper + + subject(:cop) { described_class.new } + + context 'in migration' do + before do + allow(cop).to receive(:in_migration?).and_return(true) + end + + it 'registers an offense when add_concurrent_index is used inside a change method' do + inspect_source(cop, 'def change; add_concurrent_index :table, :column; end') + + aggregate_failures do + expect(cop.offenses.size).to eq(1) + expect(cop.offenses.map(&:line)).to eq([1]) + end + end + + it 'registers no offense when add_concurrent_index is used inside an up method' do + inspect_source(cop, 'def up; add_concurrent_index :table, :column; end') + + expect(cop.offenses.size).to eq(0) + end + end + + context 'outside of migration' do + it 'registers no offense' do + inspect_source(cop, 'def change; add_concurrent_index :table, :column; end') + + expect(cop.offenses.size).to eq(0) + end + end +end diff --git a/spec/services/auth/container_registry_authentication_service_spec.rb b/spec/services/auth/container_registry_authentication_service_spec.rb index bb26513103d..b91234ddb1e 100644 --- a/spec/services/auth/container_registry_authentication_service_spec.rb +++ b/spec/services/auth/container_registry_authentication_service_spec.rb @@ -72,7 +72,7 @@ describe Auth::ContainerRegistryAuthenticationService, services: true do shared_examples 'a pullable and pushable' do it_behaves_like 'a accessible' do - let(:actions) { ['pull', 'push'] } + let(:actions) { %w(pull push) } end end diff --git a/spec/services/boards/issues/list_service_spec.rb b/spec/services/boards/issues/list_service_spec.rb index 305278843f5..22115c6566d 100644 --- a/spec/services/boards/issues/list_service_spec.rb +++ b/spec/services/boards/issues/list_service_spec.rb @@ -43,8 +43,8 @@ describe Boards::Issues::ListService, services: true do described_class.new(project, user, params).execute end - context 'sets default order to priority' do - it 'returns opened issues when list id is missing' do + context 'issues are ordered by priority' do + it 'returns opened issues when list_id is missing' do params = { board_id: board.id } issues = described_class.new(project, user, params).execute diff --git a/spec/services/boards/issues/move_service_spec.rb b/spec/services/boards/issues/move_service_spec.rb index 77f75167b3d..727ea04ea5c 100644 --- a/spec/services/boards/issues/move_service_spec.rb +++ b/spec/services/boards/issues/move_service_spec.rb @@ -78,8 +78,10 @@ describe Boards::Issues::MoveService, services: true do end context 'when moving to same list' do - let(:issue) { create(:labeled_issue, project: project, labels: [bug, development]) } - let(:params) { { board_id: board1.id, from_list_id: list1.id, to_list_id: list1.id } } + let(:issue) { create(:labeled_issue, project: project, labels: [bug, development]) } + let(:issue1) { create(:labeled_issue, project: project, labels: [bug, development]) } + let(:issue2) { create(:labeled_issue, project: project, labels: [bug, development]) } + let(:params) { { board_id: board1.id, from_list_id: list1.id, to_list_id: list1.id } } it 'returns false' do expect(described_class.new(project, user, params).execute(issue)).to eq false @@ -90,6 +92,18 @@ describe Boards::Issues::MoveService, services: true do expect(issue.reload.labels).to contain_exactly(bug, development) end + + it 'sorts issues' do + [issue, issue1, issue2].each do |issue| + issue.move_to_end && issue.save! + end + + params.merge!(move_after_iid: issue1.iid, move_before_iid: issue2.iid) + + described_class.new(project, user, params).execute(issue) + + expect(issue.relative_position).to be_between(issue1.relative_position, issue2.relative_position) + end end end end diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb index ceaca96e25b..a969829a63e 100644 --- a/spec/services/ci/create_pipeline_service_spec.rb +++ b/spec/services/ci/create_pipeline_service_spec.rb @@ -79,66 +79,53 @@ describe Ci::CreatePipelineService, services: true do context 'when commit contains a [ci skip] directive' do let(:message) { "some message[ci skip]" } - let(:messageFlip) { "some message[skip ci]" } - let(:capMessage) { "some message[CI SKIP]" } - let(:capMessageFlip) { "some message[SKIP CI]" } + + ci_messages = [ + "some message[ci skip]", + "some message[skip ci]", + "some message[CI SKIP]", + "some message[SKIP CI]", + "some message[ci_skip]", + "some message[skip_ci]", + "some message[ci-skip]", + "some message[skip-ci]" + ] before do allow_any_instance_of(Ci::Pipeline).to receive(:git_commit_message) { message } end - it "skips builds creation if there is [ci skip] tag in commit message" do - commits = [{ message: message }] - pipeline = execute(ref: 'refs/heads/master', - before: '00000000', - after: project.commit.id, - commits: commits) + ci_messages.each do |ci_message| + it "skips builds creation if the commit message is #{ci_message}" do + commits = [{ message: ci_message }] + pipeline = execute(ref: 'refs/heads/master', + before: '00000000', + after: project.commit.id, + commits: commits) - expect(pipeline).to be_persisted - expect(pipeline.builds.any?).to be false - expect(pipeline.status).to eq("skipped") - end - - it "skips builds creation if there is [skip ci] tag in commit message" do - commits = [{ message: messageFlip }] - pipeline = execute(ref: 'refs/heads/master', - before: '00000000', - after: project.commit.id, - commits: commits) - - expect(pipeline).to be_persisted - expect(pipeline.builds.any?).to be false - expect(pipeline.status).to eq("skipped") + expect(pipeline).to be_persisted + expect(pipeline.builds.any?).to be false + expect(pipeline.status).to eq("skipped") + end end - it "skips builds creation if there is [CI SKIP] tag in commit message" do - commits = [{ message: capMessage }] - pipeline = execute(ref: 'refs/heads/master', - before: '00000000', - after: project.commit.id, - commits: commits) - - expect(pipeline).to be_persisted - expect(pipeline.builds.any?).to be false - expect(pipeline.status).to eq("skipped") - end + it "does not skips builds creation if there is no [ci skip] or [skip ci] tag in commit message" do + allow_any_instance_of(Ci::Pipeline).to receive(:git_commit_message) { "some message" } - it "skips builds creation if there is [SKIP CI] tag in commit message" do - commits = [{ message: capMessageFlip }] + commits = [{ message: "some message" }] pipeline = execute(ref: 'refs/heads/master', before: '00000000', after: project.commit.id, commits: commits) expect(pipeline).to be_persisted - expect(pipeline.builds.any?).to be false - expect(pipeline.status).to eq("skipped") + expect(pipeline.builds.first.name).to eq("rspec") end - it "does not skips builds creation if there is no [ci skip] or [skip ci] tag in commit message" do - allow_any_instance_of(Ci::Pipeline).to receive(:git_commit_message) { "some message" } + it "does not skip builds creation if the commit message is nil" do + allow_any_instance_of(Ci::Pipeline).to receive(:git_commit_message) { nil } - commits = [{ message: "some message" }] + commits = [{ message: nil }] pipeline = execute(ref: 'refs/heads/master', before: '00000000', after: project.commit.id, @@ -213,7 +200,7 @@ describe Ci::CreatePipelineService, services: true do context 'with environment' do before do - config = YAML.dump(deploy: { environment: { name: "review/$CI_BUILD_REF_NAME" }, script: 'ls' }) + config = YAML.dump(deploy: { environment: { name: "review/$CI_COMMIT_REF_NAME" }, script: 'ls' }) stub_ci_pipeline_yaml_file(config) end diff --git a/spec/services/ci/create_trigger_request_service_spec.rb b/spec/services/ci/create_trigger_request_service_spec.rb index d8c443d29d5..5e68343784d 100644 --- a/spec/services/ci/create_trigger_request_service_spec.rb +++ b/spec/services/ci/create_trigger_request_service_spec.rb @@ -13,8 +13,22 @@ describe Ci::CreateTriggerRequestService, services: true do context 'valid params' do subject { service.execute(project, trigger, 'master') } - it { expect(subject).to be_kind_of(Ci::TriggerRequest) } - it { expect(subject.builds.first).to be_kind_of(Ci::Build) } + context 'without owner' do + it { expect(subject).to be_kind_of(Ci::TriggerRequest) } + it { expect(subject.pipeline).to be_kind_of(Ci::Pipeline) } + it { expect(subject.builds.first).to be_kind_of(Ci::Build) } + end + + context 'with owner' do + let(:owner) { create(:user) } + let(:trigger) { create(:ci_trigger, project: project, owner: owner) } + + it { expect(subject).to be_kind_of(Ci::TriggerRequest) } + it { expect(subject.pipeline).to be_kind_of(Ci::Pipeline) } + it { expect(subject.pipeline.user).to eq(owner) } + it { expect(subject.builds.first).to be_kind_of(Ci::Build) } + it { expect(subject.builds.first.user).to eq(owner) } + end end context 'no commit for ref' do diff --git a/spec/services/ci/image_for_build_service_spec.rb b/spec/services/ci/image_for_build_service_spec.rb deleted file mode 100644 index b3e0a7b9b58..00000000000 --- a/spec/services/ci/image_for_build_service_spec.rb +++ /dev/null @@ -1,50 +0,0 @@ -require 'spec_helper' - -module Ci - describe ImageForBuildService, services: true do - let(:service) { ImageForBuildService.new } - let(:project) { FactoryGirl.create(:empty_project) } - let(:commit_sha) { '01234567890123456789' } - let(:pipeline) { project.ensure_pipeline('master', commit_sha) } - let(:build) { FactoryGirl.create(:ci_build, pipeline: pipeline) } - - describe '#execute' do - before { build } - - context 'branch name' do - before { allow(project).to receive(:commit).and_return(OpenStruct.new(sha: commit_sha)) } - before { build.run! } - let(:image) { service.execute(project, ref: 'master') } - - it { expect(image).to be_kind_of(OpenStruct) } - it { expect(image.path.to_s).to include('public/ci/build-running.svg') } - it { expect(image.name).to eq('build-running.svg') } - end - - context 'unknown branch name' do - let(:image) { service.execute(project, ref: 'feature') } - - it { expect(image).to be_kind_of(OpenStruct) } - it { expect(image.path.to_s).to include('public/ci/build-unknown.svg') } - it { expect(image.name).to eq('build-unknown.svg') } - end - - context 'commit sha' do - before { build.run! } - let(:image) { service.execute(project, sha: build.sha) } - - it { expect(image).to be_kind_of(OpenStruct) } - it { expect(image.path.to_s).to include('public/ci/build-running.svg') } - it { expect(image.name).to eq('build-running.svg') } - end - - context 'unknown commit sha' do - let(:image) { service.execute(project, sha: '0000000') } - - it { expect(image).to be_kind_of(OpenStruct) } - it { expect(image.path.to_s).to include('public/ci/build-unknown.svg') } - it { expect(image.name).to eq('build-unknown.svg') } - end - end - end -end diff --git a/spec/services/ci/process_pipeline_service_spec.rb b/spec/services/ci/process_pipeline_service_spec.rb index ef2ddc4b1d7..d93616c4f50 100644 --- a/spec/services/ci/process_pipeline_service_spec.rb +++ b/spec/services/ci/process_pipeline_service_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Ci::ProcessPipelineService, :services do +describe Ci::ProcessPipelineService, '#execute', :services do let(:user) { create(:user) } let(:project) { create(:empty_project) } @@ -12,381 +12,518 @@ describe Ci::ProcessPipelineService, :services do project.add_developer(user) end - describe '#execute' do - context 'start queuing next builds' do - before do - create(:ci_build, :created, pipeline: pipeline, name: 'linux', stage_idx: 0) - create(:ci_build, :created, pipeline: pipeline, name: 'mac', stage_idx: 0) - create(:ci_build, :created, pipeline: pipeline, name: 'rspec', stage_idx: 1) - create(:ci_build, :created, pipeline: pipeline, name: 'rubocop', stage_idx: 1) - create(:ci_build, :created, pipeline: pipeline, name: 'deploy', stage_idx: 2) - end + context 'when simple pipeline is defined' do + before do + create_build('linux', stage_idx: 0) + create_build('mac', stage_idx: 0) + create_build('rspec', stage_idx: 1) + create_build('rubocop', stage_idx: 1) + create_build('deploy', stage_idx: 2) + end - it 'processes a pipeline' do - expect(process_pipeline).to be_truthy - succeed_pending - expect(builds.success.count).to eq(2) + it 'processes a pipeline' do + expect(process_pipeline).to be_truthy - expect(process_pipeline).to be_truthy - succeed_pending - expect(builds.success.count).to eq(4) + succeed_pending + + expect(builds.success.count).to eq(2) + expect(process_pipeline).to be_truthy + + succeed_pending + + expect(builds.success.count).to eq(4) + expect(process_pipeline).to be_truthy + + succeed_pending + + expect(builds.success.count).to eq(5) + expect(process_pipeline).to be_falsey + end + + it 'does not process pipeline if existing stage is running' do + expect(process_pipeline).to be_truthy + expect(builds.pending.count).to eq(2) + + expect(process_pipeline).to be_falsey + expect(builds.pending.count).to eq(2) + end + end + + context 'custom stage with first job allowed to fail' do + before do + create_build('clean_job', stage_idx: 0, allow_failure: true) + create_build('test_job', stage_idx: 1, allow_failure: true) + end + it 'automatically triggers a next stage when build finishes' do + expect(process_pipeline).to be_truthy + expect(builds_statuses).to eq ['pending'] + + fail_running_or_pending + + expect(builds_statuses).to eq %w(failed pending) + end + end + + context 'when optional manual actions are defined' do + before do + create_build('build', stage_idx: 0) + create_build('test', stage_idx: 1) + create_build('test_failure', stage_idx: 2, when: 'on_failure') + create_build('deploy', stage_idx: 3) + create_build('production', stage_idx: 3, when: 'manual', allow_failure: true) + create_build('cleanup', stage_idx: 4, when: 'always') + create_build('clear:cache', stage_idx: 4, when: 'manual', allow_failure: true) + end + + context 'when builds are successful' do + it 'properly processes the pipeline' do expect(process_pipeline).to be_truthy - succeed_pending - expect(builds.success.count).to eq(5) + expect(builds_names).to eq ['build'] + expect(builds_statuses).to eq ['pending'] + + succeed_running_or_pending + + expect(builds_names).to eq %w(build test) + expect(builds_statuses).to eq %w(success pending) - expect(process_pipeline).to be_falsey + succeed_running_or_pending + + expect(builds_names).to eq %w(build test deploy production) + expect(builds_statuses).to eq %w(success success pending manual) + + succeed_running_or_pending + + expect(builds_names).to eq %w(build test deploy production cleanup clear:cache) + expect(builds_statuses).to eq %w(success success success manual pending manual) + + succeed_running_or_pending + + expect(builds_statuses).to eq %w(success success success manual success manual) + expect(pipeline.reload.status).to eq 'success' end + end - it 'does not process pipeline if existing stage is running' do + context 'when test job fails' do + it 'properly processes the pipeline' do expect(process_pipeline).to be_truthy - expect(builds.pending.count).to eq(2) + expect(builds_names).to eq ['build'] + expect(builds_statuses).to eq ['pending'] + + succeed_running_or_pending + + expect(builds_names).to eq %w(build test) + expect(builds_statuses).to eq %w(success pending) - expect(process_pipeline).to be_falsey - expect(builds.pending.count).to eq(2) + fail_running_or_pending + + expect(builds_names).to eq %w(build test test_failure) + expect(builds_statuses).to eq %w(success failed pending) + + succeed_running_or_pending + + expect(builds_names).to eq %w(build test test_failure cleanup) + expect(builds_statuses).to eq %w(success failed success pending) + + succeed_running_or_pending + + expect(builds_statuses).to eq %w(success failed success success) + expect(pipeline.reload.status).to eq 'failed' end end - context 'custom stage with first job allowed to fail' do - before do - create(:ci_build, :created, pipeline: pipeline, name: 'clean_job', stage_idx: 0, allow_failure: true) - create(:ci_build, :created, pipeline: pipeline, name: 'test_job', stage_idx: 1, allow_failure: true) + context 'when test and test_failure jobs fail' do + it 'properly processes the pipeline' do + expect(process_pipeline).to be_truthy + expect(builds_names).to eq ['build'] + expect(builds_statuses).to eq ['pending'] + + succeed_running_or_pending + + expect(builds_names).to eq %w(build test) + expect(builds_statuses).to eq %w(success pending) + + fail_running_or_pending + + expect(builds_names).to eq %w(build test test_failure) + expect(builds_statuses).to eq %w(success failed pending) + + fail_running_or_pending + + expect(builds_names).to eq %w(build test test_failure cleanup) + expect(builds_statuses).to eq %w(success failed failed pending) + + succeed_running_or_pending + + expect(builds_names).to eq %w(build test test_failure cleanup) + expect(builds_statuses).to eq %w(success failed failed success) + expect(pipeline.reload.status).to eq('failed') end + end - it 'automatically triggers a next stage when build finishes' do + context 'when deploy job fails' do + it 'properly processes the pipeline' do expect(process_pipeline).to be_truthy - expect(builds.pluck(:status)).to contain_exactly('pending') + expect(builds_names).to eq ['build'] + expect(builds_statuses).to eq ['pending'] + + succeed_running_or_pending - pipeline.builds.running_or_pending.each(&:drop) - expect(builds.pluck(:status)).to contain_exactly('failed', 'pending') + expect(builds_names).to eq %w(build test) + expect(builds_statuses).to eq %w(success pending) + + succeed_running_or_pending + + expect(builds_names).to eq %w(build test deploy production) + expect(builds_statuses).to eq %w(success success pending manual) + + fail_running_or_pending + + expect(builds_names).to eq %w(build test deploy production cleanup) + expect(builds_statuses).to eq %w(success success failed manual pending) + + succeed_running_or_pending + + expect(builds_statuses).to eq %w(success success failed manual success) + expect(pipeline.reload).to be_failed end end - context 'properly creates builds when "when" is defined' do - before do - create(:ci_build, :created, pipeline: pipeline, name: 'build', stage_idx: 0) - create(:ci_build, :created, pipeline: pipeline, name: 'test', stage_idx: 1) - create(:ci_build, :created, pipeline: pipeline, name: 'test_failure', stage_idx: 2, when: 'on_failure') - create(:ci_build, :created, pipeline: pipeline, name: 'deploy', stage_idx: 3) - create(:ci_build, :created, pipeline: pipeline, name: 'production', stage_idx: 3, when: 'manual') - create(:ci_build, :created, pipeline: pipeline, name: 'cleanup', stage_idx: 4, when: 'always') - create(:ci_build, :created, pipeline: pipeline, name: 'clear cache', stage_idx: 4, when: 'manual') - end + context 'when build is canceled in the second stage' do + it 'does not schedule builds after build has been canceled' do + expect(process_pipeline).to be_truthy + expect(builds_names).to eq ['build'] + expect(builds_statuses).to eq ['pending'] - context 'when builds are successful' do - it 'properly creates builds' do - expect(process_pipeline).to be_truthy - expect(builds.pluck(:name)).to contain_exactly('build') - expect(builds.pluck(:status)).to contain_exactly('pending') - pipeline.builds.running_or_pending.each(&:success) - - expect(builds.pluck(:name)).to contain_exactly('build', 'test') - expect(builds.pluck(:status)).to contain_exactly('success', 'pending') - pipeline.builds.running_or_pending.each(&:success) - - expect(builds.pluck(:name)).to contain_exactly('build', 'test', 'deploy') - expect(builds.pluck(:status)).to contain_exactly('success', 'success', 'pending') - pipeline.builds.running_or_pending.each(&:success) - - expect(builds.pluck(:name)).to contain_exactly('build', 'test', 'deploy', 'cleanup') - expect(builds.pluck(:status)).to contain_exactly('success', 'success', 'success', 'pending') - pipeline.builds.running_or_pending.each(&:success) - - expect(builds.pluck(:status)).to contain_exactly('success', 'success', 'success', 'success') - pipeline.reload - expect(pipeline.status).to eq('success') - end - end + succeed_running_or_pending - context 'when test job fails' do - it 'properly creates builds' do - expect(process_pipeline).to be_truthy - expect(builds.pluck(:name)).to contain_exactly('build') - expect(builds.pluck(:status)).to contain_exactly('pending') - pipeline.builds.running_or_pending.each(&:success) - - expect(builds.pluck(:name)).to contain_exactly('build', 'test') - expect(builds.pluck(:status)).to contain_exactly('success', 'pending') - pipeline.builds.running_or_pending.each(&:drop) - - expect(builds.pluck(:name)).to contain_exactly('build', 'test', 'test_failure') - expect(builds.pluck(:status)).to contain_exactly('success', 'failed', 'pending') - pipeline.builds.running_or_pending.each(&:success) - - expect(builds.pluck(:name)).to contain_exactly('build', 'test', 'test_failure', 'cleanup') - expect(builds.pluck(:status)).to contain_exactly('success', 'failed', 'success', 'pending') - pipeline.builds.running_or_pending.each(&:success) - - expect(builds.pluck(:status)).to contain_exactly('success', 'failed', 'success', 'success') - pipeline.reload - expect(pipeline.status).to eq('failed') - end - end + expect(builds.running_or_pending).not_to be_empty + expect(builds_names).to eq %w(build test) + expect(builds_statuses).to eq %w(success pending) - context 'when test and test_failure jobs fail' do - it 'properly creates builds' do - expect(process_pipeline).to be_truthy - expect(builds.pluck(:name)).to contain_exactly('build') - expect(builds.pluck(:status)).to contain_exactly('pending') - pipeline.builds.running_or_pending.each(&:success) - - expect(builds.pluck(:name)).to contain_exactly('build', 'test') - expect(builds.pluck(:status)).to contain_exactly('success', 'pending') - pipeline.builds.running_or_pending.each(&:drop) - - expect(builds.pluck(:name)).to contain_exactly('build', 'test', 'test_failure') - expect(builds.pluck(:status)).to contain_exactly('success', 'failed', 'pending') - pipeline.builds.running_or_pending.each(&:drop) - - expect(builds.pluck(:name)).to contain_exactly('build', 'test', 'test_failure', 'cleanup') - expect(builds.pluck(:status)).to contain_exactly('success', 'failed', 'failed', 'pending') - pipeline.builds.running_or_pending.each(&:success) - - expect(builds.pluck(:name)).to contain_exactly('build', 'test', 'test_failure', 'cleanup') - expect(builds.pluck(:status)).to contain_exactly('success', 'failed', 'failed', 'success') - pipeline.reload - expect(pipeline.status).to eq('failed') - end - end + cancel_running_or_pending - context 'when deploy job fails' do - it 'properly creates builds' do - expect(process_pipeline).to be_truthy - expect(builds.pluck(:name)).to contain_exactly('build') - expect(builds.pluck(:status)).to contain_exactly('pending') - pipeline.builds.running_or_pending.each(&:success) - - expect(builds.pluck(:name)).to contain_exactly('build', 'test') - expect(builds.pluck(:status)).to contain_exactly('success', 'pending') - pipeline.builds.running_or_pending.each(&:success) - - expect(builds.pluck(:name)).to contain_exactly('build', 'test', 'deploy') - expect(builds.pluck(:status)).to contain_exactly('success', 'success', 'pending') - pipeline.builds.running_or_pending.each(&:drop) - - expect(builds.pluck(:name)).to contain_exactly('build', 'test', 'deploy', 'cleanup') - expect(builds.pluck(:status)).to contain_exactly('success', 'success', 'failed', 'pending') - pipeline.builds.running_or_pending.each(&:success) - - expect(builds.pluck(:status)).to contain_exactly('success', 'success', 'failed', 'success') - pipeline.reload - expect(pipeline.status).to eq('failed') - end + expect(builds.running_or_pending).to be_empty + expect(builds_names).to eq %w[build test] + expect(builds_statuses).to eq %w[success canceled] + expect(pipeline.reload).to be_canceled end + end - context 'when build is canceled in the second stage' do - it 'does not schedule builds after build has been canceled' do - expect(process_pipeline).to be_truthy - expect(builds.pluck(:name)).to contain_exactly('build') - expect(builds.pluck(:status)).to contain_exactly('pending') - pipeline.builds.running_or_pending.each(&:success) + context 'when listing optional manual actions' do + it 'returns only for skipped builds' do + # currently all builds are created + expect(process_pipeline).to be_truthy + expect(manual_actions).to be_empty - expect(builds.running_or_pending).not_to be_empty + # succeed stage build + succeed_running_or_pending - expect(builds.pluck(:name)).to contain_exactly('build', 'test') - expect(builds.pluck(:status)).to contain_exactly('success', 'pending') - pipeline.builds.running_or_pending.each(&:cancel) + expect(manual_actions).to be_empty - expect(builds.running_or_pending).to be_empty - expect(pipeline.reload.status).to eq('canceled') - end - end + # succeed stage test + succeed_running_or_pending - context 'when listing manual actions' do - it 'returns only for skipped builds' do - # currently all builds are created - expect(process_pipeline).to be_truthy - expect(manual_actions).to be_empty + expect(manual_actions).to be_one # production - # succeed stage build - pipeline.builds.running_or_pending.each(&:success) - expect(manual_actions).to be_empty + # succeed stage deploy + succeed_running_or_pending - # succeed stage test - pipeline.builds.running_or_pending.each(&:success) - expect(manual_actions).to be_one # production + expect(manual_actions).to be_many # production and clear cache + end + end + end + + context 'when there are manual action in earlier stages' do + context 'when first stage has only optional manual actions' do + before do + create_build('build', stage_idx: 0, when: 'manual', allow_failure: true) + create_build('check', stage_idx: 1) + create_build('test', stage_idx: 2) - # succeed stage deploy - pipeline.builds.running_or_pending.each(&:success) - expect(manual_actions).to be_many # production and clear cache - end + process_pipeline + end + + it 'starts from the second stage' do + expect(all_builds_statuses).to eq %w[manual pending created] end end - context 'when there are manual/on_failure jobs in earlier stages' do + context 'when second stage has only optional manual actions' do before do - builds + create_build('check', stage_idx: 0) + create_build('build', stage_idx: 1, when: 'manual', allow_failure: true) + create_build('test', stage_idx: 2) + process_pipeline - builds.each(&:reload) end - context 'when first stage has only manual jobs' do - let(:builds) do - [create_build('build', 0, 'manual'), - create_build('check', 1), - create_build('test', 2)] - end + it 'skips second stage and continues on third stage' do + expect(all_builds_statuses).to eq(%w[pending created created]) - it 'starts from the second stage' do - expect(builds.map(&:status)).to eq(%w[skipped pending created]) - end + builds.first.success + + expect(all_builds_statuses).to eq(%w[success manual pending]) end + end + end + + context 'when blocking manual actions are defined' do + before do + create_build('code:test', stage_idx: 0) + create_build('staging:deploy', stage_idx: 1, when: 'manual') + create_build('staging:test', stage_idx: 2, when: 'on_success') + create_build('production:deploy', stage_idx: 3, when: 'manual') + create_build('production:test', stage_idx: 4, when: 'always') + end - context 'when second stage has only manual jobs' do - let(:builds) do - [create_build('check', 0), - create_build('build', 1, 'manual'), - create_build('test', 2)] - end + context 'when first stage succeeds' do + it 'blocks pipeline on stage with first manual action' do + process_pipeline - it 'skips second stage and continues on third stage' do - expect(builds.map(&:status)).to eq(%w[pending created created]) + expect(builds_names).to eq %w[code:test] + expect(builds_statuses).to eq %w[pending] + expect(pipeline.reload.status).to eq 'pending' - builds.first.success - builds.each(&:reload) + succeed_running_or_pending - expect(builds.map(&:status)).to eq(%w[success skipped pending]) - end + expect(builds_names).to eq %w[code:test staging:deploy] + expect(builds_statuses).to eq %w[success manual] + expect(pipeline.reload).to be_manual end + end + + context 'when first stage fails' do + it 'does not take blocking action into account' do + process_pipeline + + expect(builds_names).to eq %w[code:test] + expect(builds_statuses).to eq %w[pending] + expect(pipeline.reload.status).to eq 'pending' - context 'when second stage has only on_failure jobs' do - let(:builds) do - [create_build('check', 0), - create_build('build', 1, 'on_failure'), - create_build('test', 2)] - end + fail_running_or_pending - it 'skips second stage and continues on third stage' do - expect(builds.map(&:status)).to eq(%w[pending created created]) + expect(builds_names).to eq %w[code:test production:test] + expect(builds_statuses).to eq %w[failed pending] - builds.first.success - builds.each(&:reload) + succeed_running_or_pending - expect(builds.map(&:status)).to eq(%w[success skipped pending]) - end + expect(builds_statuses).to eq %w[failed success] + expect(pipeline.reload).to be_failed end end - context 'when failed build in the middle stage is retried' do - context 'when failed build is the only unsuccessful build in the stage' do - before do - create(:ci_build, :created, pipeline: pipeline, name: 'build:1', stage_idx: 0) - create(:ci_build, :created, pipeline: pipeline, name: 'build:2', stage_idx: 0) - create(:ci_build, :created, pipeline: pipeline, name: 'test:1', stage_idx: 1) - create(:ci_build, :created, pipeline: pipeline, name: 'test:2', stage_idx: 1) - create(:ci_build, :created, pipeline: pipeline, name: 'deploy:1', stage_idx: 2) - create(:ci_build, :created, pipeline: pipeline, name: 'deploy:2', stage_idx: 2) - end + context 'when pipeline is promoted sequentially up to the end' do + it 'properly processes entire pipeline' do + process_pipeline + + expect(builds_names).to eq %w[code:test] + expect(builds_statuses).to eq %w[pending] + + succeed_running_or_pending + + expect(builds_names).to eq %w[code:test staging:deploy] + expect(builds_statuses).to eq %w[success manual] + expect(pipeline.reload).to be_manual + + play_manual_action('staging:deploy') + + expect(builds_statuses).to eq %w[success pending] + + succeed_running_or_pending + + expect(builds_names).to eq %w[code:test staging:deploy staging:test] + expect(builds_statuses).to eq %w[success success pending] + + succeed_running_or_pending - it 'does trigger builds in the next stage' do - expect(process_pipeline).to be_truthy - expect(builds.pluck(:name)).to contain_exactly('build:1', 'build:2') + expect(builds_names).to eq %w[code:test staging:deploy staging:test + production:deploy] + expect(builds_statuses).to eq %w[success success success manual] - pipeline.builds.running_or_pending.each(&:success) + expect(pipeline.reload).to be_manual + expect(pipeline.reload).to be_blocked + expect(pipeline.reload).not_to be_active + expect(pipeline.reload).not_to be_complete - expect(builds.pluck(:name)) - .to contain_exactly('build:1', 'build:2', 'test:1', 'test:2') + play_manual_action('production:deploy') - pipeline.builds.find_by(name: 'test:1').success - pipeline.builds.find_by(name: 'test:2').drop + expect(builds_statuses).to eq %w[success success success pending] + expect(pipeline.reload).to be_running - expect(builds.pluck(:name)) - .to contain_exactly('build:1', 'build:2', 'test:1', 'test:2') + succeed_running_or_pending - Ci::Build.retry(pipeline.builds.find_by(name: 'test:2'), user).success + expect(builds_names).to eq %w[code:test staging:deploy staging:test + production:deploy production:test] + expect(builds_statuses).to eq %w[success success success success pending] + expect(pipeline.reload).to be_running - expect(builds.pluck(:name)).to contain_exactly( - 'build:1', 'build:2', 'test:1', 'test:2', 'test:2', 'deploy:1', 'deploy:2') - end + succeed_running_or_pending + + expect(builds_names).to eq %w[code:test staging:deploy staging:test + production:deploy production:test] + expect(builds_statuses).to eq %w[success success success success success] + expect(pipeline.reload).to be_success end end + end - context 'when there are builds that are not created yet' do - let(:pipeline) do - create(:ci_pipeline, config: config) - end + context 'when second stage has only on_failure jobs' do + before do + create_build('check', stage_idx: 0) + create_build('build', stage_idx: 1, when: 'on_failure') + create_build('test', stage_idx: 2) - let(:config) do - { rspec: { stage: 'test', script: 'rspec' }, - deploy: { stage: 'deploy', script: 'rsync' } } - end + process_pipeline + end + + it 'skips second stage and continues on third stage' do + expect(all_builds_statuses).to eq(%w[pending created created]) + builds.first.success + + expect(all_builds_statuses).to eq(%w[success skipped pending]) + end + end + + context 'when failed build in the middle stage is retried' do + context 'when failed build is the only unsuccessful build in the stage' do before do - create(:ci_build, :created, pipeline: pipeline, name: 'linux', stage: 'build', stage_idx: 0) - create(:ci_build, :created, pipeline: pipeline, name: 'mac', stage: 'build', stage_idx: 0) + create_build('build:1', stage_idx: 0) + create_build('build:2', stage_idx: 0) + create_build('test:1', stage_idx: 1) + create_build('test:2', stage_idx: 1) + create_build('deploy:1', stage_idx: 2) + create_build('deploy:2', stage_idx: 2) end - it 'processes the pipeline' do - # Currently we have five builds with state created - # - expect(builds.count).to eq(0) - expect(all_builds.count).to eq(2) + it 'does trigger builds in the next stage' do + expect(process_pipeline).to be_truthy + expect(builds_names).to eq ['build:1', 'build:2'] - # Process builds service will enqueue builds from the first stage. - # - process_pipeline + succeed_running_or_pending - expect(builds.count).to eq(2) - expect(all_builds.count).to eq(2) + expect(builds_names).to eq ['build:1', 'build:2', 'test:1', 'test:2'] - # When builds succeed we will enqueue remaining builds. - # - # We will have 2 succeeded, 1 pending (from stage test), total 4 (two - # additional build from `.gitlab-ci.yml`). - # - succeed_pending - process_pipeline + pipeline.builds.find_by(name: 'test:1').success + pipeline.builds.find_by(name: 'test:2').drop - expect(builds.success.count).to eq(2) - expect(builds.pending.count).to eq(1) - expect(all_builds.count).to eq(4) + expect(builds_names).to eq ['build:1', 'build:2', 'test:1', 'test:2'] - # When pending build succeeds in stage test, we enqueue deploy stage. - # - succeed_pending - process_pipeline + Ci::Build.retry(pipeline.builds.find_by(name: 'test:2'), user).success - expect(builds.pending.count).to eq(1) - expect(builds.success.count).to eq(3) - expect(all_builds.count).to eq(4) + expect(builds_names).to eq ['build:1', 'build:2', 'test:1', 'test:2', + 'test:2', 'deploy:1', 'deploy:2'] + end + end + end - # When the last one succeeds we have 4 successful builds. - # - succeed_pending - process_pipeline + context 'when there are builds that are not created yet' do + let(:pipeline) do + create(:ci_pipeline, config: config) + end - expect(builds.success.count).to eq(4) - expect(all_builds.count).to eq(4) - end + let(:config) do + { rspec: { stage: 'test', script: 'rspec' }, + deploy: { stage: 'deploy', script: 'rsync' } } + end + + before do + create_build('linux', stage: 'build', stage_idx: 0) + create_build('mac', stage: 'build', stage_idx: 0) + end + + it 'processes the pipeline' do + # Currently we have five builds with state created + # + expect(builds.count).to eq(0) + expect(all_builds.count).to eq(2) + + # Process builds service will enqueue builds from the first stage. + # + process_pipeline + + expect(builds.count).to eq(2) + expect(all_builds.count).to eq(2) + + # When builds succeed we will enqueue remaining builds. + # + # We will have 2 succeeded, 1 pending (from stage test), total 4 (two + # additional build from `.gitlab-ci.yml`). + # + succeed_pending + process_pipeline + + expect(builds.success.count).to eq(2) + expect(builds.pending.count).to eq(1) + expect(all_builds.count).to eq(4) + + # When pending merge_when_pipeline_succeeds in stage test, we enqueue deploy stage. + # + succeed_pending + process_pipeline + + expect(builds.pending.count).to eq(1) + expect(builds.success.count).to eq(3) + expect(all_builds.count).to eq(4) + + # When the last one succeeds we have 4 successful builds. + # + succeed_pending + process_pipeline + + expect(builds.success.count).to eq(4) + expect(all_builds.count).to eq(4) end end + def process_pipeline + described_class.new(pipeline.project, user).execute(pipeline) + end + def all_builds - pipeline.builds + pipeline.builds.order(:stage_idx, :id) end def builds all_builds.where.not(status: [:created, :skipped]) end - def process_pipeline - described_class.new(pipeline.project, user).execute(pipeline) + def builds_names + builds.pluck(:name) + end + + def builds_statuses + builds.pluck(:status) + end + + def all_builds_statuses + all_builds.pluck(:status) end def succeed_pending builds.pending.update_all(status: 'success') end - def manual_actions - pipeline.manual_actions + def succeed_running_or_pending + pipeline.builds.running_or_pending.each(&:success) + end + + def fail_running_or_pending + pipeline.builds.running_or_pending.each(&:drop) + end + + def cancel_running_or_pending + pipeline.builds.running_or_pending.each(&:cancel) + end + + def play_manual_action(name) + builds.find_by(name: name).play(user) end - def create_build(name, stage_idx, when_value = nil) - create(:ci_build, - :created, - pipeline: pipeline, - name: name, - stage_idx: stage_idx, - when: when_value) + delegate :manual_actions, to: :pipeline + + def create_build(name, **opts) + create(:ci_build, :created, pipeline: pipeline, name: name, **opts) end end diff --git a/spec/services/ci/register_build_service_spec.rb b/spec/services/ci/register_job_service_spec.rb index d9f774a1095..62ba0b01339 100644 --- a/spec/services/ci/register_build_service_spec.rb +++ b/spec/services/ci/register_job_service_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' module Ci - describe RegisterBuildService, services: true do + describe RegisterJobService, services: true do let!(:project) { FactoryGirl.create :empty_project, shared_runners_enabled: false } let!(:pipeline) { FactoryGirl.create :ci_pipeline, project: project } let!(:pending_build) { FactoryGirl.create :ci_build, pipeline: pipeline } @@ -170,6 +170,51 @@ module Ci end end + context 'when first build is stalled' do + before do + pending_build.lock_version = 10 + end + + subject { described_class.new(specific_runner).execute } + + context 'with multiple builds are in queue' do + let!(:other_build) { create :ci_build, pipeline: pipeline } + + before do + allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_specific_runner) + .and_return([pending_build, other_build]) + end + + it "receives second build from the queue" do + expect(subject).to be_valid + expect(subject.build).to eq(other_build) + end + end + + context 'when single build is in queue' do + before do + allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_specific_runner) + .and_return([pending_build]) + end + + it "does not receive any valid result" do + expect(subject).not_to be_valid + end + end + + context 'when there is no build in queue' do + before do + allow_any_instance_of(Ci::RegisterJobService).to receive(:builds_for_specific_runner) + .and_return([]) + end + + it "does not receive builds but result is valid" do + expect(subject).to be_valid + expect(subject.build).to be_nil + end + end + end + def execute(runner) described_class.new(runner).execute.build end diff --git a/spec/services/ci/retry_build_service_spec.rb b/spec/services/ci/retry_build_service_spec.rb index 93147870afe..8567817147b 100644 --- a/spec/services/ci/retry_build_service_spec.rb +++ b/spec/services/ci/retry_build_service_spec.rb @@ -10,22 +10,39 @@ describe Ci::RetryBuildService, :services do described_class.new(project, user) end + CLONE_ACCESSORS = described_class::CLONE_ACCESSORS + + REJECT_ACCESSORS = + %i[id status user token coverage trace runner artifacts_expire_at + artifacts_file artifacts_metadata artifacts_size created_at + updated_at started_at finished_at queued_at erased_by + erased_at].freeze + + IGNORE_ACCESSORS = + %i[type lock_version target_url base_tags + commit_id deployments erased_by_id last_deployment project_id + runner_id tag_taggings taggings tags trigger_request_id + user_id].freeze + shared_examples 'build duplication' do let(:build) do - create(:ci_build, :failed, :artifacts, :erased, :trace, - :queued, :coverage, pipeline: pipeline) + create(:ci_build, :failed, :artifacts_expired, :erased, + :queued, :coverage, :tags, :allowed_to_fail, :on_tag, + :teardown_environment, :triggered, :trace, + description: 'some build', pipeline: pipeline) end - describe 'clone attributes' do - described_class::CLONE_ATTRIBUTES.each do |attribute| + describe 'clone accessors' do + CLONE_ACCESSORS.each do |attribute| it "clones #{attribute} build attribute" do + expect(new_build.send(attribute)).to be_present expect(new_build.send(attribute)).to eq build.send(attribute) end end end - describe 'reject attributes' do - described_class::REJECT_ATTRIBUTES.each do |attribute| + describe 'reject acessors' do + REJECT_ACCESSORS.each do |attribute| it "does not clone #{attribute} build attribute" do expect(new_build.send(attribute)).not_to eq build.send(attribute) end @@ -33,12 +50,20 @@ describe Ci::RetryBuildService, :services do end it 'has correct number of known attributes' do - attributes = - described_class::CLONE_ATTRIBUTES + - described_class::IGNORE_ATTRIBUTES + - described_class::REJECT_ATTRIBUTES + known_accessors = CLONE_ACCESSORS + REJECT_ACCESSORS + IGNORE_ACCESSORS + + # :tag_list is a special case, this accessor does not exist + # in reflected associations, comes from `act_as_taggable` and + # we use it to copy tags, instead of reusing tags. + # + current_accessors = + Ci::Build.attribute_names.map(&:to_sym) + + Ci::Build.reflect_on_all_associations.map(&:name) + + [:tag_list] + + current_accessors.uniq! - expect(attributes.size).to eq build.attributes.size + expect(known_accessors).to contain_exactly(*current_accessors) end end diff --git a/spec/services/ci/retry_pipeline_service_spec.rb b/spec/services/ci/retry_pipeline_service_spec.rb index c0af8b8450a..5445b65f4e8 100644 --- a/spec/services/ci/retry_pipeline_service_spec.rb +++ b/spec/services/ci/retry_pipeline_service_spec.rb @@ -69,35 +69,95 @@ describe Ci::RetryPipelineService, '#execute', :services do end end + context 'when the last stage was skipepd' do + before do + create_build('build 1', :success, 0) + create_build('test 2', :failed, 1) + create_build('report 3', :skipped, 2) + create_build('report 4', :skipped, 2) + end + + it 'retries builds only in the first stage' do + service.execute(pipeline) + + expect(build('build 1')).to be_success + expect(build('test 2')).to be_pending + expect(build('report 3')).to be_created + expect(build('report 4')).to be_created + expect(pipeline.reload).to be_running + end + end + context 'when pipeline contains manual actions' do - context 'when there is a canceled manual action in first stage' do - before do - create_build('rspec 1', :failed, 0) - create_build('staging', :canceled, 0, :manual) - create_build('rspec 2', :canceled, 1) + context 'when there are optional manual actions only' do + context 'when there is a canceled manual action in first stage' do + before do + create_build('rspec 1', :failed, 0) + create_build('staging', :canceled, 0, when: :manual, allow_failure: true) + create_build('rspec 2', :canceled, 1) + end + + it 'retries failed builds and marks subsequent for processing' do + service.execute(pipeline) + + expect(build('rspec 1')).to be_pending + expect(build('staging')).to be_manual + expect(build('rspec 2')).to be_created + expect(pipeline.reload).to be_running + end end + end - it 'retries builds failed builds and marks subsequent for processing' do - service.execute(pipeline) + context 'when pipeline has blocking manual actions defined' do + context 'when pipeline retry should enqueue builds' do + before do + create_build('test', :failed, 0) + create_build('deploy', :canceled, 0, when: :manual, allow_failure: false) + create_build('verify', :canceled, 1) + end + + it 'retries failed builds' do + service.execute(pipeline) + + expect(build('test')).to be_pending + expect(build('deploy')).to be_manual + expect(build('verify')).to be_created + expect(pipeline.reload).to be_running + end + end - expect(build('rspec 1')).to be_pending - expect(build('staging')).to be_skipped - expect(build('rspec 2')).to be_created - expect(pipeline.reload).to be_running + context 'when pipeline retry should block pipeline immediately' do + before do + create_build('test', :success, 0) + create_build('deploy:1', :success, 1, when: :manual, allow_failure: false) + create_build('deploy:2', :failed, 1, when: :manual, allow_failure: false) + create_build('verify', :canceled, 2) + end + + it 'reprocesses blocking manual action and blocks pipeline' do + service.execute(pipeline) + + expect(build('deploy:1')).to be_success + expect(build('deploy:2')).to be_manual + expect(build('verify')).to be_created + expect(pipeline.reload).to be_blocked + end end end context 'when there is a skipped manual action in last stage' do before do create_build('rspec 1', :canceled, 0) - create_build('staging', :skipped, 1, :manual) + create_build('rspec 2', :skipped, 0, when: :manual, allow_failure: true) + create_build('staging', :skipped, 1, when: :manual, allow_failure: true) end - it 'retries canceled job and skips manual action' do + it 'retries canceled job and reprocesses manual actions' do service.execute(pipeline) expect(build('rspec 1')).to be_pending - expect(build('staging')).to be_skipped + expect(build('rspec 2')).to be_manual + expect(build('staging')).to be_created expect(pipeline.reload).to be_running end end @@ -105,7 +165,7 @@ describe Ci::RetryPipelineService, '#execute', :services do context 'when there is a created manual action in the last stage' do before do create_build('rspec 1', :canceled, 0) - create_build('staging', :created, 1, :manual) + create_build('staging', :created, 1, when: :manual, allow_failure: true) end it 'retries canceled job and does not update the manual action' do @@ -120,14 +180,14 @@ describe Ci::RetryPipelineService, '#execute', :services do context 'when there is a created manual action in the first stage' do before do create_build('rspec 1', :canceled, 0) - create_build('staging', :created, 0, :manual) + create_build('staging', :created, 0, when: :manual, allow_failure: true) end - it 'retries canceled job and skipps the manual action' do + it 'retries canceled job and processes the manual action' do service.execute(pipeline) expect(build('rspec 1')).to be_pending - expect(build('staging')).to be_skipped + expect(build('staging')).to be_manual expect(pipeline.reload).to be_running end end @@ -162,13 +222,12 @@ describe Ci::RetryPipelineService, '#execute', :services do statuses.latest.find_by(name: name) end - def create_build(name, status, stage_num, on = 'on_success') + def create_build(name, status, stage_num, **opts) create(:ci_build, name: name, status: status, stage: "stage_#{stage_num}", stage_idx: stage_num, - when: on, - pipeline: pipeline) do |build| + pipeline: pipeline, **opts) do |build| pipeline.update_status end end diff --git a/spec/services/create_deployment_service_spec.rb b/spec/services/create_deployment_service_spec.rb index 6fb4d517115..a883705bd45 100644 --- a/spec/services/create_deployment_service_spec.rb +++ b/spec/services/create_deployment_service_spec.rb @@ -9,7 +9,8 @@ describe CreateDeploymentService, services: true do describe '#execute' do let(:options) { nil } let(:params) do - { environment: 'production', + { + environment: 'production', ref: 'master', tag: false, sha: '97de212e80737a608d939f648d959671fb0a0142', @@ -83,10 +84,11 @@ describe CreateDeploymentService, services: true do context 'for environment with invalid name' do let(:params) do - { environment: 'name,with,commas', + { + environment: 'name,with,commas', ref: 'master', tag: false, - sha: '97de212e80737a608d939f648d959671fb0a0142', + sha: '97de212e80737a608d939f648d959671fb0a0142' } end @@ -101,16 +103,17 @@ describe CreateDeploymentService, services: true do context 'when variables are used' do let(:params) do - { environment: 'review-apps/$CI_BUILD_REF_NAME', + { + environment: 'review-apps/$CI_COMMIT_REF_NAME', ref: 'master', tag: false, sha: '97de212e80737a608d939f648d959671fb0a0142', options: { - name: 'review-apps/$CI_BUILD_REF_NAME', - url: 'http://$CI_BUILD_REF_NAME.review-apps.gitlab.com' + name: 'review-apps/$CI_COMMIT_REF_NAME', + url: 'http://$CI_COMMIT_REF_NAME.review-apps.gitlab.com' }, variables: [ - { key: 'CI_BUILD_REF_NAME', value: 'feature-review-apps' } + { key: 'CI_COMMIT_REF_NAME', value: 'feature-review-apps' } ] } end diff --git a/spec/services/git_push_service_spec.rb b/spec/services/git_push_service_spec.rb index 2a0f00ce937..bd71618e6f4 100644 --- a/spec/services/git_push_service_spec.rb +++ b/spec/services/git_push_service_spec.rb @@ -150,6 +150,13 @@ describe GitPushService, services: true do execute_service(project, user, @blankrev, 'newrev', 'refs/heads/master' ) end end + + context "Sends System Push data" do + it "when pushing on a branch" do + expect(SystemHookPushWorker).to receive(:perform_async).with(@push_data, :push_hooks) + execute_service(project, user, @oldrev, @newrev, @ref ) + end + end end describe "Updates git attributes" do diff --git a/spec/services/groups/create_service_spec.rb b/spec/services/groups/create_service_spec.rb index 14717a7455d..ec89b540e6a 100644 --- a/spec/services/groups/create_service_spec.rb +++ b/spec/services/groups/create_service_spec.rb @@ -4,11 +4,11 @@ describe Groups::CreateService, '#execute', services: true do let!(:user) { create(:user) } let!(:group_params) { { path: "group_path", visibility_level: Gitlab::VisibilityLevel::PUBLIC } } + subject { service.execute } + describe 'visibility level restrictions' do let!(:service) { described_class.new(user, group_params) } - subject { service.execute } - context "create groups without restricted visibility level" do it { is_expected.to be_persisted } end @@ -24,8 +24,6 @@ describe Groups::CreateService, '#execute', services: true do let!(:group) { create(:group) } let!(:service) { described_class.new(user, group_params.merge(parent_id: group.id)) } - subject { service.execute } - context 'as group owner' do before { group.add_owner(user) } @@ -40,4 +38,20 @@ describe Groups::CreateService, '#execute', services: true do end end end + + describe 'creating a mattermost team' do + let!(:params) { group_params.merge(create_chat_team: "true") } + let!(:service) { described_class.new(user, params) } + + before do + Settings.mattermost['enabled'] = true + end + + it 'create the chat team with the group' do + allow_any_instance_of(Mattermost::Team).to receive(:create) + .and_return({ 'name' => 'tanuki', 'id' => 'lskdjfwlekfjsdifjj' }) + + expect { subject }.to change { ChatTeam.count }.from(0).to(1) + end + end end diff --git a/spec/services/groups/destroy_service_spec.rb b/spec/services/groups/destroy_service_spec.rb index 32c2ed8cae7..98c560ffb26 100644 --- a/spec/services/groups/destroy_service_spec.rb +++ b/spec/services/groups/destroy_service_spec.rb @@ -5,6 +5,7 @@ describe Groups::DestroyService, services: true do let!(:user) { create(:user) } let!(:group) { create(:group) } + let!(:nested_group) { create(:group, parent: group) } let!(:project) { create(:project, namespace: group) } let!(:gitlab_shell) { Gitlab::Shell.new } let!(:remove_path) { group.path + "+#{group.id}+deleted" } @@ -20,6 +21,7 @@ describe Groups::DestroyService, services: true do end it { expect(Group.unscoped.all).not_to include(group) } + it { expect(Group.unscoped.all).not_to include(nested_group) } it { expect(Project.unscoped.all).not_to include(project) } end diff --git a/spec/services/issues/build_service_spec.rb b/spec/services/issues/build_service_spec.rb index 09807e5d35b..1dd53236fbd 100644 --- a/spec/services/issues/build_service_spec.rb +++ b/spec/services/issues/build_service_spec.rb @@ -8,24 +8,34 @@ describe Issues::BuildService, services: true do project.team << [user, :developer] end + context 'for a single discussion' do + describe '#execute' do + let(:merge_request) { create(:merge_request, title: "Hello world", source_project: project) } + let(:discussion) { Discussion.new([create(:diff_note_on_merge_request, project: project, noteable: merge_request, note: "Almost done")]) } + let(:service) { described_class.new(project, user, merge_request_to_resolve_discussions_of: merge_request.iid, discussion_to_resolve: discussion.id) } + + it 'references the noteable title in the issue title' do + issue = service.execute + + expect(issue.title).to include('Hello world') + end + + it 'adds the note content to the description' do + issue = service.execute + + expect(issue.description).to include('Almost done') + end + end + end + context 'for discussions in a merge request' do let(:merge_request) { create(:merge_request_with_diff_notes, source_project: project) } - let(:issue) { described_class.new(project, user, merge_request_for_resolving_discussions: merge_request).execute } - - def position_on_line(line_number) - Gitlab::Diff::Position.new( - old_path: "files/ruby/popen.rb", - new_path: "files/ruby/popen.rb", - old_line: nil, - new_line: line_number, - diff_refs: merge_request.diff_refs - ) - end + let(:issue) { described_class.new(project, user, merge_request_to_resolve_discussions_of: merge_request.iid).execute } describe '#items_for_discussions' do it 'has an item for each discussion' do - create(:diff_note_on_merge_request, noteable: merge_request, project: merge_request.source_project, position: position_on_line(13)) - service = described_class.new(project, user, merge_request_for_resolving_discussions: merge_request) + create(:diff_note_on_merge_request, noteable: merge_request, project: merge_request.source_project, line_number: 13) + service = described_class.new(project, user, merge_request_to_resolve_discussions_of: merge_request.iid) service.execute @@ -34,7 +44,7 @@ describe Issues::BuildService, services: true do end describe '#item_for_discussion' do - let(:service) { described_class.new(project, user, merge_request_for_resolving_discussions: merge_request) } + let(:service) { described_class.new(project, user, merge_request_to_resolve_discussions_of: merge_request.iid) } it 'mentions the author of the note' do discussion = Discussion.new([create(:diff_note_on_merge_request, author: create(:user, username: 'author'))]) @@ -47,11 +57,11 @@ describe Issues::BuildService, services: true do "with a blockquote\n"\ "> That has a quote\n"\ ">>>\n" - note_result = "This is a string\n"\ - "> with a blockquote\n"\ - "> > That has a quote\n" + note_result = " > This is a string\n"\ + " > > with a blockquote\n"\ + " > > > That has a quote\n" discussion = Discussion.new([create(:diff_note_on_merge_request, note: note_text)]) - expect(service.item_for_discussion(discussion)).to include(">>>\n#{note_result}\n>>>") + expect(service.item_for_discussion(discussion)).to include(note_result) end end @@ -66,7 +76,7 @@ describe Issues::BuildService, services: true do it 'does not assign title when a title was given' do issue = described_class.new(project, user, - merge_request_for_resolving_discussions: merge_request, + merge_request_to_resolve_discussions_of: merge_request, title: 'What an issue').execute expect(issue.title).to eq('What an issue') @@ -74,7 +84,7 @@ describe Issues::BuildService, services: true do it 'does not assign description when a description was given' do issue = described_class.new(project, user, - merge_request_for_resolving_discussions: merge_request, + merge_request_to_resolve_discussions_of: merge_request, description: 'Fix at your earliest conveignance').execute expect(issue.description).to eq('Fix at your earliest conveignance') @@ -82,7 +92,7 @@ describe Issues::BuildService, services: true do describe 'with multiple discussions' do before do - create(:diff_note_on_merge_request, noteable: merge_request, project: merge_request.target_project, position: position_on_line(15)) + create(:diff_note_on_merge_request, noteable: merge_request, project: merge_request.target_project, line_number: 15) end it 'mentions all the authors in the description' do @@ -99,7 +109,7 @@ describe Issues::BuildService, services: true do end it 'mentions additional notes' do - create_list(:diff_note_on_merge_request, 2, noteable: merge_request, project: merge_request.target_project, position: position_on_line(15)) + create_list(:diff_note_on_merge_request, 2, noteable: merge_request, project: merge_request.target_project, line_number: 15) expect(issue.description).to include('(+2 comments)') end @@ -112,7 +122,7 @@ describe Issues::BuildService, services: true do describe '#execute' do it 'mentions the merge request in the description' do - issue = described_class.new(project, user, merge_request_for_resolving_discussions: merge_request).execute + issue = described_class.new(project, user, merge_request_to_resolve_discussions_of: merge_request.iid).execute expect(issue.description).to include("Review the conversation in #{merge_request.to_reference}") end diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb index 6045d00ff09..776cbc4296b 100644 --- a/spec/services/issues/create_service_spec.rb +++ b/spec/services/issues/create_service_spec.rb @@ -140,46 +140,85 @@ describe Issues::CreateService, services: true do it_behaves_like 'new issuable record that supports slash commands' - context 'for a merge request' do + context 'resolving discussions' do let(:discussion) { Discussion.for_diff_notes([create(:diff_note_on_merge_request)]).first } let(:merge_request) { discussion.noteable } let(:project) { merge_request.source_project } - let(:opts) { { merge_request_for_resolving_discussions: merge_request } } before do project.team << [user, :master] end - it 'resolves the discussion for the merge request' do - described_class.new(project, user, opts).execute - discussion.first_note.reload + describe 'for a single discussion' do + let(:opts) { { discussion_to_resolve: discussion.id, merge_request_to_resolve_discussions_of: merge_request.iid } } - expect(discussion.resolved?).to be(true) - end + it 'resolves the discussion' do + described_class.new(project, user, opts).execute + discussion.first_note.reload - it 'added a system note to the discussion' do - described_class.new(project, user, opts).execute + expect(discussion.resolved?).to be(true) + end - reloaded_discussion = MergeRequest.find(merge_request.id).discussions.first + it 'added a system note to the discussion' do + described_class.new(project, user, opts).execute - expect(reloaded_discussion.last_note.system).to eq(true) - end + reloaded_discussion = MergeRequest.find(merge_request.id).discussions.first + + expect(reloaded_discussion.last_note.system).to eq(true) + end + + it 'assigns the title and description for the issue' do + issue = described_class.new(project, user, opts).execute + + expect(issue.title).not_to be_nil + expect(issue.description).not_to be_nil + end - it 'assigns the title and description for the issue' do - issue = described_class.new(project, user, opts).execute + it 'can set nil explicitly to the title and description' do + issue = described_class.new(project, user, + merge_request_to_resolve_discussions_of: merge_request, + description: nil, + title: nil).execute - expect(issue.title).not_to be_nil - expect(issue.description).not_to be_nil + expect(issue.description).to be_nil + expect(issue.title).to be_nil + end end - it 'can set nil explicityly to the title and description' do - issue = described_class.new(project, user, - merge_request_for_resolving_discussions: merge_request, - description: nil, - title: nil).execute + describe 'for a merge request' do + let(:opts) { { merge_request_to_resolve_discussions_of: merge_request.iid } } + + it 'resolves the discussion' do + described_class.new(project, user, opts).execute + discussion.first_note.reload - expect(issue.description).to be_nil - expect(issue.title).to be_nil + expect(discussion.resolved?).to be(true) + end + + it 'added a system note to the discussion' do + described_class.new(project, user, opts).execute + + reloaded_discussion = MergeRequest.find(merge_request.id).discussions.first + + expect(reloaded_discussion.last_note.system).to eq(true) + end + + it 'assigns the title and description for the issue' do + issue = described_class.new(project, user, opts).execute + + expect(issue.title).not_to be_nil + expect(issue.description).not_to be_nil + end + + it 'can set nil explicitly to the title and description' do + issue = described_class.new(project, user, + merge_request_to_resolve_discussions_of: merge_request, + description: nil, + title: nil).execute + + expect(issue.description).to be_nil + expect(issue.title).to be_nil + end end end diff --git a/spec/services/issues/resolve_discussions_spec.rb b/spec/services/issues/resolve_discussions_spec.rb new file mode 100644 index 00000000000..6cc738aec08 --- /dev/null +++ b/spec/services/issues/resolve_discussions_spec.rb @@ -0,0 +1,106 @@ +require 'spec_helper.rb' + +class DummyService < Issues::BaseService + include ::Issues::ResolveDiscussions + + def initialize(*args) + super + filter_resolve_discussion_params + end +end + +describe DummyService, services: true do + let(:project) { create(:project) } + let(:user) { create(:user) } + + before do + project.team << [user, :developer] + end + + describe "for resolving discussions" do + let(:discussion) { Discussion.new([create(:diff_note_on_merge_request, project: project, note: "Almost done")]) } + let(:merge_request) { discussion.noteable } + let(:other_merge_request) { create(:merge_request, source_project: project, source_branch: "other") } + + describe "#merge_request_for_resolving_discussion" do + let(:service) { described_class.new(project, user, merge_request_to_resolve_discussions_of: merge_request.iid) } + + it "finds the merge request" do + expect(service.merge_request_to_resolve_discussions_of).to eq(merge_request) + end + + it "only queries for the merge request once" do + fake_finder = double + fake_results = double + + expect(fake_finder).to receive(:execute).and_return(fake_results).exactly(1) + expect(fake_results).to receive(:find_by).exactly(1) + expect(MergeRequestsFinder).to receive(:new).and_return(fake_finder).exactly(1) + + 2.times { service.merge_request_to_resolve_discussions_of } + end + end + + describe "#discussions_to_resolve" do + it "contains a single discussion when matching merge request and discussion are passed" do + service = described_class.new( + project, + user, + discussion_to_resolve: discussion.id, + merge_request_to_resolve_discussions_of: merge_request.iid + ) + # We need to compare discussion id's because the Discussion-objects are rebuilt + # which causes the object-id's not to be different. + discussion_ids = service.discussions_to_resolve.map(&:id) + + expect(discussion_ids).to contain_exactly(discussion.id) + end + + it "contains all discussions when only a merge request is passed" do + second_discussion = Discussion.new([create(:diff_note_on_merge_request, + noteable: merge_request, + project: merge_request.target_project, + line_number: 15)]) + service = described_class.new( + project, + user, + merge_request_to_resolve_discussions_of: merge_request.iid + ) + # We need to compare discussion id's because the Discussion-objects are rebuilt + # which causes the object-id's not to be different. + discussion_ids = service.discussions_to_resolve.map(&:id) + + expect(discussion_ids).to contain_exactly(discussion.id, second_discussion.id) + end + + it "contains only unresolved discussions" do + _second_discussion = Discussion.new([create(:diff_note_on_merge_request, :resolved, + noteable: merge_request, + project: merge_request.target_project, + line_number: 15, + )]) + service = described_class.new( + project, + user, + merge_request_to_resolve_discussions_of: merge_request.iid + ) + # We need to compare discussion id's because the Discussion-objects are rebuilt + # which causes the object-id's not to be different. + discussion_ids = service.discussions_to_resolve.map(&:id) + + expect(discussion_ids).to contain_exactly(discussion.id) + end + + it "is empty when a discussion and another merge request are passed" do + service = described_class.new( + project, + user, + discussion_to_resolve: discussion.id, + merge_request_to_resolve_discussions_of: other_merge_request.iid + ) + + expect(service.discussions_to_resolve).to be_empty + end + end + end +end diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb index d83b09fd32c..fa472f3e2c3 100644 --- a/spec/services/issues/update_service_spec.rb +++ b/spec/services/issues/update_service_spec.rb @@ -58,6 +58,22 @@ describe Issues::UpdateService, services: true do expect(issue.due_date).to eq Date.tomorrow end + it 'sorts issues as specified by parameters' do + issue1 = create(:issue, project: project, assignee_id: user3.id) + issue2 = create(:issue, project: project, assignee_id: user3.id) + + [issue, issue1, issue2].each do |issue| + issue.move_to_end + issue.save + end + + opts[:move_between_iids] = [issue1.iid, issue2.iid] + + update_issue(opts) + + expect(issue.relative_position).to be_between(issue1.relative_position, issue2.relative_position) + end + context 'when current user cannot admin issues in the project' do let(:guest) { create(:user) } before do diff --git a/spec/services/merge_requests/build_service_spec.rb b/spec/services/merge_requests/build_service_spec.rb index 0768f644036..adfa75a524f 100644 --- a/spec/services/merge_requests/build_service_spec.rb +++ b/spec/services/merge_requests/build_service_spec.rb @@ -49,10 +49,13 @@ describe MergeRequests::BuildService, services: true do let(:commits) { Commit.decorate([commit_1], project) } it 'creates compare object with target branch as default branch' do - expect(merge_request.can_be_created).to eq(false) expect(merge_request.compare).to be_present expect(merge_request.target_branch).to eq(project.default_branch) end + + it 'allows the merge request to be created' do + expect(merge_request.can_be_created).to eq(true) + end end context 'same source and target branch' do diff --git a/spec/services/merge_requests/get_urls_service_spec.rb b/spec/services/merge_requests/get_urls_service_spec.rb index 08829e4be70..b7a05907208 100644 --- a/spec/services/merge_requests/get_urls_service_spec.rb +++ b/spec/services/merge_requests/get_urls_service_spec.rb @@ -130,5 +130,15 @@ describe MergeRequests::GetUrlsService do }]) end end + + context 'when printing_merge_request_link_enabled is false' do + it 'returns empty array' do + project.update!(printing_merge_request_link_enabled: false) + + result = service.execute(existing_branch_changes) + + expect(result).to eq([]) + end + end end end diff --git a/spec/services/merge_requests/merge_when_pipeline_succeeds_service_spec.rb b/spec/services/merge_requests/merge_when_pipeline_succeeds_service_spec.rb index f92978a33a3..c2f205c389d 100644 --- a/spec/services/merge_requests/merge_when_pipeline_succeeds_service_spec.rb +++ b/spec/services/merge_requests/merge_when_pipeline_succeeds_service_spec.rb @@ -5,7 +5,7 @@ describe MergeRequests::MergeWhenPipelineSucceedsService do let(:project) { create(:project) } let(:mr_merge_if_green_enabled) do - create(:merge_request, merge_when_build_succeeds: true, merge_user: user, + create(:merge_request, merge_when_pipeline_succeeds: true, merge_user: user, source_branch: "master", target_branch: 'feature', source_project: project, target_project: project, state: "opened") end @@ -36,7 +36,7 @@ describe MergeRequests::MergeWhenPipelineSucceedsService do it 'sets the params, merge_user, and flag' do expect(merge_request).to be_valid - expect(merge_request.merge_when_build_succeeds).to be_truthy + expect(merge_request.merge_when_pipeline_succeeds).to be_truthy expect(merge_request.merge_params).to eq commit_message: 'Awesome message' expect(merge_request.merge_user).to be user end @@ -62,7 +62,7 @@ describe MergeRequests::MergeWhenPipelineSucceedsService do end it 'updates the merge params' do - expect(SystemNoteService).not_to receive(:merge_when_build_succeeds) + expect(SystemNoteService).not_to receive(:merge_when_pipeline_succeeds) service.execute(mr_merge_if_green_enabled) expect(mr_merge_if_green_enabled.merge_params).to have_key(:new_key) @@ -82,7 +82,7 @@ describe MergeRequests::MergeWhenPipelineSucceedsService do sha: merge_request_head, status: 'success') end - it "merges all merge requests with merge when build succeeds enabled" do + it "merges all merge requests with merge when the pipeline succeeds enabled" do expect(MergeWorker).to receive(:perform_async) service.trigger(triggering_pipeline) end @@ -111,6 +111,31 @@ describe MergeRequests::MergeWhenPipelineSucceedsService do service.trigger(unrelated_pipeline) end end + + context 'when the merge request is not mergeable' do + let(:mr_conflict) do + create(:merge_request, merge_when_pipeline_succeeds: true, merge_user: user, + source_branch: 'master', target_branch: 'feature-conflict', + source_project: project, target_project: project) + end + + let(:conflict_pipeline) do + create(:ci_pipeline, project: project, ref: mr_conflict.source_branch, + sha: mr_conflict.diff_head_sha, status: 'success') + end + + it 'does not merge the merge request' do + expect(MergeWorker).not_to receive(:perform_async) + + service.trigger(conflict_pipeline) + end + + it 'creates todos for unmergeability' do + expect_any_instance_of(TodoService).to receive(:merge_request_became_unmergeable).with(mr_conflict) + + service.trigger(conflict_pipeline) + end + end end describe "#cancel" do @@ -118,8 +143,8 @@ describe MergeRequests::MergeWhenPipelineSucceedsService do service.cancel(mr_merge_if_green_enabled) end - it "resets all the merge_when_build_succeeds params" do - expect(mr_merge_if_green_enabled.merge_when_build_succeeds).to be_falsey + it "resets all the pipeline succeeds params" do + expect(mr_merge_if_green_enabled.merge_when_pipeline_succeeds).to be_falsey expect(mr_merge_if_green_enabled.merge_params).to eq({}) expect(mr_merge_if_green_enabled.merge_user).to be nil end diff --git a/spec/services/merge_requests/refresh_service_spec.rb b/spec/services/merge_requests/refresh_service_spec.rb index 983dac6efdb..92729f68e5f 100644 --- a/spec/services/merge_requests/refresh_service_spec.rb +++ b/spec/services/merge_requests/refresh_service_spec.rb @@ -18,7 +18,7 @@ describe MergeRequests::RefreshService, services: true do source_branch: 'master', target_branch: 'feature', target_project: @project, - merge_when_build_succeeds: true, + merge_when_pipeline_succeeds: true, merge_user: @user) @fork_merge_request = create(:merge_request, @@ -58,16 +58,16 @@ describe MergeRequests::RefreshService, services: true do it 'executes hooks with update action' do expect(refresh_service).to have_received(:execute_hooks). with(@merge_request, 'update', @oldrev) - end - it { expect(@merge_request.notes).not_to be_empty } - it { expect(@merge_request).to be_open } - it { expect(@merge_request.merge_when_build_succeeds).to be_falsey } - it { expect(@merge_request.diff_head_sha).to eq(@newrev) } - it { expect(@fork_merge_request).to be_open } - it { expect(@fork_merge_request.notes).to be_empty } - it { expect(@build_failed_todo).to be_done } - it { expect(@fork_build_failed_todo).to be_done } + expect(@merge_request.notes).not_to be_empty + expect(@merge_request).to be_open + expect(@merge_request.merge_when_pipeline_succeeds).to be_falsey + expect(@merge_request.diff_head_sha).to eq(@newrev) + expect(@fork_merge_request).to be_open + expect(@fork_merge_request.notes).to be_empty + expect(@build_failed_todo).to be_done + expect(@fork_build_failed_todo).to be_done + end end context 'push to origin repo target branch' do @@ -76,12 +76,14 @@ describe MergeRequests::RefreshService, services: true do reload_mrs end - it { expect(@merge_request.notes.last.note).to include('merged') } - it { expect(@merge_request).to be_merged } - it { expect(@fork_merge_request).to be_merged } - it { expect(@fork_merge_request.notes.last.note).to include('merged') } - it { expect(@build_failed_todo).to be_done } - it { expect(@fork_build_failed_todo).to be_done } + it 'updates the merge state' do + expect(@merge_request.notes.last.note).to include('merged') + expect(@merge_request).to be_merged + expect(@fork_merge_request).to be_merged + expect(@fork_merge_request.notes.last.note).to include('merged') + expect(@build_failed_todo).to be_done + expect(@fork_build_failed_todo).to be_done + end end context 'manual merge of source branch' do @@ -95,13 +97,15 @@ describe MergeRequests::RefreshService, services: true do reload_mrs end - it { expect(@merge_request.notes.last.note).to include('merged') } - it { expect(@merge_request).to be_merged } - it { expect(@merge_request.diffs.size).to be > 0 } - it { expect(@fork_merge_request).to be_merged } - it { expect(@fork_merge_request.notes.last.note).to include('merged') } - it { expect(@build_failed_todo).to be_done } - it { expect(@fork_build_failed_todo).to be_done } + it 'updates the merge state' do + expect(@merge_request.notes.last.note).to include('merged') + expect(@merge_request).to be_merged + expect(@merge_request.diffs.size).to be > 0 + expect(@fork_merge_request).to be_merged + expect(@fork_merge_request.notes.last.note).to include('merged') + expect(@build_failed_todo).to be_done + expect(@fork_build_failed_todo).to be_done + end end context 'push to fork repo source branch' do @@ -117,14 +121,14 @@ describe MergeRequests::RefreshService, services: true do it 'executes hooks with update action' do expect(refresh_service).to have_received(:execute_hooks). with(@fork_merge_request, 'update', @oldrev) - end - it { expect(@merge_request.notes).to be_empty } - it { expect(@merge_request).to be_open } - it { expect(@fork_merge_request.notes.last.note).to include('added 28 commits') } - it { expect(@fork_merge_request).to be_open } - it { expect(@build_failed_todo).to be_pending } - it { expect(@fork_build_failed_todo).to be_pending } + expect(@merge_request.notes).to be_empty + expect(@merge_request).to be_open + expect(@fork_merge_request.notes.last.note).to include('added 28 commits') + expect(@fork_merge_request).to be_open + expect(@build_failed_todo).to be_pending + expect(@fork_build_failed_todo).to be_pending + end end context 'closed fork merge request' do @@ -139,12 +143,14 @@ describe MergeRequests::RefreshService, services: true do expect(refresh_service).not_to have_received(:execute_hooks) end - it { expect(@merge_request.notes).to be_empty } - it { expect(@merge_request).to be_open } - it { expect(@fork_merge_request.notes).to be_empty } - it { expect(@fork_merge_request).to be_closed } - it { expect(@build_failed_todo).to be_pending } - it { expect(@fork_build_failed_todo).to be_pending } + it 'updates merge request to closed state' do + expect(@merge_request.notes).to be_empty + expect(@merge_request).to be_open + expect(@fork_merge_request.notes).to be_empty + expect(@fork_merge_request).to be_closed + expect(@build_failed_todo).to be_pending + expect(@fork_build_failed_todo).to be_pending + end end end @@ -155,12 +161,14 @@ describe MergeRequests::RefreshService, services: true do reload_mrs end - it { expect(@merge_request.notes).to be_empty } - it { expect(@merge_request).to be_open } - it { expect(@fork_merge_request.notes).to be_empty } - it { expect(@fork_merge_request).to be_open } - it { expect(@build_failed_todo).to be_pending } - it { expect(@fork_build_failed_todo).to be_pending } + it 'updates the merge request state' do + expect(@merge_request.notes).to be_empty + expect(@merge_request).to be_open + expect(@fork_merge_request.notes).to be_empty + expect(@fork_merge_request).to be_open + expect(@build_failed_todo).to be_pending + expect(@fork_build_failed_todo).to be_pending + end end describe 'merge request diff' do @@ -179,12 +187,14 @@ describe MergeRequests::RefreshService, services: true do reload_mrs end - it { expect(@merge_request.notes.last.note).to include('merged') } - it { expect(@merge_request).to be_merged } - it { expect(@fork_merge_request).to be_open } - it { expect(@fork_merge_request.notes).to be_empty } - it { expect(@build_failed_todo).to be_done } - it { expect(@fork_build_failed_todo).to be_done } + it 'updates the merge request state' do + expect(@merge_request.notes.last.note).to include('merged') + expect(@merge_request).to be_merged + expect(@fork_merge_request).to be_open + expect(@fork_merge_request.notes).to be_empty + expect(@build_failed_todo).to be_done + expect(@fork_build_failed_todo).to be_done + end end context 'push new branch that exists in a merge request' do diff --git a/spec/services/merge_requests/resolve_service_spec.rb b/spec/services/merge_requests/resolve_service_spec.rb index a0e51681725..d33535d22af 100644 --- a/spec/services/merge_requests/resolve_service_spec.rb +++ b/spec/services/merge_requests/resolve_service_spec.rb @@ -59,20 +59,19 @@ describe MergeRequests::ResolveService do it 'creates a commit with the correct parents' do expect(merge_request.source_branch_head.parents.map(&:id)). - to eq(['1450cd639e0bc6721eb02800169e464f212cde06', - '824be604a34828eb682305f0d963056cfac87b2d']) + to eq(%w(1450cd639e0bc6721eb02800169e464f212cde06 + 824be604a34828eb682305f0d963056cfac87b2d)) end end context 'when the source project is a fork and does not contain the HEAD of the target branch' do let!(:target_head) do - project.repository.commit_file( + project.repository.create_file( user, 'new-file-in-target', '', message: 'Add new file in target', - branch_name: 'conflict-start', - update: false) + branch_name: 'conflict-start') end before do @@ -125,8 +124,8 @@ describe MergeRequests::ResolveService do it 'creates a commit with the correct parents' do expect(merge_request.source_branch_head.parents.map(&:id)). - to eq(['1450cd639e0bc6721eb02800169e464f212cde06', - '824be604a34828eb682305f0d963056cfac87b2d']) + to eq(%w(1450cd639e0bc6721eb02800169e464f212cde06 + 824be604a34828eb682305f0d963056cfac87b2d)) end it 'sets the content to the content given' do diff --git a/spec/services/notes/create_service_spec.rb b/spec/services/notes/create_service_spec.rb index 9c92a5080c6..152c6d20daa 100644 --- a/spec/services/notes/create_service_spec.rb +++ b/spec/services/notes/create_service_spec.rb @@ -102,47 +102,19 @@ describe Notes::CreateService, services: true do expect(subject.note).to eq(params[:note]) end end - end - - describe "award emoji" do - before do - project.team << [user, :master] - end - - it "creates an award emoji" do - opts = { - note: ':smile: ', - noteable_type: 'Issue', - noteable_id: issue.id - } - note = described_class.new(project, user, opts).execute - - expect(note).to be_valid - expect(note.name).to eq('smile') - end - it "creates regular note if emoji name is invalid" do - opts = { - note: ':smile: moretext:', - noteable_type: 'Issue', - noteable_id: issue.id - } - note = described_class.new(project, user, opts).execute - - expect(note).to be_valid - expect(note.note).to eq(opts[:note]) - end - - it "normalizes the emoji name" do - opts = { - note: ':+1:', - noteable_type: 'Issue', - noteable_id: issue.id - } - - expect_any_instance_of(TodoService).to receive(:new_award_emoji).with(issue, user) + describe 'note with emoji only' do + it 'creates regular note' do + opts = { + note: ':smile: ', + noteable_type: 'Issue', + noteable_id: issue.id + } + note = described_class.new(project, user, opts).execute - described_class.new(project, user, opts).execute + expect(note).to be_valid + expect(note.note).to eq(':smile:') + end end end end diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index 839250b7d84..f7240969588 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -146,16 +146,6 @@ describe NotificationService, services: true do should_not_email(@u_lazy_participant) end - it "emails the note author if they've opted into notifications about their activity" do - add_users_with_subscription(note.project, issue) - note.author.notified_of_own_activity = true - reset_delivered_emails! - - notification.new_note(note) - - should_email(note.author) - end - it 'filters out "mentioned in" notes' do mentioned_note = SystemNoteService.cross_reference(mentioned_issue, issue, issue.author) @@ -486,20 +476,6 @@ describe NotificationService, services: true do should_not_email(issue.assignee) end - it "emails the author if they've opted into notifications about their activity" do - issue.author.notified_of_own_activity = true - - notification.new_issue(issue, issue.author) - - should_email(issue.author) - end - - it "doesn't email the author if they haven't opted into notifications about their activity" do - notification.new_issue(issue, issue.author) - - should_not_email(issue.author) - end - it "emails subscribers of the issue's labels" do user_1 = create(:user) user_2 = create(:user) @@ -689,19 +665,6 @@ describe NotificationService, services: true do should_email(subscriber_to_label_2) end - it "emails the current user if they've opted into notifications about their activity" do - subscriber_to_label_2.notified_of_own_activity = true - notification.relabeled_issue(issue, [group_label_2, label_2], subscriber_to_label_2) - - should_email(subscriber_to_label_2) - end - - it "doesn't email the current user if they haven't opted into notifications about their activity" do - notification.relabeled_issue(issue, [group_label_2, label_2], subscriber_to_label_2) - - should_not_email(subscriber_to_label_2) - end - it "doesn't send email to anyone but subscribers of the given labels" do notification.relabeled_issue(issue, [group_label_2, label_2], @u_disabled) @@ -795,7 +758,7 @@ describe NotificationService, services: true do update_custom_notification(:reopen_issue, @u_custom_global) end - it 'sends email to issue assignee and issue author' do + it 'sends email to issue notification recipients' do notification.reopen_issue(issue, @u_disabled) should_email(issue.assignee) @@ -809,6 +772,7 @@ describe NotificationService, services: true do should_email(@watcher_and_subscriber) should_not_email(@unsubscriber) should_not_email(@u_participating) + should_not_email(@u_disabled) should_not_email(@u_lazy_participant) end @@ -818,6 +782,32 @@ describe NotificationService, services: true do let(:notification_trigger) { notification.reopen_issue(issue, @u_disabled) } end end + + describe '#issue_moved' do + let(:new_issue) { create(:issue) } + + it 'sends email to issue notification recipients' do + notification.issue_moved(issue, new_issue, @u_disabled) + + should_email(issue.assignee) + should_email(issue.author) + should_email(@u_watcher) + should_email(@u_guest_watcher) + should_email(@u_participant_mentioned) + should_email(@subscriber) + should_email(@watcher_and_subscriber) + should_not_email(@unsubscriber) + should_not_email(@u_participating) + should_not_email(@u_disabled) + should_not_email(@u_lazy_participant) + end + + it_behaves_like 'participating notifications' do + let(:participant) { create(:user, username: 'user-participant') } + let(:issuable) { issue } + let(:notification_trigger) { notification.issue_moved(issue, new_issue, @u_disabled) } + end + end end describe 'Merge Requests' do @@ -855,20 +845,6 @@ describe NotificationService, services: true do should_not_email(@u_lazy_participant) end - it "emails the author if they've opted into notifications about their activity" do - merge_request.author.notified_of_own_activity = true - - notification.new_merge_request(merge_request, merge_request.author) - - should_email(merge_request.author) - end - - it "doesn't email the author if they haven't opted into notifications about their activity" do - notification.new_merge_request(merge_request, merge_request.author) - - should_not_email(merge_request.author) - end - it "emails subscribers of the merge request's labels" do user_1 = create(:user) user_2 = create(:user) @@ -1050,28 +1026,20 @@ describe NotificationService, services: true do should_not_email(@u_lazy_participant) end - it "notifies the merger when merge_when_build_succeeds is true" do - merge_request.merge_when_build_succeeds = true + it "notifies the merger when the pipeline succeeds is true" do + merge_request.merge_when_pipeline_succeeds = true notification.merge_mr(merge_request, @u_watcher) should_email(@u_watcher) end - it "does not notify the merger when merge_when_build_succeeds is false" do - merge_request.merge_when_build_succeeds = false + it "does not notify the merger when the pipeline succeeds is false" do + merge_request.merge_when_pipeline_succeeds = false notification.merge_mr(merge_request, @u_watcher) should_not_email(@u_watcher) end - it "notifies the merger when merge_when_build_succeeds is false but they've opted into notifications about their activity" do - merge_request.merge_when_build_succeeds = false - @u_watcher.notified_of_own_activity = true - notification.merge_mr(merge_request, @u_watcher) - - should_email(@u_watcher) - end - it_behaves_like 'participating notifications' do let(:participant) { create(:user, username: 'user-participant') } let(:issuable) { merge_request } @@ -1251,6 +1219,48 @@ describe NotificationService, services: true do end end + describe 'Pipelines' do + describe '#pipeline_finished' do + let(:project) { create(:project, :public) } + let(:current_user) { create(:user) } + let(:u_member) { create(:user) } + let(:u_other) { create(:user) } + + let(:commit) { project.commit } + let(:pipeline) do + create(:ci_pipeline, :success, + project: project, + user: current_user, + ref: 'refs/heads/master', + sha: commit.id, + before_sha: '00000000') + end + + before do + project.add_master(current_user) + project.add_master(u_member) + reset_delivered_emails! + end + + context 'without custom recipients' do + it 'notifies the pipeline user' do + notification.pipeline_finished(pipeline) + + should_only_email(current_user, kind: :bcc) + end + end + + context 'with custom recipients' do + it 'notifies the custom recipients' do + users = [u_member, u_other] + notification.pipeline_finished(pipeline, users.map(&:notification_email)) + + should_only_email(*users, kind: :bcc) + end + end + end + end + def build_team(project) @u_watcher = create_global_setting_for(create(:user), :watch) @u_participating = create_global_setting_for(create(:user), :participating) diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb index af515ad2e0e..62f21049b0b 100644 --- a/spec/services/projects/create_service_spec.rb +++ b/spec/services/projects/create_service_spec.rb @@ -50,7 +50,7 @@ describe Projects::CreateService, '#execute', services: true do context 'error handling' do it 'handles invalid options' do - opts.merge!({ default_branch: 'master' } ) + opts[:default_branch] = 'master' expect(create_project(user, opts)).to eq(nil) end end @@ -67,7 +67,7 @@ describe Projects::CreateService, '#execute', services: true do context 'wiki_enabled false does not create wiki repository directory' do it do - opts.merge!(wiki_enabled: false) + opts[:wiki_enabled] = false project = create_project(user, opts) path = ProjectWiki.new(project, user).send(:path_to_repo) diff --git a/spec/services/projects/import_service_spec.rb b/spec/services/projects/import_service_spec.rb index ab6e8f537ba..e5917bb0b7a 100644 --- a/spec/services/projects/import_service_spec.rb +++ b/spec/services/projects/import_service_spec.rb @@ -120,6 +120,26 @@ describe Projects::ImportService, services: true do end end + context 'with blocked import_URL' do + it 'fails with localhost' do + project.import_url = 'https://localhost:9000/vim/vim.git' + + result = described_class.new(project, user).execute + + expect(result[:status]).to eq :error + expect(result[:message]).to end_with 'Blocked import URL.' + end + + it 'fails with port 25' do + project.import_url = "https://github.com:25/vim/vim.git" + + result = described_class.new(project, user).execute + + expect(result[:status]).to eq :error + expect(result[:message]).to end_with 'Blocked import URL.' + end + end + def stub_github_omniauth_provider provider = OpenStruct.new( 'name' => 'github', diff --git a/spec/services/projects/update_pages_service_spec.rb b/spec/services/projects/update_pages_service_spec.rb index 411b22a0fb8..f75fdd9e03f 100644 --- a/spec/services/projects/update_pages_service_spec.rb +++ b/spec/services/projects/update_pages_service_spec.rb @@ -26,6 +26,28 @@ describe Projects::UpdatePagesService do build.update_attributes(artifacts_metadata: metadata) end + describe 'pages artifacts' do + context 'with expiry date' do + before do + build.artifacts_expire_in = "2 days" + end + + it "doesn't delete artifacts" do + expect(execute).to eq(:success) + + expect(build.reload.artifacts_file?).to eq(true) + end + end + + context 'without expiry date' do + it "does delete artifacts" do + expect(execute).to eq(:success) + + expect(build.reload.artifacts_file?).to eq(false) + end + end + end + it 'succeeds' do expect(project.pages_deployed?).to be_falsey expect(execute).to eq(:success) diff --git a/spec/services/projects/upload_service_spec.rb b/spec/services/projects/upload_service_spec.rb index c42eeba4b9c..150c8ccaef7 100644 --- a/spec/services/projects/upload_service_spec.rb +++ b/spec/services/projects/upload_service_spec.rb @@ -10,7 +10,7 @@ describe Projects::UploadService, services: true do context 'for valid gif file' do before do gif = fixture_file_upload(Rails.root + 'spec/fixtures/banana_sample.gif', 'image/gif') - @link_to_file = upload_file(@project.repository, gif) + @link_to_file = upload_file(@project, gif) end it { expect(@link_to_file).to have_key(:alt) } @@ -23,7 +23,7 @@ describe Projects::UploadService, services: true do before do png = fixture_file_upload(Rails.root + 'spec/fixtures/dk.png', 'image/png') - @link_to_file = upload_file(@project.repository, png) + @link_to_file = upload_file(@project, png) end it { expect(@link_to_file).to have_key(:alt) } @@ -35,7 +35,7 @@ describe Projects::UploadService, services: true do context 'for valid jpg file' do before do jpg = fixture_file_upload(Rails.root + 'spec/fixtures/rails_sample.jpg', 'image/jpg') - @link_to_file = upload_file(@project.repository, jpg) + @link_to_file = upload_file(@project, jpg) end it { expect(@link_to_file).to have_key(:alt) } @@ -47,7 +47,7 @@ describe Projects::UploadService, services: true do context 'for txt file' do before do txt = fixture_file_upload(Rails.root + 'spec/fixtures/doc_sample.txt', 'text/plain') - @link_to_file = upload_file(@project.repository, txt) + @link_to_file = upload_file(@project, txt) end it { expect(@link_to_file).to have_key(:alt) } @@ -60,14 +60,14 @@ describe Projects::UploadService, services: true do before do txt = fixture_file_upload(Rails.root + 'spec/fixtures/doc_sample.txt', 'text/plain') allow(txt).to receive(:size) { 1000.megabytes.to_i } - @link_to_file = upload_file(@project.repository, txt) + @link_to_file = upload_file(@project, txt) end it { expect(@link_to_file).to eq(nil) } end end - def upload_file(repository, file) - Projects::UploadService.new(repository, file).execute + def upload_file(project, file) + Projects::UploadService.new(project, file).execute end end diff --git a/spec/services/protected_branches/create_service_spec.rb b/spec/services/protected_branches/create_service_spec.rb index 7d4eff3b6ef..6ea8f309981 100644 --- a/spec/services/protected_branches/create_service_spec.rb +++ b/spec/services/protected_branches/create_service_spec.rb @@ -6,8 +6,8 @@ describe ProtectedBranches::CreateService, services: true do let(:params) do { name: 'master', - merge_access_levels_attributes: [ { access_level: Gitlab::Access::MASTER } ], - push_access_levels_attributes: [ { access_level: Gitlab::Access::MASTER } ] + merge_access_levels_attributes: [{ access_level: Gitlab::Access::MASTER }], + push_access_levels_attributes: [{ access_level: Gitlab::Access::MASTER }] } end diff --git a/spec/services/slash_commands/interpret_service_spec.rb b/spec/services/slash_commands/interpret_service_spec.rb index 0b0925983eb..52e8678cb9d 100644 --- a/spec/services/slash_commands/interpret_service_spec.rb +++ b/spec/services/slash_commands/interpret_service_spec.rb @@ -267,6 +267,14 @@ describe SlashCommands::InterpretService, services: true do end end + shared_examples 'award command' do + it 'toggle award 100 emoji if content containts /award :100:' do + _, updates = service.execute(content, issuable) + + expect(updates).to eq(emoji_award: "100") + end + end + it_behaves_like 'reopen command' do let(:content) { '/reopen' } let(:issuable) { issue } @@ -654,6 +662,37 @@ describe SlashCommands::InterpretService, services: true do end end + context '/award command' do + it_behaves_like 'award command' do + let(:content) { '/award :100:' } + let(:issuable) { issue } + end + + it_behaves_like 'award command' do + let(:content) { '/award :100:' } + let(:issuable) { merge_request } + end + + context 'ignores command with no argument' do + it_behaves_like 'empty command' do + let(:content) { '/award' } + let(:issuable) { issue } + end + end + + context 'ignores non-existing / invalid emojis' do + it_behaves_like 'empty command' do + let(:content) { '/award noop' } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { '/award :lorem_ipsum:' } + let(:issuable) { issue } + end + end + end + context '/target_branch command' do let(:non_empty_project) { create(:project) } let(:another_merge_request) { create(:merge_request, author: developer, source_project: non_empty_project) } diff --git a/spec/services/system_hooks_service_spec.rb b/spec/services/system_hooks_service_spec.rb index db9f1231682..11037a4917b 100644 --- a/spec/services/system_hooks_service_spec.rb +++ b/spec/services/system_hooks_service_spec.rb @@ -5,6 +5,7 @@ describe SystemHooksService, services: true do let(:project) { create :project } let(:project_member) { create :project_member } let(:key) { create(:key, user: user) } + let(:deploy_key) { create(:key) } let(:group) { create(:group) } let(:group_member) { create(:group_member) } @@ -18,6 +19,8 @@ describe SystemHooksService, services: true do it { expect(event_data(project_member, :destroy)).to include(:event_name, :created_at, :updated_at, :project_name, :project_path, :project_path_with_namespace, :project_id, :user_name, :user_username, :user_email, :user_id, :access_level, :project_visibility) } it { expect(event_data(key, :create)).to include(:username, :key, :id) } it { expect(event_data(key, :destroy)).to include(:username, :key, :id) } + it { expect(event_data(deploy_key, :create)).to include(:key, :id) } + it { expect(event_data(deploy_key, :destroy)).to include(:key, :id) } it do project.old_path_with_namespace = 'renamed_from_path' diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index 7f027ae02a2..36a17a3bf2e 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -215,13 +215,13 @@ describe SystemNoteService, services: true do end end - describe '.merge_when_build_succeeds' do + describe '.merge_when_pipeline_succeeds' do let(:pipeline) { build(:ci_pipeline_without_jobs )} let(:noteable) do create(:merge_request, source_project: project, target_project: project) end - subject { described_class.merge_when_build_succeeds(noteable, project, author, noteable.diff_head_commit) } + subject { described_class.merge_when_pipeline_succeeds(noteable, project, author, noteable.diff_head_commit) } it_behaves_like 'a system note' @@ -230,12 +230,12 @@ describe SystemNoteService, services: true do end end - describe '.cancel_merge_when_build_succeeds' do + describe '.cancel_merge_when_pipeline_succeeds' do let(:noteable) do create(:merge_request, source_project: project, target_project: project) end - subject { described_class.cancel_merge_when_build_succeeds(noteable, project, author) } + subject { described_class.cancel_merge_when_pipeline_succeeds(noteable, project, author) } it_behaves_like 'a system note' @@ -418,45 +418,6 @@ describe SystemNoteService, services: true do to be_truthy end end - - context 'when noteable is an Issue' do - let(:issue) { create(:issue, project: project) } - - it 'is truthy when issue is closed' do - issue.close - - expect(described_class.cross_reference_disallowed?(issue, project.commit)). - to be_truthy - end - - it 'is falsey when issue is open' do - expect(described_class.cross_reference_disallowed?(issue, project.commit)). - to be_falsy - end - end - - context 'when noteable is a Merge Request' do - let(:merge_request) { create(:merge_request, :simple, source_project: project) } - - it 'is truthy when merge request is closed' do - allow(merge_request).to receive(:closed?).and_return(:true) - - expect(described_class.cross_reference_disallowed?(merge_request, project.commit)). - to be_truthy - end - - it 'is truthy when merge request is merged' do - allow(merge_request).to receive(:closed?).and_return(:true) - - expect(described_class.cross_reference_disallowed?(merge_request, project.commit)). - to be_truthy - end - - it 'is falsey when merge request is open' do - expect(described_class.cross_reference_disallowed?(merge_request, project.commit)). - to be_falsy - end - end end describe '.cross_reference_exists?' do @@ -631,7 +592,7 @@ describe SystemNoteService, services: true do jira_service_settings end - noteable_types = ["merge_requests", "commit"] + noteable_types = %w(merge_requests commit) noteable_types.each do |type| context "when noteable is a #{type}" do diff --git a/spec/services/todo_service_spec.rb b/spec/services/todo_service_spec.rb index 9f24cc0f3f2..3645b73b039 100644 --- a/spec/services/todo_service_spec.rb +++ b/spec/services/todo_service_spec.rb @@ -298,6 +298,10 @@ describe TodoService, services: true do expect(second_todo.reload.state?(new_state)).to be true end + it 'returns the updated ids' do + expect(service.send(meth, collection, john_doe)).to match_array([first_todo.id, second_todo.id]) + end + describe 'cached counts' do it 'updates when todos change' do expect(john_doe.todos.where(state: new_state).count).to eq(0) @@ -680,7 +684,7 @@ describe TodoService, services: true do end it 'creates a pending todo for merge_user' do - mr_unassigned.update(merge_when_build_succeeds: true, merge_user: admin) + mr_unassigned.update(merge_when_pipeline_succeeds: true, merge_user: admin) service.merge_request_build_failed(mr_unassigned) should_create_todo(user: admin, author: admin, target: mr_unassigned, action: Todo::BUILD_FAILED) @@ -700,13 +704,13 @@ describe TodoService, services: true do describe '#merge_request_became_unmergeable' do it 'creates a pending todo for a merge_user' do - mr_unassigned.update(merge_when_build_succeeds: true, merge_user: admin) + mr_unassigned.update(merge_when_pipeline_succeeds: true, merge_user: admin) service.merge_request_became_unmergeable(mr_unassigned) should_create_todo(user: admin, author: admin, target: mr_unassigned, action: Todo::UNMERGEABLE) end end - + describe '#mark_todo' do it 'creates a todo from a merge request' do service.mark_todo(mr_unassigned, author) @@ -752,7 +756,7 @@ describe TodoService, services: true do issue = create(:issue, project: project, assignee: john_doe, author: author, description: mentions) expect(john_doe.todos_pending_count).to eq(0) - expect(john_doe).to receive(:update_todos_count_cache) + expect(john_doe).to receive(:update_todos_count_cache).and_call_original service.new_issue(issue, author) @@ -779,29 +783,27 @@ describe TodoService, services: true do .to change { todo.reload.state }.from('pending').to('done') end - it 'returns the number of updated todos' do # Needed on API + it 'returns the ids of updated todos' do # Needed on API todo = create(:todo, :mentioned, user: john_doe, target: issue, project: project) - expect(TodoService.new.mark_todos_as_done([todo], john_doe)).to eq(1) + expect(TodoService.new.mark_todos_as_done([todo], john_doe)).to eq([todo.id]) end context 'when some of the todos are done already' do - before do - create(:todo, :mentioned, user: john_doe, target: issue, project: project) - create(:todo, :mentioned, user: john_doe, target: another_issue, project: project) - end + let!(:first_todo) { create(:todo, :mentioned, user: john_doe, target: issue, project: project) } + let!(:second_todo) { create(:todo, :mentioned, user: john_doe, target: another_issue, project: project) } - it 'returns the number of those still pending' do + it 'returns the ids of those still pending' do TodoService.new.mark_pending_todos_as_done(issue, john_doe) - expect(TodoService.new.mark_todos_as_done(Todo.all, john_doe)).to eq(1) + expect(TodoService.new.mark_todos_as_done(Todo.all, john_doe)).to eq([second_todo.id]) end - it 'returns 0 if all are done' do + it 'returns an empty array if all are done' do TodoService.new.mark_pending_todos_as_done(issue, john_doe) TodoService.new.mark_pending_todos_as_done(another_issue, john_doe) - expect(TodoService.new.mark_todos_as_done(Todo.all, john_doe)).to eq(0) + expect(TodoService.new.mark_todos_as_done(Todo.all, john_doe)).to eq([]) end end diff --git a/spec/services/users/destroy_spec.rb b/spec/services/users/destroy_spec.rb index c0bf27c698c..922e82445d0 100644 --- a/spec/services/users/destroy_spec.rb +++ b/spec/services/users/destroy_spec.rb @@ -24,6 +24,54 @@ describe Users::DestroyService, services: true do end end + context "a deleted user's issues" do + let(:project) { create :project } + + before do + project.add_developer(user) + end + + context "for an issue the user has created" do + let!(:issue) { create(:issue, project: project, author: user) } + + before do + service.execute(user) + end + + it 'does not delete the issue' do + expect(Issue.find_by_id(issue.id)).to be_present + end + + it 'migrates the issue so that the "Ghost User" is the issue owner' do + migrated_issue = Issue.find_by_id(issue.id) + + expect(migrated_issue.author).to eq(User.ghost) + end + + it 'blocks the user before migrating issues to the "Ghost User' do + expect(user).to be_blocked + end + end + + context "for an issue the user was assigned to" do + let!(:issue) { create(:issue, project: project, assignee: user) } + + before do + service.execute(user) + end + + it 'does not delete issues the user is assigned to' do + expect(Issue.find_by_id(issue.id)).to be_present + end + + it 'migrates the issue so that it is "Unassigned"' do + migrated_issue = Issue.find_by_id(issue.id) + + expect(migrated_issue.assignee).to be_nil + end + end + end + context "solo owned groups present" do let(:solo_owned) { create(:group) } let(:member) { create(:group_member) } diff --git a/spec/services/users/refresh_authorized_projects_service_spec.rb b/spec/services/users/refresh_authorized_projects_service_spec.rb index 690fe979492..08733d6dcf1 100644 --- a/spec/services/users/refresh_authorized_projects_service_spec.rb +++ b/spec/services/users/refresh_authorized_projects_service_spec.rb @@ -131,6 +131,80 @@ describe Users::RefreshAuthorizedProjectsService do it 'sets the values to the access levels' do expect(hash.values).to eq([Gitlab::Access::MASTER]) end + + context 'personal projects' do + it 'includes the project with the right access level' do + expect(hash[project.id]).to eq(Gitlab::Access::MASTER) + end + end + + context 'projects the user is a member of' do + let!(:other_project) { create(:empty_project) } + + before do + other_project.team.add_reporter(user) + end + + it 'includes the project with the right access level' do + expect(hash[other_project.id]).to eq(Gitlab::Access::REPORTER) + end + end + + context 'projects of groups the user is a member of' do + let(:group) { create(:group) } + let!(:other_project) { create(:project, group: group) } + + before do + group.add_owner(user) + end + + it 'includes the project with the right access level' do + expect(hash[other_project.id]).to eq(Gitlab::Access::OWNER) + end + end + + context 'projects of subgroups of groups the user is a member of' do + let(:group) { create(:group) } + let(:nested_group) { create(:group, parent: group) } + let!(:other_project) { create(:project, group: nested_group) } + + before do + group.add_master(user) + end + + it 'includes the project with the right access level' do + expect(hash[other_project.id]).to eq(Gitlab::Access::MASTER) + end + end + + context 'projects shared with groups the user is a member of' do + let(:group) { create(:group) } + let(:other_project) { create(:empty_project) } + let!(:project_group_link) { create(:project_group_link, project: other_project, group: group, group_access: Gitlab::Access::GUEST) } + + before do + group.add_master(user) + end + + it 'includes the project with the right access level' do + expect(hash[other_project.id]).to eq(Gitlab::Access::GUEST) + end + end + + context 'projects shared with subgroups of groups the user is a member of' do + let(:group) { create(:group) } + let(:nested_group) { create(:group, parent: group) } + let(:other_project) { create(:empty_project) } + let!(:project_group_link) { create(:project_group_link, project: other_project, group: nested_group, group_access: Gitlab::Access::DEVELOPER) } + + before do + group.add_master(user) + end + + it 'includes the project with the right access level' do + expect(hash[other_project.id]).to eq(Gitlab::Access::DEVELOPER) + end + end end describe '#current_authorizations_per_project' do diff --git a/spec/simplecov_env.rb b/spec/simplecov_env.rb index b507d38f472..ac2c89b3ff9 100644 --- a/spec/simplecov_env.rb +++ b/spec/simplecov_env.rb @@ -15,9 +15,9 @@ module SimpleCovEnv def configure_job SimpleCov.configure do - if ENV['CI_BUILD_NAME'] - coverage_dir "coverage/#{ENV['CI_BUILD_NAME']}" - command_name ENV['CI_BUILD_NAME'] + if ENV['CI_JOB_NAME'] + coverage_dir "coverage/#{ENV['CI_JOB_NAME']}" + command_name ENV['CI_JOB_NAME'] end if ENV['CI'] diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 5fda7c63cdb..ceb3209331f 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -43,14 +43,27 @@ RSpec.configure do |config| config.include ActiveSupport::Testing::TimeHelpers config.include StubGitlabCalls config.include StubGitlabData + config.include ApiHelpers, :api config.infer_spec_type_from_file_location! + + config.define_derived_metadata(file_path: %r{/spec/requests/(ci/)?api/}) do |metadata| + metadata[:api] = true + end + config.raise_errors_for_deprecations! config.before(:suite) do TestEnv.init end + if ENV['CI'] + # Retry only on feature specs that use JS + config.around :each, :js do |ex| + ex.run_with_retry retry: 3 + end + end + config.around(:each, :caching) do |example| caching_store = Rails.cache Rails.cache = ActiveSupport::Cache::MemoryStore.new if example.metadata[:caching] diff --git a/spec/support/api/issues_resolving_discussions_shared_examples.rb b/spec/support/api/issues_resolving_discussions_shared_examples.rb new file mode 100644 index 00000000000..d26d279363c --- /dev/null +++ b/spec/support/api/issues_resolving_discussions_shared_examples.rb @@ -0,0 +1,15 @@ +shared_examples 'creating an issue resolving discussions through the API' do + it 'creates a new project issue' do + expect(response).to have_http_status(:created) + end + + it 'resolves the discussions in a merge request' do + discussion.first_note.reload + + expect(discussion.resolved?).to be(true) + end + + it 'assigns a description to the issue mentioning the merge request' do + expect(json_response['description']).to include(merge_request.to_reference) + end +end diff --git a/spec/support/api/time_tracking_shared_examples.rb b/spec/support/api/time_tracking_shared_examples.rb index 210cd5817e0..16a3cf06be7 100644 --- a/spec/support/api/time_tracking_shared_examples.rb +++ b/spec/support/api/time_tracking_shared_examples.rb @@ -7,13 +7,13 @@ shared_examples 'time tracking endpoints' do |issuable_name| describe "POST /projects/:id/#{issuable_collection_name}/:#{issuable_name}_id/time_estimate" do context 'with an unauthorized user' do - subject { post(api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/time_estimate", non_member), duration: '1w') } + subject { post(api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.iid}/time_estimate", non_member), duration: '1w') } it_behaves_like 'an unauthorized API user' end it "sets the time estimate for #{issuable_name}" do - post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/time_estimate", user), duration: '1w' + post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.iid}/time_estimate", user), duration: '1w' expect(response).to have_http_status(200) expect(json_response['human_time_estimate']).to eq('1w') @@ -21,12 +21,12 @@ shared_examples 'time tracking endpoints' do |issuable_name| describe 'updating the current estimate' do before do - post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/time_estimate", user), duration: '1w' + post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.iid}/time_estimate", user), duration: '1w' end context 'when duration has a bad format' do it 'does not modify the original estimate' do - post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/time_estimate", user), duration: 'foo' + post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.iid}/time_estimate", user), duration: 'foo' expect(response).to have_http_status(400) expect(issuable.reload.human_time_estimate).to eq('1w') @@ -35,7 +35,7 @@ shared_examples 'time tracking endpoints' do |issuable_name| context 'with a valid duration' do it 'updates the estimate' do - post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/time_estimate", user), duration: '3w1h' + post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.iid}/time_estimate", user), duration: '3w1h' expect(response).to have_http_status(200) expect(issuable.reload.human_time_estimate).to eq('3w 1h') @@ -46,13 +46,13 @@ shared_examples 'time tracking endpoints' do |issuable_name| describe "POST /projects/:id/#{issuable_collection_name}/:#{issuable_name}_id/reset_time_estimate" do context 'with an unauthorized user' do - subject { post(api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/reset_time_estimate", non_member)) } + subject { post(api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.iid}/reset_time_estimate", non_member)) } it_behaves_like 'an unauthorized API user' end it "resets the time estimate for #{issuable_name}" do - post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/reset_time_estimate", user) + post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.iid}/reset_time_estimate", user) expect(response).to have_http_status(200) expect(json_response['time_estimate']).to eq(0) @@ -62,7 +62,7 @@ shared_examples 'time tracking endpoints' do |issuable_name| describe "POST /projects/:id/#{issuable_collection_name}/:#{issuable_name}_id/add_spent_time" do context 'with an unauthorized user' do subject do - post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/add_spent_time", non_member), + post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.iid}/add_spent_time", non_member), duration: '2h' end @@ -70,7 +70,7 @@ shared_examples 'time tracking endpoints' do |issuable_name| end it "add spent time for #{issuable_name}" do - post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/add_spent_time", user), + post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.iid}/add_spent_time", user), duration: '2h' expect(response).to have_http_status(201) @@ -81,7 +81,7 @@ shared_examples 'time tracking endpoints' do |issuable_name| it 'subtracts time of the total spent time' do issuable.update_attributes!(spend_time: { duration: 7200, user: user }) - post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/add_spent_time", user), + post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.iid}/add_spent_time", user), duration: '-1h' expect(response).to have_http_status(201) @@ -93,7 +93,7 @@ shared_examples 'time tracking endpoints' do |issuable_name| it 'does not modify the total time spent' do issuable.update_attributes!(spend_time: { duration: 7200, user: user }) - post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/add_spent_time", user), + post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.iid}/add_spent_time", user), duration: '-1w' expect(response).to have_http_status(400) @@ -104,13 +104,13 @@ shared_examples 'time tracking endpoints' do |issuable_name| describe "POST /projects/:id/#{issuable_collection_name}/:#{issuable_name}_id/reset_spent_time" do context 'with an unauthorized user' do - subject { post(api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/reset_spent_time", non_member)) } + subject { post(api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.iid}/reset_spent_time", non_member)) } it_behaves_like 'an unauthorized API user' end it "resets spent time for #{issuable_name}" do - post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/reset_spent_time", user) + post api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.iid}/reset_spent_time", user) expect(response).to have_http_status(200) expect(json_response['total_time_spent']).to eq(0) @@ -122,7 +122,7 @@ shared_examples 'time tracking endpoints' do |issuable_name| issuable.update_attributes!(spend_time: { duration: 1800, user: user }, time_estimate: 3600) - get api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/time_stats", user) + get api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.iid}/time_stats", user) expect(response).to have_http_status(200) expect(json_response['total_time_spent']).to eq(1800) diff --git a/spec/support/api/v3/time_tracking_shared_examples.rb b/spec/support/api/v3/time_tracking_shared_examples.rb new file mode 100644 index 00000000000..f982b10d999 --- /dev/null +++ b/spec/support/api/v3/time_tracking_shared_examples.rb @@ -0,0 +1,128 @@ +shared_examples 'V3 time tracking endpoints' do |issuable_name| + issuable_collection_name = issuable_name.pluralize + + describe "POST /projects/:id/#{issuable_collection_name}/:#{issuable_name}_id/time_estimate" do + context 'with an unauthorized user' do + subject { post(v3_api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/time_estimate", non_member), duration: '1w') } + + it_behaves_like 'an unauthorized API user' + end + + it "sets the time estimate for #{issuable_name}" do + post v3_api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/time_estimate", user), duration: '1w' + + expect(response).to have_http_status(200) + expect(json_response['human_time_estimate']).to eq('1w') + end + + describe 'updating the current estimate' do + before do + post v3_api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/time_estimate", user), duration: '1w' + end + + context 'when duration has a bad format' do + it 'does not modify the original estimate' do + post v3_api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/time_estimate", user), duration: 'foo' + + expect(response).to have_http_status(400) + expect(issuable.reload.human_time_estimate).to eq('1w') + end + end + + context 'with a valid duration' do + it 'updates the estimate' do + post v3_api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/time_estimate", user), duration: '3w1h' + + expect(response).to have_http_status(200) + expect(issuable.reload.human_time_estimate).to eq('3w 1h') + end + end + end + end + + describe "POST /projects/:id/#{issuable_collection_name}/:#{issuable_name}_id/reset_time_estimate" do + context 'with an unauthorized user' do + subject { post(v3_api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/reset_time_estimate", non_member)) } + + it_behaves_like 'an unauthorized API user' + end + + it "resets the time estimate for #{issuable_name}" do + post v3_api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/reset_time_estimate", user) + + expect(response).to have_http_status(200) + expect(json_response['time_estimate']).to eq(0) + end + end + + describe "POST /projects/:id/#{issuable_collection_name}/:#{issuable_name}_id/add_spent_time" do + context 'with an unauthorized user' do + subject do + post v3_api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/add_spent_time", non_member), + duration: '2h' + end + + it_behaves_like 'an unauthorized API user' + end + + it "add spent time for #{issuable_name}" do + post v3_api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/add_spent_time", user), + duration: '2h' + + expect(response).to have_http_status(201) + expect(json_response['human_total_time_spent']).to eq('2h') + end + + context 'when subtracting time' do + it 'subtracts time of the total spent time' do + issuable.update_attributes!(spend_time: { duration: 7200, user: user }) + + post v3_api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/add_spent_time", user), + duration: '-1h' + + expect(response).to have_http_status(201) + expect(json_response['total_time_spent']).to eq(3600) + end + end + + context 'when time to subtract is greater than the total spent time' do + it 'does not modify the total time spent' do + issuable.update_attributes!(spend_time: { duration: 7200, user: user }) + + post v3_api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/add_spent_time", user), + duration: '-1w' + + expect(response).to have_http_status(400) + expect(json_response['message']['time_spent'].first).to match(/exceeds the total time spent/) + end + end + end + + describe "POST /projects/:id/#{issuable_collection_name}/:#{issuable_name}_id/reset_spent_time" do + context 'with an unauthorized user' do + subject { post(v3_api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/reset_spent_time", non_member)) } + + it_behaves_like 'an unauthorized API user' + end + + it "resets spent time for #{issuable_name}" do + post v3_api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/reset_spent_time", user) + + expect(response).to have_http_status(200) + expect(json_response['total_time_spent']).to eq(0) + end + end + + describe "GET /projects/:id/#{issuable_collection_name}/:#{issuable_name}_id/time_stats" do + it "returns the time stats for #{issuable_name}" do + issuable.update_attributes!(spend_time: { duration: 1800, user: user }, + time_estimate: 3600) + + get v3_api("/projects/#{project.id}/#{issuable_collection_name}/#{issuable.id}/time_stats", user) + + expect(response).to have_http_status(200) + expect(json_response['total_time_spent']).to eq(1800) + expect(json_response['time_estimate']).to eq(3600) + end + end +end diff --git a/spec/support/api_helpers.rb b/spec/support/api_helpers.rb index ae6e708cf87..35d1e1cfc7d 100644 --- a/spec/support/api_helpers.rb +++ b/spec/support/api_helpers.rb @@ -49,8 +49,4 @@ module ApiHelpers '' end end - - def json_response - @_json_response ||= JSON.parse(response.body) - end end diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb index 16d5f2bf0b8..aa14709bc9c 100644 --- a/spec/support/capybara.rb +++ b/spec/support/capybara.rb @@ -1,9 +1,10 @@ require 'capybara/rails' require 'capybara/rspec' require 'capybara/poltergeist' +require 'capybara-screenshot/rspec' # Give CI some extra time -timeout = (ENV['CI'] || ENV['CI_SERVER']) ? 90 : 10 +timeout = (ENV['CI'] || ENV['CI_SERVER']) ? 30 : 10 Capybara.javascript_driver = :poltergeist Capybara.register_driver :poltergeist do |app| @@ -21,12 +22,8 @@ end Capybara.default_max_wait_time = timeout Capybara.ignore_hidden_elements = true -unless ENV['CI'] || ENV['CI_SERVER'] - require 'capybara-screenshot/rspec' - - # Keep only the screenshots generated from the last failing test suite - Capybara::Screenshot.prune_strategy = :keep_last_run -end +# Keep only the screenshots generated from the last failing test suite +Capybara::Screenshot.prune_strategy = :keep_last_run RSpec.configure do |config| config.before(:suite) do diff --git a/spec/support/carrierwave.rb b/spec/support/carrierwave.rb index 72af2c70324..b4b016e408f 100644 --- a/spec/support/carrierwave.rb +++ b/spec/support/carrierwave.rb @@ -1,7 +1,7 @@ -CarrierWave.root = 'tmp/tests/uploads' +CarrierWave.root = File.expand_path('tmp/tests/public', Rails.root) RSpec.configure do |config| config.after(:each) do - FileUtils.rm_rf('tmp/tests/uploads') + FileUtils.rm_rf(CarrierWave.root) end end diff --git a/spec/support/cycle_analytics_helpers.rb b/spec/support/cycle_analytics_helpers.rb index 6ed55289ed9..c864a705ca4 100644 --- a/spec/support/cycle_analytics_helpers.rb +++ b/spec/support/cycle_analytics_helpers.rb @@ -9,14 +9,7 @@ module CycleAnalyticsHelpers commit_shas = Array.new(count) do |index| filename = random_git_name - options = { - committer: project.repository.user_to_committer(user), - author: project.repository.user_to_committer(user), - commit: { message: message, branch: branch_name, update_ref: true }, - file: { content: "content", path: filename, update: false } - } - - commit_sha = Gitlab::Git::Blob.commit(project.repository, options) + commit_sha = project.repository.create_file(user, filename, "content", message: message, branch_name: branch_name) project.repository.commit(commit_sha) commit_sha @@ -35,13 +28,12 @@ module CycleAnalyticsHelpers project.repository.add_branch(user, source_branch, 'master') end - sha = project.repository.commit_file( + sha = project.repository.create_file( user, random_git_name, 'content', message: 'commit message', - branch_name: source_branch, - update: false) + branch_name: source_branch) project.repository.commit(sha) opts = { diff --git a/spec/support/dropzone_helper.rb b/spec/support/dropzone_helper.rb new file mode 100644 index 00000000000..984ec7d2741 --- /dev/null +++ b/spec/support/dropzone_helper.rb @@ -0,0 +1,37 @@ +module DropzoneHelper + # Provides a way to perform `attach_file` for a Dropzone-based file input + # + # This is accomplished by creating a standard HTML file input on the page, + # performing `attach_file` on that field, and then triggering the appropriate + # Dropzone events to perform the actual upload. + # + # This method waits for the upload to complete before returning. + def dropzone_file(file_path) + # Generate a fake file input that Capybara can attach to + page.execute_script <<-JS.strip_heredoc + var fakeFileInput = window.$('<input/>').attr( + {id: 'fakeFileInput', type: 'file'} + ).appendTo('body'); + + window._dropzoneComplete = false; + JS + + # Attach the file to the fake input selector with Capybara + attach_file('fakeFileInput', file_path) + + # Manually trigger a Dropzone "drop" event with the fake input's file list + page.execute_script <<-JS.strip_heredoc + var fileList = [$('#fakeFileInput')[0].files[0]]; + var e = jQuery.Event('drop', { dataTransfer : { files : fileList } }); + + var dropzone = $('.div-dropzone')[0].dropzone; + dropzone.on('queuecomplete', function() { + window._dropzoneComplete = true; + }); + dropzone.listeners[0].events.drop(e); + JS + + # Wait until Dropzone's fired `queuecomplete` + loop until page.evaluate_script('window._dropzoneComplete === true') + end +end diff --git a/spec/support/features/resolving_discussions_in_issues_shared_examples.rb b/spec/support/features/resolving_discussions_in_issues_shared_examples.rb new file mode 100644 index 00000000000..4a946995f84 --- /dev/null +++ b/spec/support/features/resolving_discussions_in_issues_shared_examples.rb @@ -0,0 +1,41 @@ +shared_examples 'creating an issue for a discussion' do + it 'shows an issue with the title filled in' do + title_field = page.find_field('issue[title]') + + expect(title_field.value).to include(merge_request.title) + end + + it 'has a mention of the discussion in the description' do + description_field = page.find_field('issue[description]') + + expect(description_field.value).to include(discussion.first_note.note) + end + + it 'can create a new issue for the project' do + expect { click_button 'Submit issue' }.to change { project.issues.reload.size }.by(1) + end + + it 'resolves the discussion in the merge request' do + click_button 'Submit issue' + + discussion.first_note.reload + + expect(discussion.resolved?).to eq(true) + end + + it 'shows a flash messaage after resolving a discussion' do + click_button 'Submit issue' + + page.within '.flash-notice' do + # Only check for the word 'Resolved' since the spec might have resolved + # multiple discussions + expect(page).to have_content('Resolved') + end + end + + it 'has a hidden field for the merge request' do + merge_request_field = find('#merge_request_to_resolve_discussions_of', visible: false) + + expect(merge_request_field.value).to eq(merge_request.iid.to_s) + end +end diff --git a/spec/support/features/rss_shared_examples.rb b/spec/support/features/rss_shared_examples.rb new file mode 100644 index 00000000000..9a3b0a731ad --- /dev/null +++ b/spec/support/features/rss_shared_examples.rb @@ -0,0 +1,23 @@ +shared_examples "an autodiscoverable RSS feed with current_user's private token" do + it "has an RSS autodiscovery link tag with current_user's private token" do + expect(page).to have_css("link[type*='atom+xml'][href*='private_token=#{Thread.current[:current_user].private_token}']", visible: false) + end +end + +shared_examples "it has an RSS button with current_user's private token" do + it "shows the RSS button with current_user's private token" do + expect(page).to have_css("a:has(.fa-rss)[href*='private_token=#{Thread.current[:current_user].private_token}']") + end +end + +shared_examples "an autodiscoverable RSS feed without a private token" do + it "has an RSS autodiscovery link tag without a private token" do + expect(page).to have_css("link[type*='atom+xml']:not([href*='private_token'])", visible: false) + end +end + +shared_examples "it has an RSS button without a private token" do + it "shows the RSS button without a private token" do + expect(page).to have_css("a:has(.fa-rss):not([href*='private_token'])") + end +end diff --git a/spec/support/filtered_search_helpers.rb b/spec/support/filtered_search_helpers.rb index 58f6636e680..6b009b132b6 100644 --- a/spec/support/filtered_search_helpers.rb +++ b/spec/support/filtered_search_helpers.rb @@ -3,16 +3,25 @@ module FilteredSearchHelpers page.find('.filtered-search') end - def input_filtered_search(search_term, submit: true) - filtered_search.set(search_term) + # Enables input to be set (similar to copy and paste) + def input_filtered_search(search_term, submit: true, extra_space: true) + search = search_term + if extra_space + # Add an extra space to engage visual tokens + search = "#{search_term} " + end + + filtered_search.set(search) if submit filtered_search.send_keys(:enter) end end + # Enables input to be added character by character def input_filtered_search_keys(search_term) - filtered_search.send_keys(search_term) + # Add an extra space to engage visual tokens + filtered_search.send_keys("#{search_term} ") filtered_search.send_keys(:enter) end @@ -34,4 +43,32 @@ module FilteredSearchHelpers # This ensures the dropdown is shown expect(find('#js-dropdown-label')).not_to have_css('.filter-dropdown-loading') end + + def expect_filtered_search_input_empty + expect(find('.filtered-search').value).to eq('') + end + + # Iterates through each visual token inside + # .tokens-container to make sure the correct names and values are rendered + def expect_tokens(tokens) + page.find '.filtered-search-input-container .tokens-container' do + page.all(:css, '.tokens-container li').each_with_index do |el, index| + token_name = tokens[index][:name] + token_value = tokens[index][:value] + + expect(el.find('.name')).to have_content(token_name) + if token_value + expect(el.find('.value')).to have_content(token_value) + end + end + end + end + + def default_placeholder + 'Search or filter results...' + end + + def get_filtered_search_placeholder + find('.filtered-search')['placeholder'] + end end diff --git a/spec/support/issuables_list_metadata_shared_examples.rb b/spec/support/issuables_list_metadata_shared_examples.rb index dac94dfc31e..4c0f556e736 100644 --- a/spec/support/issuables_list_metadata_shared_examples.rb +++ b/spec/support/issuables_list_metadata_shared_examples.rb @@ -3,11 +3,12 @@ shared_examples 'issuables list meta-data' do |issuable_type, action = nil| @issuable_ids = [] 2.times do - if issuable_type == :issue - issuable = create(issuable_type, project: project) - else - issuable = create(issuable_type, title: FFaker::Lorem.sentence, source_project: project, source_branch: FFaker::Name.name) - end + issuable = + if issuable_type == :issue + create(issuable_type, project: project) + else + create(issuable_type, title: FFaker::Lorem.sentence, source_project: project, source_branch: FFaker::Name.name) + end @issuable_ids << issuable.id @@ -21,7 +22,7 @@ shared_examples 'issuables list meta-data' do |issuable_type, action = nil| if action get action else - get :index, namespace_id: project.namespace.path, project_id: project.path + get :index, namespace_id: project.namespace, project_id: project end meta_data = assigns(:issuable_meta_data) diff --git a/spec/support/javascript_fixtures_helpers.rb b/spec/support/javascript_fixtures_helpers.rb index 0b8729db0f9..a982b159b48 100644 --- a/spec/support/javascript_fixtures_helpers.rb +++ b/spec/support/javascript_fixtures_helpers.rb @@ -5,7 +5,7 @@ require 'gitlab/popen' module JavaScriptFixturesHelpers include Gitlab::Popen - FIXTURE_PATH = 'spec/javascripts/fixtures' + FIXTURE_PATH = 'spec/javascripts/fixtures'.freeze # Public: Removes all fixture files from given directory # diff --git a/spec/support/jira_service_helper.rb b/spec/support/jira_service_helper.rb index 929fc0c5182..97ae0b6afc5 100644 --- a/spec/support/jira_service_helper.rb +++ b/spec/support/jira_service_helper.rb @@ -1,5 +1,5 @@ module JiraServiceHelper - JIRA_URL = "http://jira.example.net" + JIRA_URL = "http://jira.example.net".freeze JIRA_API = JIRA_URL + "/rest/api/2" def jira_service_settings diff --git a/spec/support/json_response_helpers.rb b/spec/support/json_response_helpers.rb new file mode 100644 index 00000000000..e8d2ef2d7f0 --- /dev/null +++ b/spec/support/json_response_helpers.rb @@ -0,0 +1,9 @@ +shared_context 'JSON response' do + let(:json_response) { JSON.parse(response.body) } +end + +RSpec.configure do |config| + config.include_context 'JSON response', type: :controller + config.include_context 'JSON response', type: :request + config.include_context 'JSON response', :api +end diff --git a/spec/support/kubernetes_helpers.rb b/spec/support/kubernetes_helpers.rb index 444612cf871..b5ed71ba3be 100644 --- a/spec/support/kubernetes_helpers.rb +++ b/spec/support/kubernetes_helpers.rb @@ -2,23 +2,24 @@ module KubernetesHelpers include Gitlab::Kubernetes def kube_discovery_body - { "kind" => "APIResourceList", + { + "kind" => "APIResourceList", "resources" => [ { "name" => "pods", "namespaced" => true, "kind" => "Pod" }, - ], + ] } end def kube_pods_body(*pods) { "kind" => "PodList", - "items" => [ kube_pod ], - } + "items" => [kube_pod] } end # This is a partial response, it will have many more elements in reality but # these are the ones we care about at the moment def kube_pod(app: "valid-pod-label") - { "metadata" => { + { + "metadata" => { "name" => "kube-pod", "creationTimestamp" => "2016-11-25T19:55:19Z", "labels" => { "app" => app }, @@ -29,7 +30,7 @@ module KubernetesHelpers { "name" => "container-1" }, ], }, - "status" => { "phase" => "Running" }, + "status" => { "phase" => "Running" } } end diff --git a/spec/support/login_helpers.rb b/spec/support/login_helpers.rb index ad1eed5b369..9ffb00be0b8 100644 --- a/spec/support/login_helpers.rb +++ b/spec/support/login_helpers.rb @@ -15,11 +15,12 @@ module LoginHelpers # user = create(:user) # login_as(user) def login_as(user_or_role) - if user_or_role.kind_of?(User) - @user = user_or_role - else - @user = create(user_or_role) - end + @user = + if user_or_role.is_a?(User) + user_or_role + else + create(user_or_role) + end login_with(@user) end diff --git a/spec/support/markdown_feature.rb b/spec/support/markdown_feature.rb index a79386b5db9..dea0015f105 100644 --- a/spec/support/markdown_feature.rb +++ b/spec/support/markdown_feature.rb @@ -79,8 +79,8 @@ class MarkdownFeature def xproject @xproject ||= begin - namespace = create(:namespace, name: 'cross-reference') - create(:project, namespace: namespace) do |project| + group = create(:group, :nested) + create(:project, namespace: group) do |project| project.team << [user, :developer] end end diff --git a/spec/support/matchers/access_matchers.rb b/spec/support/matchers/access_matchers.rb index ceddb656596..7d238850520 100644 --- a/spec/support/matchers/access_matchers.rb +++ b/spec/support/matchers/access_matchers.rb @@ -38,7 +38,7 @@ module AccessMatchers end def description_for(user, type) - if user.kind_of?(User) + if user.is_a?(User) # User#inspect displays too much information for RSpec's descriptions "be #{type} for the specified user" else diff --git a/spec/support/matchers/email_matchers.rb b/spec/support/matchers/email_matchers.rb new file mode 100644 index 00000000000..d9d59ec12ec --- /dev/null +++ b/spec/support/matchers/email_matchers.rb @@ -0,0 +1,5 @@ +RSpec::Matchers.define :have_html_escaped_body_text do |expected| + match do |actual| + expect(actual).to have_body_text(ERB::Util.html_escape(expected)) + end +end diff --git a/spec/support/matchers/gitaly_matchers.rb b/spec/support/matchers/gitaly_matchers.rb new file mode 100644 index 00000000000..d7a53820684 --- /dev/null +++ b/spec/support/matchers/gitaly_matchers.rb @@ -0,0 +1,3 @@ +RSpec::Matchers.define :post_receive_request_with_repo_path do |path| + match { |actual| actual.repository.path == path } +end diff --git a/spec/support/matchers/markdown_matchers.rb b/spec/support/matchers/markdown_matchers.rb index 97b8b342eb2..bbbbaf4c5e8 100644 --- a/spec/support/matchers/markdown_matchers.rb +++ b/spec/support/matchers/markdown_matchers.rb @@ -26,10 +26,11 @@ module MarkdownMatchers set_default_markdown_messages match do |actual| - expect(actual).to have_selector('img.emoji', count: 10) + expect(actual).to have_selector('gl-emoji', count: 10) - image = actual.at_css('img.emoji') - expect(image['src'].to_s).to start_with(Gitlab.config.gitlab.url + '/assets') + emoji_element = actual.at_css('gl-emoji') + expect(emoji_element['data-name'].to_s).not_to be_empty + expect(emoji_element['data-unicode-version'].to_s).not_to be_empty end end diff --git a/spec/support/project_features_apply_to_issuables_shared_examples.rb b/spec/support/project_features_apply_to_issuables_shared_examples.rb index 4621d17549b..f8b7d0527ba 100644 --- a/spec/support/project_features_apply_to_issuables_shared_examples.rb +++ b/spec/support/project_features_apply_to_issuables_shared_examples.rb @@ -18,7 +18,7 @@ shared_examples 'project features apply to issuables' do |klass| before do _ = issuable - login_as(user) + login_as(user) if user visit path end diff --git a/spec/support/prometheus_helpers.rb b/spec/support/prometheus_helpers.rb new file mode 100644 index 00000000000..a52d8f37d14 --- /dev/null +++ b/spec/support/prometheus_helpers.rb @@ -0,0 +1,117 @@ +module PrometheusHelpers + def prometheus_memory_query(environment_slug) + %{sum(container_memory_usage_bytes{container_name="app",environment="#{environment_slug}"})/1024/1024} + end + + def prometheus_cpu_query(environment_slug) + %{sum(rate(container_cpu_usage_seconds_total{container_name="app",environment="#{environment_slug}"}[2m]))} + end + + def prometheus_query_url(prometheus_query) + query = { query: prometheus_query }.to_query + + "https://prometheus.example.com/api/v1/query?#{query}" + end + + def prometheus_query_range_url(prometheus_query, start: 8.hours.ago) + query = { + query: prometheus_query, + start: start.to_f, + end: Time.now.utc.to_f, + step: 1.minute.to_i + }.to_query + + "https://prometheus.example.com/api/v1/query_range?#{query}" + end + + def stub_prometheus_request(url, body: {}, status: 200) + WebMock.stub_request(:get, url) + .to_return({ + status: status, + headers: { 'Content-Type' => 'application/json' }, + body: body.to_json + }) + end + + def stub_all_prometheus_requests(environment_slug, body: nil, status: 200) + stub_prometheus_request( + prometheus_query_url(prometheus_memory_query(environment_slug)), + status: status, + body: body || prometheus_value_body + ) + stub_prometheus_request( + prometheus_query_range_url(prometheus_memory_query(environment_slug)), + status: status, + body: body || prometheus_values_body + ) + stub_prometheus_request( + prometheus_query_url(prometheus_cpu_query(environment_slug)), + status: status, + body: body || prometheus_value_body + ) + stub_prometheus_request( + prometheus_query_range_url(prometheus_cpu_query(environment_slug)), + status: status, + body: body || prometheus_values_body + ) + end + + def prometheus_data(last_update: Time.now.utc) + { + success: true, + metrics: { + memory_values: prometheus_values_body('matrix').dig(:data, :result), + memory_current: prometheus_value_body('vector').dig(:data, :result), + cpu_values: prometheus_values_body('matrix').dig(:data, :result), + cpu_current: prometheus_value_body('vector').dig(:data, :result) + }, + last_update: last_update + } + end + + def prometheus_empty_body(type) + { + "status": "success", + "data": { + "resultType": type, + "result": [] + } + } + end + + def prometheus_value_body(type = 'vector') + { + "status": "success", + "data": { + "resultType": type, + "result": [ + { + "metric": {}, + "value": [ + 1488772511.004, + "0.000041021495238095323" + ] + } + ] + } + } + end + + def prometheus_values_body(type = 'matrix') + { + "status": "success", + "data": { + "resultType": type, + "result": [ + { + "metric": {}, + "values": [ + [1488758662.506, "0.00002996364761904785"], + [1488758722.506, "0.00003090239047619091"] + ] + } + ] + } + } + end +end diff --git a/spec/support/repo_helpers.rb b/spec/support/repo_helpers.rb index 73f375c481b..e9d5c7b12ae 100644 --- a/spec/support/repo_helpers.rb +++ b/spec/support/repo_helpers.rb @@ -42,7 +42,7 @@ Signed-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com> eos ) end - + def another_sample_commit OpenStruct.new( id: "e56497bb5f03a90a51293fc6d516788730953899", @@ -100,13 +100,13 @@ eos } ] - commits = [ - '5937ac0a7beb003549fc5fd26fc247adbce4a52e', - '570e7b2abdd848b95f2f578043fc23bd6f6fd24d', - '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9', - 'd14d6c0abdd253381df51a723d58691b2ee1ab08', - 'c1acaa58bbcbc3eafe538cb8274ba387047b69f8', - ].reverse # last commit is recent one + commits = %w( + 5937ac0a7beb003549fc5fd26fc247adbce4a52e + 570e7b2abdd848b95f2f578043fc23bd6f6fd24d + 6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9 + d14d6c0abdd253381df51a723d58691b2ee1ab08 + c1acaa58bbcbc3eafe538cb8274ba387047b69f8 + ).reverse # last commit is recent one OpenStruct.new( source_branch: 'master', diff --git a/spec/support/seed_helper.rb b/spec/support/seed_helper.rb index 03fa0a66b9a..f55fee28ff9 100644 --- a/spec/support/seed_helper.rb +++ b/spec/support/seed_helper.rb @@ -7,7 +7,7 @@ TEST_MUTABLE_REPO_PATH = File.join(SEED_REPOSITORY_PATH, "mutable-repo.git") TEST_BROKEN_REPO_PATH = File.join(SEED_REPOSITORY_PATH, "broken-repo.git") module SeedHelper - GITLAB_URL = "https://gitlab.com/gitlab-org/gitlab-git-test.git" + GITLAB_GIT_TEST_REPO_URL = ENV.fetch('GITLAB_GIT_TEST_REPO_URL', 'https://gitlab.com/gitlab-org/gitlab-git-test.git').freeze def ensure_seeds if File.exist?(SEED_REPOSITORY_PATH) @@ -25,7 +25,7 @@ module SeedHelper end def create_bare_seeds - system(git_env, *%W(#{Gitlab.config.git.bin_path} clone --bare #{GITLAB_URL}), + system(git_env, *%W(#{Gitlab.config.git.bin_path} clone --bare #{GITLAB_GIT_TEST_REPO_URL}), chdir: SEED_REPOSITORY_PATH, out: '/dev/null', err: '/dev/null') @@ -45,7 +45,7 @@ module SeedHelper system(git_env, *%w(git branch -t feature origin/feature), chdir: TEST_MUTABLE_REPO_PATH, out: '/dev/null', err: '/dev/null') - system(git_env, *%W(#{Gitlab.config.git.bin_path} remote add expendable #{GITLAB_URL}), + system(git_env, *%W(#{Gitlab.config.git.bin_path} remote add expendable #{GITLAB_GIT_TEST_REPO_URL}), chdir: TEST_MUTABLE_REPO_PATH, out: '/dev/null', err: '/dev/null') end diff --git a/spec/support/seed_repo.rb b/spec/support/seed_repo.rb index 9f2cd7c67c5..99a500bbbb1 100644 --- a/spec/support/seed_repo.rb +++ b/spec/support/seed_repo.rb @@ -25,64 +25,64 @@ module SeedRepo module BigCommit - ID = "913c66a37b4a45b9769037c55c2d238bd0942d2e" - PARENT_ID = "cfe32cf61b73a0d5e9f13e774abde7ff789b1660" - MESSAGE = "Files, encoding and much more" - AUTHOR_FULL_NAME = "Dmitriy Zaporozhets" + ID = "913c66a37b4a45b9769037c55c2d238bd0942d2e".freeze + PARENT_ID = "cfe32cf61b73a0d5e9f13e774abde7ff789b1660".freeze + MESSAGE = "Files, encoding and much more".freeze + AUTHOR_FULL_NAME = "Dmitriy Zaporozhets".freeze FILES_COUNT = 2 end module Commit - ID = "570e7b2abdd848b95f2f578043fc23bd6f6fd24d" - PARENT_ID = "6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9" - MESSAGE = "Change some files\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n" - AUTHOR_FULL_NAME = "Dmitriy Zaporozhets" - FILES = ["files/ruby/popen.rb", "files/ruby/regex.rb"] + ID = "570e7b2abdd848b95f2f578043fc23bd6f6fd24d".freeze + PARENT_ID = "6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9".freeze + MESSAGE = "Change some files\n\nSigned-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>\n".freeze + AUTHOR_FULL_NAME = "Dmitriy Zaporozhets".freeze + FILES = ["files/ruby/popen.rb", "files/ruby/regex.rb"].freeze FILES_COUNT = 2 - C_FILE_PATH = "files/ruby" - C_FILES = ["popen.rb", "regex.rb", "version_info.rb"] - BLOB_FILE = %{%h3= @key.title\n%hr\n%pre= @key.key\n.actions\n = link_to 'Remove', @key, :confirm => 'Are you sure?', :method => :delete, :class => \"btn danger delete-key\"\n\n\n} - BLOB_FILE_PATH = "app/views/keys/show.html.haml" + C_FILE_PATH = "files/ruby".freeze + C_FILES = ["popen.rb", "regex.rb", "version_info.rb"].freeze + BLOB_FILE = %{%h3= @key.title\n%hr\n%pre= @key.key\n.actions\n = link_to 'Remove', @key, :confirm => 'Are you sure?', :method => :delete, :class => \"btn danger delete-key\"\n\n\n}.freeze + BLOB_FILE_PATH = "app/views/keys/show.html.haml".freeze end module EmptyCommit - ID = "b0e52af38d7ea43cf41d8a6f2471351ac036d6c9" - PARENT_ID = "40f4a7a617393735a95a0bb67b08385bc1e7c66d" - MESSAGE = "Empty commit" - AUTHOR_FULL_NAME = "Rémy Coutable" - FILES = [] + ID = "b0e52af38d7ea43cf41d8a6f2471351ac036d6c9".freeze + PARENT_ID = "40f4a7a617393735a95a0bb67b08385bc1e7c66d".freeze + MESSAGE = "Empty commit".freeze + AUTHOR_FULL_NAME = "Rémy Coutable".freeze + FILES = [].freeze FILES_COUNT = FILES.count end module EncodingCommit - ID = "40f4a7a617393735a95a0bb67b08385bc1e7c66d" - PARENT_ID = "66028349a123e695b589e09a36634d976edcc5e8" - MESSAGE = "Add ISO-8859-encoded file" - AUTHOR_FULL_NAME = "Stan Hu" - FILES = ["encoding/iso8859.txt"] + ID = "40f4a7a617393735a95a0bb67b08385bc1e7c66d".freeze + PARENT_ID = "66028349a123e695b589e09a36634d976edcc5e8".freeze + MESSAGE = "Add ISO-8859-encoded file".freeze + AUTHOR_FULL_NAME = "Stan Hu".freeze + FILES = ["encoding/iso8859.txt"].freeze FILES_COUNT = FILES.count end module FirstCommit - ID = "1a0b36b3cdad1d2ee32457c102a8c0b7056fa863" + ID = "1a0b36b3cdad1d2ee32457c102a8c0b7056fa863".freeze PARENT_ID = nil - MESSAGE = "Initial commit" - AUTHOR_FULL_NAME = "Dmitriy Zaporozhets" - FILES = ["LICENSE", ".gitignore", "README.md"] + MESSAGE = "Initial commit".freeze + AUTHOR_FULL_NAME = "Dmitriy Zaporozhets".freeze + FILES = ["LICENSE", ".gitignore", "README.md"].freeze FILES_COUNT = 3 end module LastCommit - ID = "4b4918a572fa86f9771e5ba40fbd48e1eb03e2c6" - PARENT_ID = "0e1b353b348f8477bdbec1ef47087171c5032cd9" - MESSAGE = "Merge branch 'master' into 'master'" - AUTHOR_FULL_NAME = "Stan Hu" - FILES = ["bin/executable"] + ID = "4b4918a572fa86f9771e5ba40fbd48e1eb03e2c6".freeze + PARENT_ID = "0e1b353b348f8477bdbec1ef47087171c5032cd9".freeze + MESSAGE = "Merge branch 'master' into 'master'".freeze + AUTHOR_FULL_NAME = "Stan Hu".freeze + FILES = ["bin/executable"].freeze FILES_COUNT = FILES.count end module Repo - HEAD = "master" + HEAD = "master".freeze BRANCHES = %w[ feature fix @@ -93,14 +93,14 @@ module SeedRepo gitattributes-updated master merge-test - ] - TAGS = %w[v1.0.0 v1.1.0 v1.2.0 v1.2.1] + ].freeze + TAGS = %w[v1.0.0 v1.1.0 v1.2.0 v1.2.1].freeze end module RubyBlob - ID = "7e3e39ebb9b2bf433b4ad17313770fbe4051649c" - NAME = "popen.rb" - CONTENT = <<-eos + ID = "7e3e39ebb9b2bf433b4ad17313770fbe4051649c".freeze + NAME = "popen.rb".freeze + CONTENT = <<-eos.freeze require 'fileutils' require 'open3' diff --git a/spec/support/select2_helper.rb b/spec/support/select2_helper.rb index d30cc8ff9f2..0d526045012 100644 --- a/spec/support/select2_helper.rb +++ b/spec/support/select2_helper.rb @@ -12,7 +12,7 @@ module Select2Helper def select2(value, options = {}) - raise ArgumentError, 'options must be a Hash' unless options.kind_of?(Hash) + raise ArgumentError, 'options must be a Hash' unless options.is_a?(Hash) selector = options.fetch(:from) diff --git a/spec/support/services/issuable_create_service_slash_commands_shared_examples.rb b/spec/support/services/issuable_create_service_slash_commands_shared_examples.rb index c64574679b6..81d06dc2a3d 100644 --- a/spec/support/services/issuable_create_service_slash_commands_shared_examples.rb +++ b/spec/support/services/issuable_create_service_slash_commands_shared_examples.rb @@ -11,7 +11,7 @@ shared_examples 'new issuable record that supports slash commands' do let(:params) { base_params.merge(defined?(default_params) ? default_params : {}).merge(example_params) } let(:issuable) { described_class.new(project, user, params).execute } - before { project.team << [assignee, :master ] } + before { project.team << [assignee, :master] } context 'with labels in command only' do let(:example_params) do diff --git a/spec/support/stub_gitlab_calls.rb b/spec/support/stub_gitlab_calls.rb index 93f96cacc00..a01ef576234 100644 --- a/spec/support/stub_gitlab_calls.rb +++ b/spec/support/stub_gitlab_calls.rb @@ -35,7 +35,7 @@ module StubGitlabCalls { "tags" => tags } ) allow_any_instance_of(ContainerRegistry::Client).to receive(:repository_manifest).and_return( - JSON.load(File.read(Rails.root + 'spec/fixtures/container_registry/tag_manifest.json')) + JSON.parse(File.read(Rails.root + 'spec/fixtures/container_registry/tag_manifest.json')) ) allow_any_instance_of(ContainerRegistry::Client).to receive(:blob).and_return( File.read(Rails.root + 'spec/fixtures/container_registry/config_blob.json') diff --git a/spec/support/test_env.rb b/spec/support/test_env.rb index b87232a350b..f1d226b6ae3 100644 --- a/spec/support/test_env.rb +++ b/spec/support/test_env.rb @@ -38,7 +38,7 @@ module TestEnv 'deleted-image-test' => '6c17798', 'wip' => 'b9238ee', 'csv' => '3dd0896' - } + }.freeze # gitlab-test-fork is a fork of gitlab-fork, but we don't necessarily # need to keep all the branches in sync. @@ -48,7 +48,7 @@ module TestEnv 'master' => '5937ac0', 'remove-submodule' => '2a33e0c', 'conflict-resolvable-fork' => '404fa3f' - } + }.freeze # Test environment # @@ -135,7 +135,7 @@ module TestEnv def copy_repo(project) base_repo_path = File.expand_path(factory_repo_path_bare) - target_repo_path = File.expand_path(project.repository_storage_path + "/#{project.namespace.path}/#{project.path}.git") + target_repo_path = File.expand_path(project.repository_storage_path + "/#{project.full_path}.git") FileUtils.mkdir_p(target_repo_path) FileUtils.cp_r("#{base_repo_path}/.", target_repo_path) FileUtils.chmod_R 0755, target_repo_path @@ -143,7 +143,7 @@ module TestEnv end def repos_path - Gitlab.config.repositories.storages.default + Gitlab.config.repositories.storages.default['path'] end def backup_path @@ -152,7 +152,7 @@ module TestEnv def copy_forked_repo_with_submodules(project) base_repo_path = File.expand_path(forked_repo_path_bare) - target_repo_path = File.expand_path(project.repository_storage_path + "/#{project.namespace.path}/#{project.path}.git") + target_repo_path = File.expand_path(project.repository_storage_path + "/#{project.full_path}.git") FileUtils.mkdir_p(target_repo_path) FileUtils.cp_r("#{base_repo_path}/.", target_repo_path) FileUtils.chmod_R 0755, target_repo_path diff --git a/spec/support/unique_ip_check_shared_examples.rb b/spec/support/unique_ip_check_shared_examples.rb new file mode 100644 index 00000000000..7cf5a65eeed --- /dev/null +++ b/spec/support/unique_ip_check_shared_examples.rb @@ -0,0 +1,62 @@ +shared_context 'unique ips sign in limit' do + include StubENV + before(:each) do + Gitlab::Redis.with(&:flushall) + end + + before do + stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false') + + current_application_settings.update!( + unique_ips_limit_enabled: true, + unique_ips_limit_time_window: 10000 + ) + end + + def change_ip(ip) + allow(Gitlab::RequestContext).to receive(:client_ip).and_return(ip) + end + + def request_from_ip(ip) + change_ip(ip) + request + response + end + + def operation_from_ip(ip) + change_ip(ip) + operation + end +end + +shared_examples 'user login operation with unique ip limit' do + include_context 'unique ips sign in limit' do + before { current_application_settings.update!(unique_ips_limit_per_user: 1) } + + it 'allows user authenticating from the same ip' do + expect { operation_from_ip('ip') }.not_to raise_error + expect { operation_from_ip('ip') }.not_to raise_error + end + + it 'blocks user authenticating from two distinct ips' do + expect { operation_from_ip('ip') }.not_to raise_error + expect { operation_from_ip('ip2') }.to raise_error(Gitlab::Auth::TooManyIps) + end + end +end + +shared_examples 'user login request with unique ip limit' do |success_status = 200| + include_context 'unique ips sign in limit' do + before { current_application_settings.update!(unique_ips_limit_per_user: 1) } + + it 'allows user authenticating from the same ip' do + expect(request_from_ip('ip')).to have_http_status(success_status) + expect(request_from_ip('ip')).to have_http_status(success_status) + end + + it 'blocks user authenticating from two distinct ips' do + expect(request_from_ip('ip')).to have_http_status(success_status) + expect(request_from_ip('ip2')).to have_http_status(403) + end + end +end diff --git a/spec/support/update_invalid_issuable.rb b/spec/support/update_invalid_issuable.rb new file mode 100644 index 00000000000..365c34448ac --- /dev/null +++ b/spec/support/update_invalid_issuable.rb @@ -0,0 +1,57 @@ +shared_examples 'update invalid issuable' do |klass| + let(:params) do + { + namespace_id: project.namespace.path, + project_id: project.path, + id: issuable.iid + } + end + + let(:issuable) do + klass == Issue ? issue : merge_request + end + + before do + if klass == Issue + params.merge!(issue: { title: "any" }) + else + params.merge!(merge_request: { title: "any" }) + end + end + + context 'when updating causes conflicts' do + before do + allow_any_instance_of(issuable.class).to receive(:save). + and_raise(ActiveRecord::StaleObjectError.new(issuable, :save)) + end + + it 'renders edit when format is html' do + put :update, params + + expect(response).to render_template(:edit) + expect(assigns[:conflict]).to be_truthy + end + + it 'renders json error message when format is json' do + params[:format] = "json" + + put :update, params + + expect(response.status).to eq(409) + expect(JSON.parse(response.body)).to have_key('errors') + end + end + + context 'when updating an invalid issuable' do + before do + key = klass == Issue ? :issue : :merge_request + params[key][:title] = "" + end + + it 'renders edit when merge request is invalid' do + put :update, params + + expect(response).to render_template(:edit) + end + end +end diff --git a/spec/tasks/gitlab/backup_rake_spec.rb b/spec/tasks/gitlab/backup_rake_spec.rb index df8a47893f9..10458966cb9 100644 --- a/spec/tasks/gitlab/backup_rake_spec.rb +++ b/spec/tasks/gitlab/backup_rake_spec.rb @@ -108,7 +108,7 @@ describe 'gitlab:app namespace rake task' do $stdout = orig_stdout end - describe 'backup creation and deletion using annex and custom_hooks' do + describe 'backup creation and deletion using custom_hooks' do let(:project) { create(:project) } let(:user_backup_path) { "repositories/#{project.path_with_namespace}" } @@ -132,25 +132,6 @@ describe 'gitlab:app namespace rake task' do Dir.chdir(@origin_cd) end - context 'project uses git-annex and successfully creates backup' do - let(:filename) { "annex" } - - it 'creates annex.tar and project bundle' do - tar_contents, exit_status = Gitlab::Popen.popen(%W{tar -tvf #{@backup_tar}}) - - expect(exit_status).to eq(0) - expect(tar_contents).to match(user_backup_path) - expect(tar_contents).to match("#{user_backup_path}/annex.tar") - expect(tar_contents).to match("#{user_backup_path}.bundle") - end - - it 'restores files correctly' do - restore_backup - - expect(Dir.entries(File.join(project.repository.path, "annex"))).to include("dummy.txt") - end - end - context 'project uses custom_hooks and successfully creates backup' do let(:filename) { "custom_hooks" } @@ -246,8 +227,8 @@ describe 'gitlab:app namespace rake task' do FileUtils.mkdir('tmp/tests/default_storage') FileUtils.mkdir('tmp/tests/custom_storage') storages = { - 'default' => 'tmp/tests/default_storage', - 'custom' => 'tmp/tests/custom_storage' + 'default' => { 'path' => 'tmp/tests/default_storage' }, + 'custom' => { 'path' => 'tmp/tests/custom_storage' } } allow(Gitlab.config.repositories).to receive(:storages).and_return(storages) diff --git a/spec/tasks/gitlab/info_rake_spec.rb b/spec/tasks/gitlab/info_rake_spec.rb new file mode 100644 index 00000000000..ca74378a12a --- /dev/null +++ b/spec/tasks/gitlab/info_rake_spec.rb @@ -0,0 +1,37 @@ +require 'rake_helper' + +describe 'gitlab:env:info' do + before do + Rake.application.rake_require 'tasks/gitlab/info' + + stub_warn_user_is_not_gitlab + allow(Gitlab::Popen).to receive(:popen) + end + + describe 'git version' do + before do + allow(Gitlab::Popen).to receive(:popen).with([Gitlab.config.git.bin_path, '--version']) + .and_return(git_version) + end + + context 'when git installed' do + let(:git_version) { 'git version 2.10.0' } + + it 'prints git version' do + run_rake_task('gitlab:env:info') + + expect($stdout.string).to match(/Git Version:(.*)2.10.0/) + end + end + + context 'when git not installed' do + let(:git_version) { '' } + + it 'prints unknown' do + run_rake_task('gitlab:env:info') + + expect($stdout.string).to match(/Git Version:(.*)unknown/) + end + end + end +end diff --git a/spec/uploaders/attachment_uploader_spec.rb b/spec/uploaders/attachment_uploader_spec.rb index 6098be5cd45..ea714fb08f0 100644 --- a/spec/uploaders/attachment_uploader_spec.rb +++ b/spec/uploaders/attachment_uploader_spec.rb @@ -1,18 +1,17 @@ require 'spec_helper' describe AttachmentUploader do - let(:issue) { build(:issue) } - subject { described_class.new(issue) } + let(:uploader) { described_class.new(build_stubbed(:user)) } describe '#move_to_cache' do it 'is true' do - expect(subject.move_to_cache).to eq(true) + expect(uploader.move_to_cache).to eq(true) end end describe '#move_to_store' do it 'is true' do - expect(subject.move_to_store).to eq(true) + expect(uploader.move_to_store).to eq(true) end end end diff --git a/spec/uploaders/avatar_uploader_spec.rb b/spec/uploaders/avatar_uploader_spec.rb index 76f5a4b42ed..c4d558805ab 100644 --- a/spec/uploaders/avatar_uploader_spec.rb +++ b/spec/uploaders/avatar_uploader_spec.rb @@ -1,18 +1,17 @@ require 'spec_helper' describe AvatarUploader do - let(:user) { build(:user) } - subject { described_class.new(user) } + let(:uploader) { described_class.new(build_stubbed(:user)) } describe '#move_to_cache' do it 'is false' do - expect(subject.move_to_cache).to eq(false) + expect(uploader.move_to_cache).to eq(false) end end describe '#move_to_store' do it 'is false' do - expect(subject.move_to_store).to eq(false) + expect(uploader.move_to_store).to eq(false) end end end diff --git a/spec/uploaders/file_uploader_spec.rb b/spec/uploaders/file_uploader_spec.rb index 6a712e33c96..d9113ef4095 100644 --- a/spec/uploaders/file_uploader_spec.rb +++ b/spec/uploaders/file_uploader_spec.rb @@ -1,57 +1,56 @@ require 'spec_helper' describe FileUploader do - let(:project) { create(:project) } + let(:uploader) { described_class.new(build_stubbed(:empty_project)) } - before do - @previous_enable_processing = FileUploader.enable_processing - FileUploader.enable_processing = false - @uploader = FileUploader.new(project) - end - - after do - FileUploader.enable_processing = @previous_enable_processing - @uploader.remove! - end + describe '.absolute_path' do + it 'returns the correct absolute path by building it dynamically' do + project = build_stubbed(:project) + upload = double(model: project, path: 'secret/foo.jpg') - describe '#image_or_video?' do - context 'given an image file' do - before do - @uploader.store!(fixture_file_upload(Rails.root.join('spec', 'fixtures', 'rails_sample.jpg'))) - end + dynamic_segment = project.path_with_namespace - it 'detects an image based on file extension' do - expect(@uploader.image_or_video?).to be true - end + expect(described_class.absolute_path(upload)) + .to end_with("#{dynamic_segment}/secret/foo.jpg") end + end + + describe 'initialize' do + it 'generates a secret if none is provided' do + expect(SecureRandom).to receive(:hex).and_return('secret') - context 'given an video file' do - before do - video_file = fixture_file_upload(Rails.root.join('spec', 'fixtures', 'video_sample.mp4')) - @uploader.store!(video_file) - end + uploader = described_class.new(double) - it 'detects a video based on file extension' do - expect(@uploader.image_or_video?).to be true - end + expect(uploader.secret).to eq 'secret' end - it 'does not return image_or_video? for other types' do - @uploader.store!(fixture_file_upload(Rails.root.join('spec', 'fixtures', 'doc_sample.txt'))) + it 'accepts a secret parameter' do + expect(SecureRandom).not_to receive(:hex) - expect(@uploader.image_or_video?).to be false + uploader = described_class.new(double, 'secret') + + expect(uploader.secret).to eq 'secret' end end describe '#move_to_cache' do it 'is true' do - expect(@uploader.move_to_cache).to eq(true) + expect(uploader.move_to_cache).to eq(true) end end describe '#move_to_store' do it 'is true' do - expect(@uploader.move_to_store).to eq(true) + expect(uploader.move_to_store).to eq(true) + end + end + + describe '#relative_path' do + it 'removes the leading dynamic path segment' do + fixture = Rails.root.join('spec', 'fixtures', 'rails_sample.jpg') + uploader.store!(fixture_file_upload(fixture)) + + expect(uploader.relative_path).to match(/\A\h{32}\/rails_sample.jpg\z/) end end end diff --git a/spec/uploaders/records_uploads_spec.rb b/spec/uploaders/records_uploads_spec.rb new file mode 100644 index 00000000000..5c26e334a6e --- /dev/null +++ b/spec/uploaders/records_uploads_spec.rb @@ -0,0 +1,97 @@ +require 'rails_helper' + +describe RecordsUploads do + let(:uploader) do + class RecordsUploadsExampleUploader < GitlabUploader + include RecordsUploads + + storage :file + + def model + FactoryGirl.build_stubbed(:user) + end + end + + RecordsUploadsExampleUploader.new + end + + def upload_fixture(filename) + fixture_file_upload(Rails.root.join('spec', 'fixtures', filename)) + end + + describe 'callbacks' do + it 'calls `record_upload` after `store`' do + expect(uploader).to receive(:record_upload).once + + uploader.store!(upload_fixture('doc_sample.txt')) + end + + it 'calls `destroy_upload` after `remove`' do + expect(uploader).to receive(:destroy_upload).once + + uploader.store!(upload_fixture('doc_sample.txt')) + + uploader.remove! + end + end + + describe '#record_upload callback' do + it 'returns early when not using file storage' do + allow(uploader).to receive(:file_storage?).and_return(false) + expect(Upload).not_to receive(:record) + + uploader.store!(upload_fixture('rails_sample.jpg')) + end + + it "returns early when the file doesn't exist" do + allow(uploader).to receive(:file).and_return(double(exists?: false)) + expect(Upload).not_to receive(:record) + + uploader.store!(upload_fixture('rails_sample.jpg')) + end + + it 'creates an Upload record after store' do + expect(Upload).to receive(:record) + .with(uploader) + + uploader.store!(upload_fixture('rails_sample.jpg')) + end + + it 'it destroys Upload records at the same path before recording' do + existing = Upload.create!( + path: File.join('uploads', 'rails_sample.jpg'), + size: 512.kilobytes, + model: build_stubbed(:user), + uploader: uploader.class.to_s + ) + + uploader.store!(upload_fixture('rails_sample.jpg')) + + expect { existing.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect(Upload.count).to eq 1 + end + end + + describe '#destroy_upload callback' do + it 'returns early when not using file storage' do + uploader.store!(upload_fixture('rails_sample.jpg')) + + allow(uploader).to receive(:file_storage?).and_return(false) + expect(Upload).not_to receive(:remove_path) + + uploader.remove! + end + + it 'returns early when file is nil' do + expect(Upload).not_to receive(:remove_path) + + uploader.remove! + end + + it 'it destroys Upload records at the same path after removal' do + uploader.store!(upload_fixture('rails_sample.jpg')) + + expect { uploader.remove! }.to change { Upload.count }.from(1).to(0) + end + end +end diff --git a/spec/uploaders/uploader_helper_spec.rb b/spec/uploaders/uploader_helper_spec.rb new file mode 100644 index 00000000000..c47f09adb6d --- /dev/null +++ b/spec/uploaders/uploader_helper_spec.rb @@ -0,0 +1,37 @@ +require 'rails_helper' + +describe UploaderHelper do + let(:uploader) do + example_uploader = Class.new(CarrierWave::Uploader::Base) do + include UploaderHelper + + storage :file + end + + example_uploader.new + end + + def upload_fixture(filename) + fixture_file_upload(Rails.root.join('spec', 'fixtures', filename)) + end + + describe '#image_or_video?' do + it 'returns true for an image file' do + uploader.store!(upload_fixture('dk.png')) + + expect(uploader).to be_image_or_video + end + + it 'it returns true for a video file' do + uploader.store!(upload_fixture('video_sample.mp4')) + + expect(uploader).to be_image_or_video + end + + it 'returns false for other extensions' do + uploader.store!(upload_fixture('doc_sample.txt')) + + expect(uploader).not_to be_image_or_video + end + end +end diff --git a/spec/views/ci/status/_badge.html.haml_spec.rb b/spec/views/ci/status/_badge.html.haml_spec.rb new file mode 100644 index 00000000000..c62450fb8e2 --- /dev/null +++ b/spec/views/ci/status/_badge.html.haml_spec.rb @@ -0,0 +1,89 @@ +require 'spec_helper' + +describe 'ci/status/_badge', :view do + let(:user) { create(:user) } + let(:project) { create(:empty_project, :private) } + let(:pipeline) { create(:ci_pipeline, project: project) } + + context 'when rendering status for build' do + let(:build) do + create(:ci_build, :success, pipeline: pipeline) + end + + context 'when user has ability to see details' do + before do + project.add_developer(user) + end + + it 'has link to build details page' do + details_path = namespace_project_build_path( + project.namespace, project, build) + + render_status(build) + + expect(rendered).to have_link 'passed', href: details_path + end + end + + context 'when user do not have ability to see build details' do + before do + render_status(build) + end + + it 'contains build status text' do + expect(rendered).to have_content 'passed' + end + + it 'does not contain links' do + expect(rendered).not_to have_link 'passed' + end + end + end + + context 'when rendering status for external job' do + context 'when user has ability to see commit status details' do + before do + project.add_developer(user) + end + + context 'status has external target url' do + before do + external_job = create(:generic_commit_status, + status: :running, + pipeline: pipeline, + target_url: 'http://gitlab.com') + + render_status(external_job) + end + + it 'contains valid commit status text' do + expect(rendered).to have_content 'running' + end + + it 'has link to external status page' do + expect(rendered).to have_link 'running', href: 'http://gitlab.com' + end + end + + context 'status do not have external target url' do + before do + external_job = create(:generic_commit_status, status: :canceled) + + render_status(external_job) + end + + it 'contains valid commit status text' do + expect(rendered).to have_content 'canceled' + end + + it 'has link to external status page' do + expect(rendered).not_to have_link 'canceled' + end + end + end + end + + def render_status(resource) + render 'ci/status/badge', status: resource.detailed_status(user) + end +end diff --git a/spec/views/projects/builds/show.html.haml_spec.rb b/spec/views/projects/builds/show.html.haml_spec.rb index b6f6e7b7a2b..ec78ac30593 100644 --- a/spec/views/projects/builds/show.html.haml_spec.rb +++ b/spec/views/projects/builds/show.html.haml_spec.rb @@ -209,6 +209,10 @@ describe 'projects/builds/show', :view do it 'does not show retry button' do expect(rendered).not_to have_link('Retry') end + + it 'does not show New issue button' do + expect(rendered).not_to have_link('New issue') + end end context 'when job is not running' do @@ -220,6 +224,23 @@ describe 'projects/builds/show', :view do it 'shows retry button' do expect(rendered).to have_link('Retry') end + + context 'if build passed' do + it 'does not show New issue button' do + expect(rendered).not_to have_link('New issue') + end + end + + context 'if build failed' do + before do + build.status = 'failed' + render + end + + it 'shows New issue button' do + expect(rendered).to have_link('New issue') + end + end end describe 'commit title in sidebar' do @@ -248,4 +269,25 @@ describe 'projects/builds/show', :view do expect(rendered).to have_css('.js-build-value', visible: false, text: 'TRIGGER_VALUE_2') end end + + describe 'New issue button' do + before do + build.status = 'failed' + render + end + + it 'links to issues/new with the title and description filled in' do + title = "Build Failed ##{build.id}" + build_url = namespace_project_build_url(project.namespace, project, build) + href = new_namespace_project_issue_path( + project.namespace, + project, + issue: { + title: title, + description: build_url + } + ) + expect(rendered).to have_link('New issue', href: href) + end + end end diff --git a/spec/views/projects/commit/_commit_box.html.haml_spec.rb b/spec/views/projects/commit/_commit_box.html.haml_spec.rb index e741e3cf9b6..8bc344bfbf6 100644 --- a/spec/views/projects/commit/_commit_box.html.haml_spec.rb +++ b/spec/views/projects/commit/_commit_box.html.haml_spec.rb @@ -3,11 +3,13 @@ require 'spec_helper' describe 'projects/commit/_commit_box.html.haml' do include Devise::Test::ControllerHelpers + let(:user) { create(:user) } let(:project) { create(:project) } before do assign(:project, project) assign(:commit, project.commit) + allow(view).to receive(:can_collaborate_with_project?).and_return(false) end it 'shows the commit SHA' do @@ -23,6 +25,32 @@ describe 'projects/commit/_commit_box.html.haml' do render - expect(rendered).to have_text("Pipeline ##{third_pipeline.id} for #{Commit.truncate_sha(project.commit.sha)} failed") + expect(rendered).to have_text("Pipeline ##{third_pipeline.id} failed") + end + + context 'viewing a commit' do + context 'as a developer' do + before do + expect(view).to receive(:can_collaborate_with_project?).and_return(true) + end + + it 'has a link to create a new tag' do + render + + expect(rendered).to have_link('Tag') + end + end + + context 'as a non-developer' do + before do + expect(view).to receive(:can_collaborate_with_project?).and_return(false) + end + + it 'does not have a link to create a new tag' do + render + + expect(rendered).not_to have_link('Tag') + end + end end end diff --git a/spec/views/projects/pipelines/_stage.html.haml_spec.rb b/spec/views/projects/pipelines/_stage.html.haml_spec.rb index d25de8af5d2..65f9d0125e6 100644 --- a/spec/views/projects/pipelines/_stage.html.haml_spec.rb +++ b/spec/views/projects/pipelines/_stage.html.haml_spec.rb @@ -50,4 +50,23 @@ describe 'projects/pipelines/_stage', :view do expect(rendered).to have_text 'test:build', count: 1 end end + + context 'when there are multiple builds' do + before do + HasStatus::AVAILABLE_STATUSES.each do |status| + create_build(status) + end + end + + it 'shows them in order' do + render + + expect(rendered).to have_text(HasStatus::ORDERED_STATUSES.join(" ")) + end + + def create_build(status) + create(:ci_build, name: status, status: status, + pipeline: pipeline, stage: stage.name) + end + end end diff --git a/spec/workers/authorized_projects_worker_spec.rb b/spec/workers/authorized_projects_worker_spec.rb index 97c4bfcd248..bd5cc651c2b 100644 --- a/spec/workers/authorized_projects_worker_spec.rb +++ b/spec/workers/authorized_projects_worker_spec.rb @@ -1,12 +1,10 @@ require 'spec_helper' describe AuthorizedProjectsWorker do - let(:worker) { described_class.new } + let(:project) { create(:empty_project) } describe '.bulk_perform_and_wait' do it 'schedules the ids and waits for the jobs to complete' do - project = create(:project) - project.owner.project_authorizations.delete_all described_class.bulk_perform_and_wait([[project.owner.id]]) @@ -15,20 +13,37 @@ describe AuthorizedProjectsWorker do end end + describe '.bulk_perform_async' do + it "uses it's respective sidekiq queue" do + args = [[project.owner.id]] + push_bulk_args = { + 'class' => described_class, + 'queue' => described_class.sidekiq_options['queue'], + 'args' => args + } + + expect(Sidekiq::Client).to receive(:push_bulk).with(push_bulk_args).once + + described_class.bulk_perform_async(args) + end + end + describe '#perform' do + subject { described_class.new } + it "refreshes user's authorized projects" do user = create(:user) expect_any_instance_of(User).to receive(:refresh_authorized_projects) - worker.perform(user.id) + subject.perform(user.id) end context "when the user is not found" do it "does nothing" do expect_any_instance_of(User).not_to receive(:refresh_authorized_projects) - described_class.new.perform(-1) + subject.perform(-1) end end end diff --git a/spec/workers/build_email_worker_spec.rb b/spec/workers/build_email_worker_spec.rb deleted file mode 100644 index 542e674c150..00000000000 --- a/spec/workers/build_email_worker_spec.rb +++ /dev/null @@ -1,36 +0,0 @@ -require 'spec_helper' - -describe BuildEmailWorker do - include EmailHelpers - include RepoHelpers - - let(:build) { create(:ci_build) } - let(:user) { create(:user) } - let(:data) { Gitlab::DataBuilder::Build.build(build) } - - subject { BuildEmailWorker.new } - - before do - allow(build).to receive(:execute_hooks).and_return(false) - build.success - end - - describe "#perform" do - it "sends mail" do - subject.perform(build.id, [user.email], data.stringify_keys) - - email = ActionMailer::Base.deliveries.last - expect(email.subject).to include('Build success for') - expect(email.to).to eq([user.email]) - end - - it "gracefully handles an input SMTP error" do - reset_delivered_emails! - allow(Notify).to receive(:build_success_email).and_raise(Net::SMTPFatalError) - - subject.perform(build.id, [user.email], data.stringify_keys) - - expect(ActionMailer::Base.deliveries.count).to eq(0) - end - end -end diff --git a/spec/workers/git_garbage_collect_worker_spec.rb b/spec/workers/git_garbage_collect_worker_spec.rb index 5ef8cf1105b..a60af574a08 100644 --- a/spec/workers/git_garbage_collect_worker_spec.rb +++ b/spec/workers/git_garbage_collect_worker_spec.rb @@ -102,8 +102,8 @@ describe GitGarbageCollectWorker do new_commit_sha = Rugged::Commit.create( rugged, message: "hello world #{SecureRandom.hex(6)}", - author: Gitlab::Git::committer_hash(email: 'foo@bar', name: 'baz'), - committer: Gitlab::Git::committer_hash(email: 'foo@bar', name: 'baz'), + author: Gitlab::Git.committer_hash(email: 'foo@bar', name: 'baz'), + committer: Gitlab::Git.committer_hash(email: 'foo@bar', name: 'baz'), tree: old_commit.tree, parents: [old_commit], ) diff --git a/spec/workers/post_receive_spec.rb b/spec/workers/post_receive_spec.rb index 5919b99a6ed..7bcb5521202 100644 --- a/spec/workers/post_receive_spec.rb +++ b/spec/workers/post_receive_spec.rb @@ -105,6 +105,6 @@ describe PostReceive do end def pwd(project) - File.join(Gitlab.config.repositories.storages.default, project.path_with_namespace) + File.join(Gitlab.config.repositories.storages.default['path'], project.path_with_namespace) end end diff --git a/spec/workers/repository_fork_worker_spec.rb b/spec/workers/repository_fork_worker_spec.rb index 60605460adb..87521ae408e 100644 --- a/spec/workers/repository_fork_worker_spec.rb +++ b/spec/workers/repository_fork_worker_spec.rb @@ -15,24 +15,24 @@ describe RepositoryForkWorker do it "creates a new repository from a fork" do expect(shell).to receive(:fork_repository).with( '/test/path', - project.path_with_namespace, + project.full_path, project.repository_storage_path, - fork_project.namespace.path + fork_project.namespace.full_path ).and_return(true) subject.perform( project.id, '/test/path', - project.path_with_namespace, - fork_project.namespace.path) + project.full_path, + fork_project.namespace.full_path) end it 'flushes various caches' do expect(shell).to receive(:fork_repository).with( '/test/path', - project.path_with_namespace, + project.full_path, project.repository_storage_path, - fork_project.namespace.path + fork_project.namespace.full_path ).and_return(true) expect_any_instance_of(Repository).to receive(:expire_emptiness_caches). @@ -41,8 +41,8 @@ describe RepositoryForkWorker do expect_any_instance_of(Repository).to receive(:expire_exists_cache). and_call_original - subject.perform(project.id, '/test/path', project.path_with_namespace, - fork_project.namespace.path) + subject.perform(project.id, '/test/path', project.full_path, + fork_project.namespace.full_path) end it "handles bad fork" do @@ -53,8 +53,8 @@ describe RepositoryForkWorker do subject.perform( project.id, '/test/path', - project.path_with_namespace, - fork_project.namespace.path) + project.full_path, + fork_project.namespace.full_path) end end end diff --git a/spec/workers/repository_import_worker_spec.rb b/spec/workers/repository_import_worker_spec.rb index f1b1574abf4..c42f3147b7a 100644 --- a/spec/workers/repository_import_worker_spec.rb +++ b/spec/workers/repository_import_worker_spec.rb @@ -20,7 +20,7 @@ describe RepositoryImportWorker do context 'when the import has failed' do it 'hide the credentials that were used in the import URL' do - error = %Q{remote: Not Found fatal: repository 'https://user:pass@test.com/root/repoC.git/' not found } + error = %q{remote: Not Found fatal: repository 'https://user:pass@test.com/root/repoC.git/' not found } expect_any_instance_of(Projects::ImportService).to receive(:execute). and_return({ status: :error, message: error }) diff --git a/spec/workers/stuck_ci_builds_worker_spec.rb b/spec/workers/stuck_ci_builds_worker_spec.rb deleted file mode 100644 index 801fa31b45d..00000000000 --- a/spec/workers/stuck_ci_builds_worker_spec.rb +++ /dev/null @@ -1,57 +0,0 @@ -require "spec_helper" - -describe StuckCiBuildsWorker do - let!(:build) { create :ci_build } - let(:worker) { described_class.new } - - subject do - build.reload - build.status - end - - %w(pending running).each do |status| - context "#{status} build" do - before do - build.update!(status: status) - end - - it 'gets dropped if it was updated over 2 days ago' do - build.update!(updated_at: 2.days.ago) - worker.perform - is_expected.to eq('failed') - end - - it "is still #{status}" do - build.update!(updated_at: 1.minute.ago) - worker.perform - is_expected.to eq(status) - end - end - end - - %w(success failed canceled).each do |status| - context "#{status} build" do - before do - build.update!(status: status) - end - - it "is still #{status}" do - build.update!(updated_at: 2.days.ago) - worker.perform - is_expected.to eq(status) - end - end - end - - context "for deleted project" do - before do - build.update!(status: :running, updated_at: 2.days.ago) - build.project.update(pending_delete: true) - end - - it "does not drop build" do - expect_any_instance_of(Ci::Build).not_to receive(:drop) - worker.perform - end - end -end diff --git a/spec/workers/stuck_ci_jobs_worker_spec.rb b/spec/workers/stuck_ci_jobs_worker_spec.rb new file mode 100644 index 00000000000..8434b0c8e5b --- /dev/null +++ b/spec/workers/stuck_ci_jobs_worker_spec.rb @@ -0,0 +1,129 @@ +require 'spec_helper' + +describe StuckCiJobsWorker do + let!(:runner) { create :ci_runner } + let!(:job) { create :ci_build, runner: runner } + let(:worker) { described_class.new } + let(:exclusive_lease_uuid) { SecureRandom.uuid } + + subject do + job.reload + job.status + end + + before do + job.update!(status: status, updated_at: updated_at) + allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain).and_return(exclusive_lease_uuid) + end + + shared_examples 'job is dropped' do + it 'changes status' do + worker.perform + is_expected.to eq('failed') + end + end + + shared_examples 'job is unchanged' do + it "doesn't change status" do + worker.perform + is_expected.to eq(status) + end + end + + context 'when job is pending' do + let(:status) { 'pending' } + + context 'when job is not stuck' do + before { allow_any_instance_of(Ci::Build).to receive(:stuck?).and_return(false) } + + context 'when job was not updated for more than 1 day ago' do + let(:updated_at) { 2.days.ago } + it_behaves_like 'job is dropped' + end + + context 'when job was updated in less than 1 day ago' do + let(:updated_at) { 6.hours.ago } + it_behaves_like 'job is unchanged' + end + + context 'when job was not updated for more than 1 hour ago' do + let(:updated_at) { 2.hours.ago } + it_behaves_like 'job is unchanged' + end + end + + context 'when job is stuck' do + before { allow_any_instance_of(Ci::Build).to receive(:stuck?).and_return(true) } + + context 'when job was not updated for more than 1 hour ago' do + let(:updated_at) { 2.hours.ago } + it_behaves_like 'job is dropped' + end + + context 'when job was updated in less than 1 hour ago' do + let(:updated_at) { 30.minutes.ago } + it_behaves_like 'job is unchanged' + end + end + end + + context 'when job is running' do + let(:status) { 'running' } + + context 'when job was not updated for more than 1 hour ago' do + let(:updated_at) { 2.hours.ago } + it_behaves_like 'job is dropped' + end + + context 'when job was updated in less than 1 hour ago' do + let(:updated_at) { 30.minutes.ago } + it_behaves_like 'job is unchanged' + end + end + + %w(success skipped failed canceled).each do |status| + context "when job is #{status}" do + let(:status) { status } + let(:updated_at) { 2.days.ago } + it_behaves_like 'job is unchanged' + end + end + + context 'for deleted project' do + let(:status) { 'running' } + let(:updated_at) { 2.days.ago } + + before { job.project.update(pending_delete: true) } + + it 'does not drop job' do + expect_any_instance_of(Ci::Build).not_to receive(:drop) + worker.perform + end + end + + describe 'exclusive lease' do + let(:status) { 'running' } + let(:updated_at) { 2.days.ago } + let(:worker2) { described_class.new } + + it 'is guard by exclusive lease when executed concurrently' do + expect(worker).to receive(:drop).at_least(:once) + expect(worker2).not_to receive(:drop) + worker.perform + allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain).and_return(false) + worker2.perform + end + + it 'can be executed in sequence' do + expect(worker).to receive(:drop).at_least(:once) + expect(worker2).to receive(:drop).at_least(:once) + worker.perform + worker2.perform + end + + it 'cancels exclusive lease after worker perform' do + expect(Gitlab::ExclusiveLease).to receive(:cancel).with(described_class::EXCLUSIVE_LEASE_KEY, exclusive_lease_uuid) + worker.perform + end + end +end diff --git a/spec/workers/system_hook_push_worker_spec.rb b/spec/workers/system_hook_push_worker_spec.rb new file mode 100644 index 00000000000..b1d446ed25f --- /dev/null +++ b/spec/workers/system_hook_push_worker_spec.rb @@ -0,0 +1,19 @@ +require 'spec_helper' + +describe SystemHookPushWorker do + include RepoHelpers + + subject { described_class.new } + + describe '#perform' do + it 'executes SystemHooksService with expected values' do + push_data = double('push_data') + system_hook_service = double('system_hook_service') + + expect(SystemHooksService).to receive(:new).and_return(system_hook_service) + expect(system_hook_service).to receive(:execute_hooks).with(push_data, :push_hooks) + + subject.perform(push_data, :push_hooks) + end + end +end diff --git a/spec/workers/update_merge_requests_worker_spec.rb b/spec/workers/update_merge_requests_worker_spec.rb index c78a69eda67..262d6e5a9ab 100644 --- a/spec/workers/update_merge_requests_worker_spec.rb +++ b/spec/workers/update_merge_requests_worker_spec.rb @@ -23,16 +23,5 @@ describe UpdateMergeRequestsWorker do perform end - - it 'executes SystemHooksService with expected values' do - push_data = double('push_data') - expect(Gitlab::DataBuilder::Push).to receive(:build).with(project, user, oldrev, newrev, ref, []).and_return(push_data) - - system_hook_service = double('system_hook_service') - expect(SystemHooksService).to receive(:new).and_return(system_hook_service) - expect(system_hook_service).to receive(:execute_hooks).with(push_data, :push_hooks) - - perform - end end end diff --git a/spec/workers/upload_checksum_worker_spec.rb b/spec/workers/upload_checksum_worker_spec.rb new file mode 100644 index 00000000000..911360da66c --- /dev/null +++ b/spec/workers/upload_checksum_worker_spec.rb @@ -0,0 +1,19 @@ +require 'rails_helper' + +describe UploadChecksumWorker do + describe '#perform' do + it 'rescues ActiveRecord::RecordNotFound' do + expect { described_class.new.perform(999_999) }.not_to raise_error + end + + it 'calls calculate_checksum_without_delay and save!' do + upload = spy + expect(Upload).to receive(:find).with(999_999).and_return(upload) + + described_class.new.perform(999_999) + + expect(upload).to have_received(:calculate_checksum) + expect(upload).to have_received(:save!) + end + end +end |