diff options
Diffstat (limited to 'spec')
33 files changed, 2825 insertions, 415 deletions
diff --git a/spec/controllers/concerns/group_tree_spec.rb b/spec/controllers/concerns/group_tree_spec.rb new file mode 100644 index 00000000000..ba84fbf8564 --- /dev/null +++ b/spec/controllers/concerns/group_tree_spec.rb @@ -0,0 +1,89 @@ +require 'spec_helper' + +describe GroupTree do + let(:group) { create(:group, :public) } + let(:user) { create(:user) } + + controller(ApplicationController) do + # `described_class` is not available in this context + include GroupTree # rubocop:disable RSpec/DescribedClass + + def index + render_group_tree GroupsFinder.new(current_user).execute + end + end + + before do + group.add_owner(user) + sign_in(user) + end + + describe 'GET #index' do + it 'filters groups' do + other_group = create(:group, name: 'filter') + other_group.add_owner(user) + + get :index, filter: 'filt', format: :json + + expect(assigns(:groups)).to contain_exactly(other_group) + end + + context 'for subgroups', :nested_groups do + it 'only renders root groups when no parent was given' do + create(:group, :public, parent: group) + + get :index, format: :json + + expect(assigns(:groups)).to contain_exactly(group) + end + + it 'contains only the subgroup when a parent was given' do + subgroup = create(:group, :public, parent: group) + + get :index, parent_id: group.id, format: :json + + expect(assigns(:groups)).to contain_exactly(subgroup) + end + + it 'allows filtering for subgroups and includes the parents for rendering' do + subgroup = create(:group, :public, parent: group, name: 'filter') + + get :index, filter: 'filt', format: :json + + expect(assigns(:groups)).to contain_exactly(group, subgroup) + end + + it 'does not include groups the user does not have access to' do + parent = create(:group, :private) + subgroup = create(:group, :private, parent: parent, name: 'filter') + subgroup.add_developer(user) + _other_subgroup = create(:group, :private, parent: parent, name: 'filte') + + get :index, filter: 'filt', format: :json + + expect(assigns(:groups)).to contain_exactly(parent, subgroup) + end + end + + context 'json content' do + it 'shows groups as json' do + get :index, format: :json + + expect(json_response.first['id']).to eq(group.id) + end + + context 'nested groups', :nested_groups do + it 'expands the tree when filtering' do + subgroup = create(:group, :public, parent: group, name: 'filter') + + get :index, filter: 'filt', format: :json + + children_response = json_response.first['children'] + + expect(json_response.first['id']).to eq(group.id) + expect(children_response.first['id']).to eq(subgroup.id) + end + end + end + end +end diff --git a/spec/controllers/dashboard/groups_controller_spec.rb b/spec/controllers/dashboard/groups_controller_spec.rb new file mode 100644 index 00000000000..fb9d3efbac0 --- /dev/null +++ b/spec/controllers/dashboard/groups_controller_spec.rb @@ -0,0 +1,23 @@ +require 'spec_helper' + +describe Dashboard::GroupsController do + let(:user) { create(:user) } + + before do + sign_in(user) + end + + it 'renders group trees' do + expect(described_class).to include(GroupTree) + end + + it 'only includes projects the user is a member of' do + member_of_group = create(:group) + member_of_group.add_developer(user) + create(:group, :public) + + get :index + + expect(assigns(:groups)).to contain_exactly(member_of_group) + end +end diff --git a/spec/controllers/explore/groups_controller_spec.rb b/spec/controllers/explore/groups_controller_spec.rb new file mode 100644 index 00000000000..9e0ad9ea86f --- /dev/null +++ b/spec/controllers/explore/groups_controller_spec.rb @@ -0,0 +1,23 @@ +require 'spec_helper' + +describe Explore::GroupsController do + let(:user) { create(:user) } + + before do + sign_in(user) + end + + it 'renders group trees' do + expect(described_class).to include(GroupTree) + end + + it 'includes public projects' do + member_of_group = create(:group) + member_of_group.add_developer(user) + public_group = create(:group, :public) + + get :index + + expect(assigns(:groups)).to contain_exactly(member_of_group, public_group) + end +end diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb index b0564e27a68..f914fd6f20a 100644 --- a/spec/controllers/groups_controller_spec.rb +++ b/spec/controllers/groups_controller_spec.rb @@ -1,4 +1,4 @@ -require 'rails_helper' +require 'spec_helper' describe GroupsController do let(:user) { create(:user) } @@ -150,40 +150,275 @@ describe GroupsController do end end - describe 'GET #subgroups', :nested_groups do - let!(:public_subgroup) { create(:group, :public, parent: group) } - let!(:private_subgroup) { create(:group, :private, parent: group) } + describe 'GET #children' do + context 'for projects' do + let!(:public_project) { create(:project, :public, namespace: group) } + let!(:private_project) { create(:project, :private, namespace: group) } - context 'as a user' do - before do - sign_in(user) + context 'as a user' do + before do + sign_in(user) + end + + it 'shows all children' do + get :children, id: group.to_param, format: :json + + expect(assigns(:children)).to contain_exactly(public_project, private_project) + end + + context 'being member of private subgroup' do + it 'shows public and private children the user is member of' do + group_member.destroy! + private_project.add_guest(user) + + get :children, id: group.to_param, format: :json + + expect(assigns(:children)).to contain_exactly(public_project, private_project) + end + end + end + + context 'as a guest' do + it 'shows the public children' do + get :children, id: group.to_param, format: :json + + expect(assigns(:children)).to contain_exactly(public_project) + end + end + end + + context 'for subgroups', :nested_groups do + let!(:public_subgroup) { create(:group, :public, parent: group) } + let!(:private_subgroup) { create(:group, :private, parent: group) } + let!(:public_project) { create(:project, :public, namespace: group) } + let!(:private_project) { create(:project, :private, namespace: group) } + + context 'as a user' do + before do + sign_in(user) + end + + it 'shows all children' do + get :children, id: group.to_param, format: :json + + expect(assigns(:children)).to contain_exactly(public_subgroup, private_subgroup, public_project, private_project) + end + + context 'being member of private subgroup' do + it 'shows public and private children the user is member of' do + group_member.destroy! + private_subgroup.add_guest(user) + private_project.add_guest(user) + + get :children, id: group.to_param, format: :json + + expect(assigns(:children)).to contain_exactly(public_subgroup, private_subgroup, public_project, private_project) + end + end end - it 'shows all subgroups' do - get :subgroups, id: group.to_param + context 'as a guest' do + it 'shows the public children' do + get :children, id: group.to_param, format: :json - expect(assigns(:nested_groups)).to contain_exactly(public_subgroup, private_subgroup) + expect(assigns(:children)).to contain_exactly(public_subgroup, public_project) + end end - context 'being member of private subgroup' do - it 'shows public and private subgroups the user is member of' do - group_member.destroy! - private_subgroup.add_guest(user) + context 'filtering children' do + it 'expands the tree for matching projects' do + project = create(:project, :public, namespace: public_subgroup, name: 'filterme') - get :subgroups, id: group.to_param + get :children, id: group.to_param, filter: 'filter', format: :json - expect(assigns(:nested_groups)).to contain_exactly(public_subgroup, private_subgroup) + group_json = json_response.first + project_json = group_json['children'].first + + expect(group_json['id']).to eq(public_subgroup.id) + expect(project_json['id']).to eq(project.id) + end + + it 'expands the tree for matching subgroups' do + matched_group = create(:group, :public, parent: public_subgroup, name: 'filterme') + + get :children, id: group.to_param, filter: 'filter', format: :json + + group_json = json_response.first + matched_group_json = group_json['children'].first + + expect(group_json['id']).to eq(public_subgroup.id) + expect(matched_group_json['id']).to eq(matched_group.id) + end + + it 'merges the trees correctly' do + shared_subgroup = create(:group, :public, parent: group, path: 'hardware') + matched_project_1 = create(:project, :public, namespace: shared_subgroup, name: 'mobile-soc') + + l2_subgroup = create(:group, :public, parent: shared_subgroup, path: 'broadcom') + l3_subgroup = create(:group, :public, parent: l2_subgroup, path: 'wifi-group') + matched_project_2 = create(:project, :public, namespace: l3_subgroup, name: 'mobile') + + get :children, id: group.to_param, filter: 'mobile', format: :json + + shared_group_json = json_response.first + expect(shared_group_json['id']).to eq(shared_subgroup.id) + + matched_project_1_json = shared_group_json['children'].detect { |child| child['type'] == 'project' } + expect(matched_project_1_json['id']).to eq(matched_project_1.id) + + l2_subgroup_json = shared_group_json['children'].detect { |child| child['type'] == 'group' } + expect(l2_subgroup_json['id']).to eq(l2_subgroup.id) + + l3_subgroup_json = l2_subgroup_json['children'].first + expect(l3_subgroup_json['id']).to eq(l3_subgroup.id) + + matched_project_2_json = l3_subgroup_json['children'].first + expect(matched_project_2_json['id']).to eq(matched_project_2.id) + end + + it 'expands the tree upto a specified parent' do + subgroup = create(:group, :public, parent: group) + l2_subgroup = create(:group, :public, parent: subgroup) + create(:project, :public, namespace: l2_subgroup, name: 'test') + + get :children, id: subgroup.to_param, filter: 'test', format: :json + + expect(response).to have_http_status(200) + end + + it 'returns an empty array when there are no search results' do + subgroup = create(:group, :public, parent: group) + l2_subgroup = create(:group, :public, parent: subgroup) + create(:project, :public, namespace: l2_subgroup, name: 'no-match') + + get :children, id: subgroup.to_param, filter: 'test', format: :json + + expect(json_response).to eq([]) + end + + it 'includes pagination headers' do + 2.times { |i| create(:group, :public, parent: public_subgroup, name: "filterme#{i}") } + + get :children, id: group.to_param, filter: 'filter', per_page: 1, format: :json + + expect(response).to include_pagination_headers + end + end + + context 'queries per rendered element', :request_store do + # We need to make sure the following counts are preloaded + # otherwise they will cause an extra query + # 1. Count of visible projects in the element + # 2. Count of visible subgroups in the element + # 3. Count of members of a group + let(:expected_queries_per_group) { 0 } + let(:expected_queries_per_project) { 0 } + + def get_list + get :children, id: group.to_param, format: :json + end + + it 'queries the expected amount for a group row' do + control = ActiveRecord::QueryRecorder.new { get_list } + + _new_group = create(:group, :public, parent: group) + + expect { get_list }.not_to exceed_query_limit(control).with_threshold(expected_queries_per_group) + end + + it 'queries the expected amount for a project row' do + control = ActiveRecord::QueryRecorder.new { get_list } + _new_project = create(:project, :public, namespace: group) + + expect { get_list }.not_to exceed_query_limit(control).with_threshold(expected_queries_per_project) + end + + context 'when rendering hierarchies' do + # When loading hierarchies we load the all the ancestors for matched projects + # in 1 separate query + let(:extra_queries_for_hierarchies) { 1 } + + def get_filtered_list + get :children, id: group.to_param, filter: 'filter', format: :json + end + + it 'queries the expected amount when nested rows are increased for a group' do + matched_group = create(:group, :public, parent: group, name: 'filterme') + + control = ActiveRecord::QueryRecorder.new { get_filtered_list } + + matched_group.update!(parent: public_subgroup) + + expect { get_filtered_list }.not_to exceed_query_limit(control).with_threshold(extra_queries_for_hierarchies) + end + + it 'queries the expected amount when a new group match is added' do + create(:group, :public, parent: public_subgroup, name: 'filterme') + + control = ActiveRecord::QueryRecorder.new { get_filtered_list } + + create(:group, :public, parent: public_subgroup, name: 'filterme2') + create(:group, :public, parent: public_subgroup, name: 'filterme3') + + expect { get_filtered_list }.not_to exceed_query_limit(control).with_threshold(extra_queries_for_hierarchies) + end + + it 'queries the expected amount when nested rows are increased for a project' do + matched_project = create(:project, :public, namespace: group, name: 'filterme') + + control = ActiveRecord::QueryRecorder.new { get_filtered_list } + + matched_project.update!(namespace: public_subgroup) + + expect { get_filtered_list }.not_to exceed_query_limit(control).with_threshold(extra_queries_for_hierarchies) + end end end end - context 'as a guest' do - it 'shows the public subgroups' do - get :subgroups, id: group.to_param + context 'pagination' do + let(:per_page) { 3 } - expect(assigns(:nested_groups)).to contain_exactly(public_subgroup) + before do + allow(Kaminari.config).to receive(:default_per_page).and_return(per_page) + end + + context 'with only projects' do + let!(:other_project) { create(:project, :public, namespace: group) } + let!(:first_page_projects) { create_list(:project, per_page, :public, namespace: group ) } + + it 'has projects on the first page' do + get :children, id: group.to_param, sort: 'id_desc', format: :json + + expect(assigns(:children)).to contain_exactly(*first_page_projects) + end + + it 'has projects on the second page' do + get :children, id: group.to_param, sort: 'id_desc', page: 2, format: :json + + expect(assigns(:children)).to contain_exactly(other_project) + end + end + + context 'with subgroups and projects', :nested_groups do + let!(:first_page_subgroups) { create_list(:group, per_page, :public, parent: group) } + let!(:other_subgroup) { create(:group, :public, parent: group) } + let!(:next_page_projects) { create_list(:project, per_page, :public, namespace: group) } + + it 'contains all subgroups' do + get :children, id: group.to_param, sort: 'id_asc', format: :json + + expect(assigns(:children)).to contain_exactly(*first_page_subgroups) + end + + it 'contains the project and group on the second page' do + get :children, id: group.to_param, sort: 'id_asc', page: 2, format: :json + + expect(assigns(:children)).to contain_exactly(other_subgroup, *next_page_projects.take(per_page - 1)) + end end end + end describe 'GET #issues' do @@ -425,62 +660,62 @@ describe GroupsController do end end end - end - context 'for a POST request' do - context 'when requesting the canonical path with different casing' do - it 'does not 404' do - post :update, id: group.to_param.upcase, group: { path: 'new_path' } + context 'for a POST request' do + context 'when requesting the canonical path with different casing' do + it 'does not 404' do + post :update, id: group.to_param.upcase, group: { path: 'new_path' } - expect(response).not_to have_http_status(404) - end + expect(response).not_to have_http_status(404) + end - it 'does not redirect to the correct casing' do - post :update, id: group.to_param.upcase, group: { path: 'new_path' } + it 'does not redirect to the correct casing' do + post :update, id: group.to_param.upcase, group: { path: 'new_path' } - expect(response).not_to have_http_status(301) + expect(response).not_to have_http_status(301) + end end - end - context 'when requesting a redirected path' do - let(:redirect_route) { group.redirect_routes.create(path: 'old-path') } + context 'when requesting a redirected path' do + let(:redirect_route) { group.redirect_routes.create(path: 'old-path') } - it 'returns not found' do - post :update, id: redirect_route.path, group: { path: 'new_path' } + it 'returns not found' do + post :update, id: redirect_route.path, group: { path: 'new_path' } - expect(response).to have_http_status(404) + expect(response).to have_http_status(404) + end end end - end - context 'for a DELETE request' do - context 'when requesting the canonical path with different casing' do - it 'does not 404' do - delete :destroy, id: group.to_param.upcase + context 'for a DELETE request' do + context 'when requesting the canonical path with different casing' do + it 'does not 404' do + delete :destroy, id: group.to_param.upcase - expect(response).not_to have_http_status(404) - end + expect(response).not_to have_http_status(404) + end - it 'does not redirect to the correct casing' do - delete :destroy, id: group.to_param.upcase + it 'does not redirect to the correct casing' do + delete :destroy, id: group.to_param.upcase - expect(response).not_to have_http_status(301) + expect(response).not_to have_http_status(301) + end end - end - context 'when requesting a redirected path' do - let(:redirect_route) { group.redirect_routes.create(path: 'old-path') } + context 'when requesting a redirected path' do + let(:redirect_route) { group.redirect_routes.create(path: 'old-path') } - it 'returns not found' do - delete :destroy, id: redirect_route.path + it 'returns not found' do + delete :destroy, id: redirect_route.path - expect(response).to have_http_status(404) + expect(response).to have_http_status(404) + end end end end - end - def group_moved_message(redirect_route, group) - "Group '#{redirect_route.path}' was moved to '#{group.full_path}'. Please update any links and bookmarks that may still have the old path." + def group_moved_message(redirect_route, group) + "Group '#{redirect_route.path}' was moved to '#{group.full_path}'. Please update any links and bookmarks that may still have the old path." + end end end diff --git a/spec/features/dashboard/groups_list_spec.rb b/spec/features/dashboard/groups_list_spec.rb index 533df7a325c..c9d9371f5ab 100644 --- a/spec/features/dashboard/groups_list_spec.rb +++ b/spec/features/dashboard/groups_list_spec.rb @@ -6,16 +6,28 @@ feature 'Dashboard Groups page', :js do let!(:nested_group) { create(:group, :nested) } let!(:another_group) { create(:group) } + def click_group_caret(group) + within("#group-#{group.id}") do + first('.folder-caret').click + end + wait_for_requests + end + it 'shows groups user is member of' do group.add_owner(user) nested_group.add_owner(user) sign_in(user) visit dashboard_groups_path + wait_for_requests + + expect(page).to have_content(group.name) + expect(page).to have_content(nested_group.parent.name) - 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) + click_group_caret(nested_group.parent) + expect(page).to have_content(nested_group.name) + + expect(page).not_to have_content(another_group.name) end describe 'when filtering groups' do @@ -28,25 +40,26 @@ feature 'Dashboard Groups page', :js do visit dashboard_groups_path end - it 'filters groups' do - fill_in 'filter_groups', with: group.name + it 'expands when filtering groups' do + fill_in 'filter', with: nested_group.name wait_for_requests - 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) + expect(page).not_to have_content(group.name) + expect(page).to have_content(nested_group.parent.name) + expect(page).to have_content(nested_group.name) + expect(page).not_to have_content(another_group.name) end it 'resets search when user cleans the input' do - fill_in 'filter_groups', with: group.name + fill_in 'filter', with: group.name wait_for_requests - fill_in 'filter_groups', with: '' + fill_in 'filter', with: '' wait_for_requests - 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).to have_content(group.name) + expect(page).to have_content(nested_group.parent.name) + expect(page).not_to have_content(another_group.name) expect(page.all('.js-groups-list-holder .content-list li').length).to eq 2 end end @@ -64,28 +77,29 @@ feature 'Dashboard Groups page', :js do end it 'shows subgroups inside of its parent group' do - expect(page).to have_selector('.groups-list-tree-container .group-list-tree', count: 2) - expect(page).to have_selector(".groups-list-tree-container #group-#{group.id} #group-#{subgroup.id}", count: 1) + expect(page).to have_selector("#group-#{group.id}") + click_group_caret(group) + expect(page).to have_selector("#group-#{group.id} #group-#{subgroup.id}") end it 'can toggle parent group' do - # Expanded by default - expect(page).to have_selector("#group-#{group.id} .fa-caret-down", count: 1) - expect(page).not_to have_selector("#group-#{group.id} .fa-caret-right") + # Collapsed by default + expect(page).not_to have_selector("#group-#{group.id} .fa-caret-down", count: 1) + expect(page).to have_selector("#group-#{group.id} .fa-caret-right") - # Collapse - find("#group-#{group.id}").trigger('click') + # expand + click_group_caret(group) - expect(page).not_to have_selector("#group-#{group.id} .fa-caret-down") - expect(page).to have_selector("#group-#{group.id} .fa-caret-right", count: 1) - expect(page).not_to have_selector("#group-#{group.id} #group-#{subgroup.id}") + expect(page).to have_selector("#group-#{group.id} .fa-caret-down") + expect(page).not_to have_selector("#group-#{group.id} .fa-caret-right", count: 1) + expect(page).to have_selector("#group-#{group.id} #group-#{subgroup.id}") - # Expand - find("#group-#{group.id}").trigger('click') + # collapse + click_group_caret(group) - expect(page).to have_selector("#group-#{group.id} .fa-caret-down", count: 1) - expect(page).not_to have_selector("#group-#{group.id} .fa-caret-right") - expect(page).to have_selector("#group-#{group.id} #group-#{subgroup.id}") + expect(page).not_to have_selector("#group-#{group.id} .fa-caret-down", count: 1) + expect(page).to have_selector("#group-#{group.id} .fa-caret-right") + expect(page).not_to have_selector("#group-#{group.id} #group-#{subgroup.id}") end end diff --git a/spec/features/explore/groups_list_spec.rb b/spec/features/explore/groups_list_spec.rb index b5325301968..801a33979ff 100644 --- a/spec/features/explore/groups_list_spec.rb +++ b/spec/features/explore/groups_list_spec.rb @@ -13,6 +13,7 @@ describe 'Explore Groups page', :js do sign_in(user) visit explore_groups_path + wait_for_requests end it 'shows groups user is member of' do @@ -22,7 +23,7 @@ describe 'Explore Groups page', :js do end it 'filters groups' do - fill_in 'filter_groups', with: group.name + fill_in 'filter', with: group.name wait_for_requests expect(page).to have_content(group.full_name) @@ -31,10 +32,10 @@ describe 'Explore Groups page', :js do end it 'resets search when user cleans the input' do - fill_in 'filter_groups', with: group.name + fill_in 'filter', with: group.name wait_for_requests - fill_in 'filter_groups', with: "" + fill_in 'filter', with: "" wait_for_requests expect(page).to have_content(group.full_name) @@ -45,21 +46,21 @@ describe 'Explore Groups page', :js do it 'shows non-archived projects count' do # Initially project is not archived - expect(find('.js-groups-list-holder .content-list li:first-child .stats span:first-child')).to have_text("1") + expect(find('.js-groups-list-holder .content-list li:first-child .stats .number-projects')).to have_text("1") # Archive project empty_project.archive! visit explore_groups_path # Check project count - expect(find('.js-groups-list-holder .content-list li:first-child .stats span:first-child')).to have_text("0") + expect(find('.js-groups-list-holder .content-list li:first-child .stats .number-projects')).to have_text("0") # Unarchive project empty_project.unarchive! visit explore_groups_path # Check project count - expect(find('.js-groups-list-holder .content-list li:first-child .stats span:first-child')).to have_text("1") + expect(find('.js-groups-list-holder .content-list li:first-child .stats .number-projects')).to have_text("1") end describe 'landing component' do diff --git a/spec/features/groups/show_spec.rb b/spec/features/groups/show_spec.rb index 303013e59d5..501086fce80 100644 --- a/spec/features/groups/show_spec.rb +++ b/spec/features/groups/show_spec.rb @@ -24,4 +24,34 @@ feature 'Group show page' do it_behaves_like "an autodiscoverable RSS feed without an RSS token" end + + context 'subgroup support' do + let(:user) { create(:user) } + + before do + group.add_owner(user) + sign_in(user) + end + context 'when subgroups are supported', :js do + before do + allow(Group).to receive(:supports_nested_groups?) { true } + visit path + end + + it 'allows creating subgroups' do + expect(page).to have_css("li[data-text='New subgroup']", visible: false) + end + end + + context 'when subgroups are not supported' do + before do + allow(Group).to receive(:supports_nested_groups?) { false } + visit path + end + + it 'allows creating subgroups' do + expect(page).not_to have_selector("li[data-text='New subgroup']", visible: false) + end + end + end end diff --git a/spec/features/groups_spec.rb b/spec/features/groups_spec.rb index 862823d862e..cc8906fa969 100644 --- a/spec/features/groups_spec.rb +++ b/spec/features/groups_spec.rb @@ -90,8 +90,7 @@ feature 'Group' do context 'as admin' do before do - visit subgroups_group_path(group) - click_link 'New Subgroup' + visit new_group_path(group, parent_id: group.id) end it 'creates a nested group' do @@ -111,8 +110,8 @@ feature 'Group' do sign_out(:user) sign_in(user) - visit subgroups_group_path(group) - click_link 'New Subgroup' + visit new_group_path(group, parent_id: group.id) + fill_in 'Group path', with: 'bar' click_button 'Create group' @@ -120,16 +119,6 @@ feature 'Group' do expect(page).to have_content("Group 'bar' was successfully created.") end end - - context 'when nested group feature is disabled' do - it 'renders 404' do - allow(Group).to receive(:supports_nested_groups?).and_return(false) - - visit subgroups_group_path(group) - - expect(page.status_code).to eq(404) - end - end end it 'checks permissions to avoid exposing groups by parent_id' do @@ -210,13 +199,15 @@ feature 'Group' do describe 'group page with nested groups', :nested_groups, :js do let!(:group) { create(:group) } let!(:nested_group) { create(:group, parent: group) } + let!(:project) { create(:project, namespace: group) } let!(:path) { group_path(group) } - it 'has nested groups tab with nested groups inside' do + it 'it renders projects and groups on the page' do visit path - click_link 'Subgroups' + wait_for_requests expect(page).to have_content(nested_group.name) + expect(page).to have_content(project.name) end end diff --git a/spec/finders/group_descendants_finder_spec.rb b/spec/finders/group_descendants_finder_spec.rb new file mode 100644 index 00000000000..4a5bdd84508 --- /dev/null +++ b/spec/finders/group_descendants_finder_spec.rb @@ -0,0 +1,134 @@ +require 'spec_helper' + +describe GroupDescendantsFinder do + let(:user) { create(:user) } + let(:group) { create(:group) } + let(:params) { {} } + subject(:finder) do + described_class.new(current_user: user, parent_group: group, params: params) + end + + before do + group.add_owner(user) + end + + describe '#has_children?' do + it 'is true when there are projects' do + create(:project, namespace: group) + + expect(finder.has_children?).to be_truthy + end + + context 'when there are subgroups', :nested_groups do + it 'is true when there are projects' do + create(:group, parent: group) + + expect(finder.has_children?).to be_truthy + end + end + end + + describe '#execute' do + it 'includes projects' do + project = create(:project, namespace: group) + + expect(finder.execute).to contain_exactly(project) + end + + it 'does not include archived projects' do + _archived_project = create(:project, :archived, namespace: group) + + expect(finder.execute).to be_empty + end + + context 'with a filter' do + let(:params) { { filter: 'test' } } + + it 'includes only projects matching the filter' do + _other_project = create(:project, namespace: group) + matching_project = create(:project, namespace: group, name: 'testproject') + + expect(finder.execute).to contain_exactly(matching_project) + end + end + end + + context 'with nested groups', :nested_groups do + let!(:project) { create(:project, namespace: group) } + let!(:subgroup) { create(:group, :private, parent: group) } + + describe '#execute' do + it 'contains projects and subgroups' do + expect(finder.execute).to contain_exactly(subgroup, project) + end + + it 'does not include subgroups the user does not have access to' do + subgroup.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) + + public_subgroup = create(:group, :public, parent: group, path: 'public-group') + other_subgroup = create(:group, :private, parent: group, path: 'visible-private-group') + other_user = create(:user) + other_subgroup.add_developer(other_user) + + finder = described_class.new(current_user: other_user, parent_group: group) + + expect(finder.execute).to contain_exactly(public_subgroup, other_subgroup) + end + + it 'only includes public groups when no user is given' do + public_subgroup = create(:group, :public, parent: group) + _private_subgroup = create(:group, :private, parent: group) + + finder = described_class.new(current_user: nil, parent_group: group) + + expect(finder.execute).to contain_exactly(public_subgroup) + end + + context 'with a filter' do + let(:params) { { filter: 'test' } } + + it 'contains only matching projects and subgroups' do + matching_project = create(:project, namespace: group, name: 'Testproject') + matching_subgroup = create(:group, name: 'testgroup', parent: group) + + expect(finder.execute).to contain_exactly(matching_subgroup, matching_project) + end + + it 'does not include subgroups the user does not have access to' do + _invisible_subgroup = create(:group, :private, parent: group, name: 'test1') + other_subgroup = create(:group, :private, parent: group, name: 'test2') + public_subgroup = create(:group, :public, parent: group, name: 'test3') + other_subsubgroup = create(:group, :private, parent: other_subgroup, name: 'test4') + other_user = create(:user) + other_subgroup.add_developer(other_user) + + finder = described_class.new(current_user: other_user, + parent_group: group, + params: params) + + expect(finder.execute).to contain_exactly(other_subgroup, public_subgroup, other_subsubgroup) + end + + context 'with matching children' do + it 'includes a group that has a subgroup matching the query and its parent' do + matching_subgroup = create(:group, :private, name: 'testgroup', parent: subgroup) + + expect(finder.execute).to contain_exactly(subgroup, matching_subgroup) + end + + it 'includes the parent of a matching project' do + matching_project = create(:project, namespace: subgroup, name: 'Testproject') + + expect(finder.execute).to contain_exactly(subgroup, matching_project) + end + + it 'does not include the parent itself' do + group.update!(name: 'test') + + expect(finder.execute).not_to include(group) + end + end + end + end + end +end diff --git a/spec/javascripts/groups/components/app_spec.js b/spec/javascripts/groups/components/app_spec.js new file mode 100644 index 00000000000..8472c726b08 --- /dev/null +++ b/spec/javascripts/groups/components/app_spec.js @@ -0,0 +1,440 @@ +import Vue from 'vue'; + +import appComponent from '~/groups/components/app.vue'; +import groupFolderComponent from '~/groups/components/group_folder.vue'; +import groupItemComponent from '~/groups/components/group_item.vue'; + +import eventHub from '~/groups/event_hub'; +import GroupsStore from '~/groups/store/groups_store'; +import GroupsService from '~/groups/service/groups_service'; + +import { + mockEndpoint, mockGroups, mockSearchedGroups, + mockRawPageInfo, mockParentGroupItem, mockRawChildren, + mockChildren, mockPageInfo, +} from '../mock_data'; + +const createComponent = (hideProjects = false) => { + const Component = Vue.extend(appComponent); + const store = new GroupsStore(false); + const service = new GroupsService(mockEndpoint); + + return new Component({ + propsData: { + store, + service, + hideProjects, + }, + }); +}; + +const returnServicePromise = (data, failed) => new Promise((resolve, reject) => { + if (failed) { + reject(data); + } else { + resolve({ + json() { + return data; + }, + }); + } +}); + +describe('AppComponent', () => { + let vm; + + beforeEach((done) => { + Vue.component('group-folder', groupFolderComponent); + Vue.component('group-item', groupItemComponent); + + vm = createComponent(); + + Vue.nextTick(() => { + done(); + }); + }); + + describe('computed', () => { + beforeEach(() => { + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('groups', () => { + it('should return list of groups from store', () => { + spyOn(vm.store, 'getGroups'); + + const groups = vm.groups; + expect(vm.store.getGroups).toHaveBeenCalled(); + expect(groups).not.toBeDefined(); + }); + }); + + describe('pageInfo', () => { + it('should return pagination info from store', () => { + spyOn(vm.store, 'getPaginationInfo'); + + const pageInfo = vm.pageInfo; + expect(vm.store.getPaginationInfo).toHaveBeenCalled(); + expect(pageInfo).not.toBeDefined(); + }); + }); + }); + + describe('methods', () => { + beforeEach(() => { + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('fetchGroups', () => { + it('should call `getGroups` with all the params provided', (done) => { + spyOn(vm.service, 'getGroups').and.returnValue(returnServicePromise(mockGroups)); + + vm.fetchGroups({ + parentId: 1, + page: 2, + filterGroupsBy: 'git', + sortBy: 'created_desc', + }); + setTimeout(() => { + expect(vm.service.getGroups).toHaveBeenCalledWith(1, 2, 'git', 'created_desc'); + done(); + }, 0); + }); + + it('should set headers to store for building pagination info when called with `updatePagination`', (done) => { + spyOn(vm.service, 'getGroups').and.returnValue(returnServicePromise({ headers: mockRawPageInfo })); + spyOn(vm, 'updatePagination'); + + vm.fetchGroups({ updatePagination: true }); + setTimeout(() => { + expect(vm.service.getGroups).toHaveBeenCalled(); + expect(vm.updatePagination).toHaveBeenCalled(); + done(); + }, 0); + }); + + it('should show flash error when request fails', (done) => { + spyOn(vm.service, 'getGroups').and.returnValue(returnServicePromise(null, true)); + spyOn($, 'scrollTo'); + spyOn(window, 'Flash'); + + vm.fetchGroups({}); + setTimeout(() => { + expect(vm.isLoading).toBeFalsy(); + expect($.scrollTo).toHaveBeenCalledWith(0); + expect(window.Flash).toHaveBeenCalledWith('An error occurred. Please try again.'); + done(); + }, 0); + }); + }); + + describe('fetchAllGroups', () => { + it('should fetch default set of groups', (done) => { + spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockGroups)); + spyOn(vm, 'updatePagination').and.callThrough(); + spyOn(vm, 'updateGroups').and.callThrough(); + + vm.fetchAllGroups(); + expect(vm.isLoading).toBeTruthy(); + expect(vm.fetchGroups).toHaveBeenCalled(); + setTimeout(() => { + expect(vm.isLoading).toBeFalsy(); + expect(vm.updateGroups).toHaveBeenCalled(); + done(); + }, 0); + }); + + it('should fetch matching set of groups when app is loaded with search query', (done) => { + spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockSearchedGroups)); + spyOn(vm, 'updateGroups').and.callThrough(); + + vm.fetchAllGroups(); + expect(vm.fetchGroups).toHaveBeenCalledWith({ + page: null, + filterGroupsBy: null, + sortBy: null, + updatePagination: true, + }); + setTimeout(() => { + expect(vm.updateGroups).toHaveBeenCalled(); + done(); + }, 0); + }); + }); + + describe('fetchPage', () => { + it('should fetch groups for provided page details and update window state', (done) => { + spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockGroups)); + spyOn(vm, 'updateGroups').and.callThrough(); + spyOn(gl.utils, 'mergeUrlParams').and.callThrough(); + spyOn(window.history, 'replaceState'); + spyOn($, 'scrollTo'); + + vm.fetchPage(2, null, null); + expect(vm.isLoading).toBeTruthy(); + expect(vm.fetchGroups).toHaveBeenCalledWith({ + page: 2, + filterGroupsBy: null, + sortBy: null, + updatePagination: true, + }); + setTimeout(() => { + expect(vm.isLoading).toBeFalsy(); + expect($.scrollTo).toHaveBeenCalledWith(0); + expect(gl.utils.mergeUrlParams).toHaveBeenCalledWith({ page: 2 }, jasmine.any(String)); + expect(window.history.replaceState).toHaveBeenCalledWith({ + page: jasmine.any(String), + }, jasmine.any(String), jasmine.any(String)); + expect(vm.updateGroups).toHaveBeenCalled(); + done(); + }, 0); + }); + }); + + describe('toggleChildren', () => { + let groupItem; + + beforeEach(() => { + groupItem = Object.assign({}, mockParentGroupItem); + groupItem.isOpen = false; + groupItem.isChildrenLoading = false; + }); + + it('should fetch children of given group and expand it if group is collapsed and children are not loaded', (done) => { + spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise(mockRawChildren)); + spyOn(vm.store, 'setGroupChildren'); + + vm.toggleChildren(groupItem); + expect(groupItem.isChildrenLoading).toBeTruthy(); + expect(vm.fetchGroups).toHaveBeenCalledWith({ + parentId: groupItem.id, + }); + setTimeout(() => { + expect(vm.store.setGroupChildren).toHaveBeenCalled(); + done(); + }, 0); + }); + + it('should skip network request while expanding group if children are already loaded', () => { + spyOn(vm, 'fetchGroups'); + groupItem.children = mockRawChildren; + + vm.toggleChildren(groupItem); + expect(vm.fetchGroups).not.toHaveBeenCalled(); + expect(groupItem.isOpen).toBeTruthy(); + }); + + it('should collapse group if it is already expanded', () => { + spyOn(vm, 'fetchGroups'); + groupItem.isOpen = true; + + vm.toggleChildren(groupItem); + expect(vm.fetchGroups).not.toHaveBeenCalled(); + expect(groupItem.isOpen).toBeFalsy(); + }); + + it('should set `isChildrenLoading` back to `false` if load request fails', (done) => { + spyOn(vm, 'fetchGroups').and.returnValue(returnServicePromise({}, true)); + + vm.toggleChildren(groupItem); + expect(groupItem.isChildrenLoading).toBeTruthy(); + setTimeout(() => { + expect(groupItem.isChildrenLoading).toBeFalsy(); + done(); + }, 0); + }); + }); + + describe('leaveGroup', () => { + let groupItem; + let childGroupItem; + + beforeEach(() => { + groupItem = Object.assign({}, mockParentGroupItem); + groupItem.children = mockChildren; + childGroupItem = groupItem.children[0]; + groupItem.isChildrenLoading = false; + }); + + it('should leave group and remove group item from tree', (done) => { + const notice = `You left the "${childGroupItem.fullName}" group.`; + spyOn(vm.service, 'leaveGroup').and.returnValue(returnServicePromise({ notice })); + spyOn(vm.store, 'removeGroup').and.callThrough(); + spyOn(window, 'Flash'); + spyOn($, 'scrollTo'); + + vm.leaveGroup(childGroupItem, groupItem); + expect(childGroupItem.isBeingRemoved).toBeTruthy(); + expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath); + setTimeout(() => { + expect($.scrollTo).toHaveBeenCalledWith(0); + expect(vm.store.removeGroup).toHaveBeenCalledWith(childGroupItem, groupItem); + expect(window.Flash).toHaveBeenCalledWith(notice, 'notice'); + done(); + }, 0); + }); + + it('should show error flash message if request failed to leave group', (done) => { + const message = 'An error occurred. Please try again.'; + spyOn(vm.service, 'leaveGroup').and.returnValue(returnServicePromise({ status: 500 }, true)); + spyOn(vm.store, 'removeGroup').and.callThrough(); + spyOn(window, 'Flash'); + + vm.leaveGroup(childGroupItem, groupItem); + expect(childGroupItem.isBeingRemoved).toBeTruthy(); + expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath); + setTimeout(() => { + expect(vm.store.removeGroup).not.toHaveBeenCalled(); + expect(window.Flash).toHaveBeenCalledWith(message); + expect(childGroupItem.isBeingRemoved).toBeFalsy(); + done(); + }, 0); + }); + + it('should show appropriate error flash message if request forbids to leave group', (done) => { + const message = 'Failed to leave the group. Please make sure you are not the only owner.'; + spyOn(vm.service, 'leaveGroup').and.returnValue(returnServicePromise({ status: 403 }, true)); + spyOn(vm.store, 'removeGroup').and.callThrough(); + spyOn(window, 'Flash'); + + vm.leaveGroup(childGroupItem, groupItem); + expect(childGroupItem.isBeingRemoved).toBeTruthy(); + expect(vm.service.leaveGroup).toHaveBeenCalledWith(childGroupItem.leavePath); + setTimeout(() => { + expect(vm.store.removeGroup).not.toHaveBeenCalled(); + expect(window.Flash).toHaveBeenCalledWith(message); + expect(childGroupItem.isBeingRemoved).toBeFalsy(); + done(); + }, 0); + }); + }); + + describe('updatePagination', () => { + it('should set pagination info to store from provided headers', () => { + spyOn(vm.store, 'setPaginationInfo'); + + vm.updatePagination(mockRawPageInfo); + expect(vm.store.setPaginationInfo).toHaveBeenCalledWith(mockRawPageInfo); + }); + }); + + describe('updateGroups', () => { + it('should call setGroups on store if method was called directly', () => { + spyOn(vm.store, 'setGroups'); + + vm.updateGroups(mockGroups); + expect(vm.store.setGroups).toHaveBeenCalledWith(mockGroups); + }); + + it('should call setSearchedGroups on store if method was called with fromSearch param', () => { + spyOn(vm.store, 'setSearchedGroups'); + + vm.updateGroups(mockGroups, true); + expect(vm.store.setSearchedGroups).toHaveBeenCalledWith(mockGroups); + }); + + it('should set `isSearchEmpty` prop based on groups count', () => { + vm.updateGroups(mockGroups); + expect(vm.isSearchEmpty).toBeFalsy(); + + vm.updateGroups([]); + expect(vm.isSearchEmpty).toBeTruthy(); + }); + }); + }); + + describe('created', () => { + it('should bind event listeners on eventHub', (done) => { + spyOn(eventHub, '$on'); + + const newVm = createComponent(); + newVm.$mount(); + + Vue.nextTick(() => { + expect(eventHub.$on).toHaveBeenCalledWith('fetchPage', jasmine.any(Function)); + expect(eventHub.$on).toHaveBeenCalledWith('toggleChildren', jasmine.any(Function)); + expect(eventHub.$on).toHaveBeenCalledWith('leaveGroup', jasmine.any(Function)); + expect(eventHub.$on).toHaveBeenCalledWith('updatePagination', jasmine.any(Function)); + expect(eventHub.$on).toHaveBeenCalledWith('updateGroups', jasmine.any(Function)); + newVm.$destroy(); + done(); + }); + }); + + it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `false`', (done) => { + const newVm = createComponent(); + newVm.$mount(); + Vue.nextTick(() => { + expect(newVm.searchEmptyMessage).toBe('Sorry, no groups or projects matched your search'); + newVm.$destroy(); + done(); + }); + }); + + it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `true`', (done) => { + const newVm = createComponent(true); + newVm.$mount(); + Vue.nextTick(() => { + expect(newVm.searchEmptyMessage).toBe('Sorry, no groups matched your search'); + newVm.$destroy(); + done(); + }); + }); + }); + + describe('beforeDestroy', () => { + it('should unbind event listeners on eventHub', (done) => { + spyOn(eventHub, '$off'); + + const newVm = createComponent(); + newVm.$mount(); + newVm.$destroy(); + + Vue.nextTick(() => { + expect(eventHub.$off).toHaveBeenCalledWith('fetchPage', jasmine.any(Function)); + expect(eventHub.$off).toHaveBeenCalledWith('toggleChildren', jasmine.any(Function)); + expect(eventHub.$off).toHaveBeenCalledWith('leaveGroup', jasmine.any(Function)); + expect(eventHub.$off).toHaveBeenCalledWith('updatePagination', jasmine.any(Function)); + expect(eventHub.$off).toHaveBeenCalledWith('updateGroups', jasmine.any(Function)); + done(); + }); + }); + }); + + describe('template', () => { + beforeEach(() => { + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('should render loading icon', (done) => { + vm.isLoading = true; + Vue.nextTick(() => { + expect(vm.$el.querySelector('.loading-animation')).toBeDefined(); + expect(vm.$el.querySelector('i.fa').getAttribute('aria-label')).toBe('Loading groups'); + done(); + }); + }); + + it('should render groups tree', (done) => { + vm.groups = [mockParentGroupItem]; + vm.isLoading = false; + vm.pageInfo = mockPageInfo; + Vue.nextTick(() => { + expect(vm.$el.querySelector('.groups-list-tree-container')).toBeDefined(); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/groups/components/group_folder_spec.js b/spec/javascripts/groups/components/group_folder_spec.js new file mode 100644 index 00000000000..4eb198595fb --- /dev/null +++ b/spec/javascripts/groups/components/group_folder_spec.js @@ -0,0 +1,66 @@ +import Vue from 'vue'; + +import groupFolderComponent from '~/groups/components/group_folder.vue'; +import groupItemComponent from '~/groups/components/group_item.vue'; +import { mockGroups, mockParentGroupItem } from '../mock_data'; + +const createComponent = (groups = mockGroups, parentGroup = mockParentGroupItem) => { + const Component = Vue.extend(groupFolderComponent); + + return new Component({ + propsData: { + groups, + parentGroup, + }, + }); +}; + +describe('GroupFolderComponent', () => { + let vm; + + beforeEach((done) => { + Vue.component('group-item', groupItemComponent); + + vm = createComponent(); + vm.$mount(); + + Vue.nextTick(() => { + done(); + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('computed', () => { + describe('hasMoreChildren', () => { + it('should return false when childrenCount of group is less than MAX_CHILDREN_COUNT', () => { + expect(vm.hasMoreChildren).toBeFalsy(); + }); + }); + + describe('moreChildrenStats', () => { + it('should return message with count of excess children over MAX_CHILDREN_COUNT limit', () => { + expect(vm.moreChildrenStats).toBe('3 more items'); + }); + }); + }); + + describe('template', () => { + it('should render component template correctly', () => { + expect(vm.$el.classList.contains('group-list-tree')).toBeTruthy(); + expect(vm.$el.querySelectorAll('li.group-row').length).toBe(7); + }); + + it('should render more children link when groups list has children over MAX_CHILDREN_COUNT limit', () => { + const parentGroup = Object.assign({}, mockParentGroupItem); + parentGroup.childrenCount = 21; + + const newVm = createComponent(mockGroups, parentGroup); + newVm.$mount(); + expect(newVm.$el.querySelector('li.group-row a.has-more-items')).toBeDefined(); + newVm.$destroy(); + }); + }); +}); diff --git a/spec/javascripts/groups/components/group_item_spec.js b/spec/javascripts/groups/components/group_item_spec.js new file mode 100644 index 00000000000..0f4fbdae445 --- /dev/null +++ b/spec/javascripts/groups/components/group_item_spec.js @@ -0,0 +1,177 @@ +import Vue from 'vue'; + +import groupItemComponent from '~/groups/components/group_item.vue'; +import groupFolderComponent from '~/groups/components/group_folder.vue'; +import eventHub from '~/groups/event_hub'; +import { mockParentGroupItem, mockChildren } from '../mock_data'; + +import mountComponent from '../../helpers/vue_mount_component_helper'; + +const createComponent = (group = mockParentGroupItem, parentGroup = mockChildren[0]) => { + const Component = Vue.extend(groupItemComponent); + + return mountComponent(Component, { + group, + parentGroup, + }); +}; + +describe('GroupItemComponent', () => { + let vm; + + beforeEach((done) => { + Vue.component('group-folder', groupFolderComponent); + + vm = createComponent(); + + Vue.nextTick(() => { + done(); + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('computed', () => { + describe('groupDomId', () => { + it('should return ID string suffixed with group ID', () => { + expect(vm.groupDomId).toBe('group-55'); + }); + }); + + describe('rowClass', () => { + it('should return map of classes based on group details', () => { + const classes = ['is-open', 'has-children', 'has-description', 'being-removed']; + const rowClass = vm.rowClass; + + expect(Object.keys(rowClass).length).toBe(classes.length); + Object.keys(rowClass).forEach((className) => { + expect(classes.indexOf(className) > -1).toBeTruthy(); + }); + }); + }); + + describe('hasChildren', () => { + it('should return boolean value representing if group has any children present', () => { + let newVm; + const group = Object.assign({}, mockParentGroupItem); + + group.childrenCount = 5; + newVm = createComponent(group); + expect(newVm.hasChildren).toBeTruthy(); + newVm.$destroy(); + + group.childrenCount = 0; + newVm = createComponent(group); + expect(newVm.hasChildren).toBeFalsy(); + newVm.$destroy(); + }); + }); + + describe('hasAvatar', () => { + it('should return boolean value representing if group has any avatar present', () => { + let newVm; + const group = Object.assign({}, mockParentGroupItem); + + group.avatarUrl = null; + newVm = createComponent(group); + expect(newVm.hasAvatar).toBeFalsy(); + newVm.$destroy(); + + group.avatarUrl = '/uploads/group_avatar.png'; + newVm = createComponent(group); + expect(newVm.hasAvatar).toBeTruthy(); + newVm.$destroy(); + }); + }); + + describe('isGroup', () => { + it('should return boolean value representing if group item is of type `group` or not', () => { + let newVm; + const group = Object.assign({}, mockParentGroupItem); + + group.type = 'group'; + newVm = createComponent(group); + expect(newVm.isGroup).toBeTruthy(); + newVm.$destroy(); + + group.type = 'project'; + newVm = createComponent(group); + expect(newVm.isGroup).toBeFalsy(); + newVm.$destroy(); + }); + }); + }); + + describe('methods', () => { + describe('onClickRowGroup', () => { + let event; + + beforeEach(() => { + const classList = { + contains() { + return false; + }, + }; + + event = { + target: { + classList, + parentElement: { + classList, + }, + }, + }; + }); + + it('should emit `toggleChildren` event when expand is clicked on a group and it has children present', () => { + spyOn(eventHub, '$emit'); + + vm.onClickRowGroup(event); + expect(eventHub.$emit).toHaveBeenCalledWith('toggleChildren', vm.group); + }); + + it('should navigate page to group homepage if group does not have any children present', (done) => { + const group = Object.assign({}, mockParentGroupItem); + group.childrenCount = 0; + const newVm = createComponent(group); + spyOn(gl.utils, 'visitUrl').and.stub(); + spyOn(eventHub, '$emit'); + + newVm.onClickRowGroup(event); + setTimeout(() => { + expect(eventHub.$emit).not.toHaveBeenCalled(); + expect(gl.utils.visitUrl).toHaveBeenCalledWith(newVm.group.relativePath); + done(); + }, 0); + }); + }); + }); + + describe('template', () => { + it('should render component template correctly', () => { + expect(vm.$el.getAttribute('id')).toBe('group-55'); + expect(vm.$el.classList.contains('group-row')).toBeTruthy(); + + expect(vm.$el.querySelector('.group-row-contents')).toBeDefined(); + expect(vm.$el.querySelector('.group-row-contents .controls')).toBeDefined(); + expect(vm.$el.querySelector('.group-row-contents .stats')).toBeDefined(); + + expect(vm.$el.querySelector('.folder-toggle-wrap')).toBeDefined(); + expect(vm.$el.querySelector('.folder-toggle-wrap .folder-caret')).toBeDefined(); + expect(vm.$el.querySelector('.folder-toggle-wrap .item-type-icon')).toBeDefined(); + + expect(vm.$el.querySelector('.avatar-container')).toBeDefined(); + expect(vm.$el.querySelector('.avatar-container a.no-expand')).toBeDefined(); + expect(vm.$el.querySelector('.avatar-container .avatar')).toBeDefined(); + + expect(vm.$el.querySelector('.title')).toBeDefined(); + expect(vm.$el.querySelector('.title a.no-expand')).toBeDefined(); + expect(vm.$el.querySelector('.access-type')).toBeDefined(); + expect(vm.$el.querySelector('.description')).toBeDefined(); + + expect(vm.$el.querySelector('.group-list-tree')).toBeDefined(); + }); + }); +}); diff --git a/spec/javascripts/groups/components/groups_spec.js b/spec/javascripts/groups/components/groups_spec.js new file mode 100644 index 00000000000..280376d4903 --- /dev/null +++ b/spec/javascripts/groups/components/groups_spec.js @@ -0,0 +1,70 @@ +import Vue from 'vue'; + +import groupsComponent from '~/groups/components/groups.vue'; +import groupFolderComponent from '~/groups/components/group_folder.vue'; +import groupItemComponent from '~/groups/components/group_item.vue'; +import eventHub from '~/groups/event_hub'; +import { mockGroups, mockPageInfo } from '../mock_data'; + +import mountComponent from '../../helpers/vue_mount_component_helper'; + +const createComponent = (searchEmpty = false) => { + const Component = Vue.extend(groupsComponent); + + return mountComponent(Component, { + groups: mockGroups, + pageInfo: mockPageInfo, + searchEmptyMessage: 'No matching results', + searchEmpty, + }); +}; + +describe('GroupsComponent', () => { + let vm; + + beforeEach((done) => { + Vue.component('group-folder', groupFolderComponent); + Vue.component('group-item', groupItemComponent); + + vm = createComponent(); + + Vue.nextTick(() => { + done(); + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('methods', () => { + describe('change', () => { + it('should emit `fetchPage` event when page is changed via pagination', () => { + spyOn(eventHub, '$emit').and.stub(); + + vm.change(2); + expect(eventHub.$emit).toHaveBeenCalledWith('fetchPage', 2, jasmine.any(Object), jasmine.any(Object)); + }); + }); + }); + + describe('template', () => { + it('should render component template correctly', (done) => { + Vue.nextTick(() => { + expect(vm.$el.querySelector('.groups-list-tree-container')).toBeDefined(); + expect(vm.$el.querySelector('.group-list-tree')).toBeDefined(); + expect(vm.$el.querySelector('.gl-pagination')).toBeDefined(); + expect(vm.$el.querySelectorAll('.has-no-search-results').length === 0).toBeTruthy(); + done(); + }); + }); + + it('should render empty search message when `searchEmpty` is `true`', (done) => { + vm.searchEmpty = true; + Vue.nextTick(() => { + expect(vm.$el.querySelector('.has-no-search-results')).toBeDefined(); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/groups/components/item_actions_spec.js b/spec/javascripts/groups/components/item_actions_spec.js new file mode 100644 index 00000000000..2ce1a749a96 --- /dev/null +++ b/spec/javascripts/groups/components/item_actions_spec.js @@ -0,0 +1,110 @@ +import Vue from 'vue'; + +import itemActionsComponent from '~/groups/components/item_actions.vue'; +import eventHub from '~/groups/event_hub'; +import { mockParentGroupItem, mockChildren } from '../mock_data'; + +import mountComponent from '../../helpers/vue_mount_component_helper'; + +const createComponent = (group = mockParentGroupItem, parentGroup = mockChildren[0]) => { + const Component = Vue.extend(itemActionsComponent); + + return mountComponent(Component, { + group, + parentGroup, + }); +}; + +describe('ItemActionsComponent', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('computed', () => { + describe('leaveConfirmationMessage', () => { + it('should return appropriate string for leave group confirmation', () => { + expect(vm.leaveConfirmationMessage).toBe('Are you sure you want to leave the "platform / hardware" group?'); + }); + }); + }); + + describe('methods', () => { + describe('onLeaveGroup', () => { + it('should change `dialogStatus` prop to `true` which shows confirmation dialog', () => { + expect(vm.dialogStatus).toBeFalsy(); + vm.onLeaveGroup(); + expect(vm.dialogStatus).toBeTruthy(); + }); + }); + + describe('leaveGroup', () => { + it('should change `dialogStatus` prop to `false` and emit `leaveGroup` event with required params when called with `leaveConfirmed` as `true`', () => { + spyOn(eventHub, '$emit'); + vm.dialogStatus = true; + vm.leaveGroup(true); + expect(vm.dialogStatus).toBeFalsy(); + expect(eventHub.$emit).toHaveBeenCalledWith('leaveGroup', vm.group, vm.parentGroup); + }); + + it('should change `dialogStatus` prop to `false` and should NOT emit `leaveGroup` event when called with `leaveConfirmed` as `false`', () => { + spyOn(eventHub, '$emit'); + vm.dialogStatus = true; + vm.leaveGroup(false); + expect(vm.dialogStatus).toBeFalsy(); + expect(eventHub.$emit).not.toHaveBeenCalled(); + }); + }); + }); + + describe('template', () => { + it('should render component template correctly', () => { + expect(vm.$el.classList.contains('controls')).toBeTruthy(); + }); + + it('should render Edit Group button with correct attribute values', () => { + const group = Object.assign({}, mockParentGroupItem); + group.canEdit = true; + const newVm = createComponent(group); + + const editBtn = newVm.$el.querySelector('a.edit-group'); + expect(editBtn).toBeDefined(); + expect(editBtn.classList.contains('no-expand')).toBeTruthy(); + expect(editBtn.getAttribute('href')).toBe(group.editPath); + expect(editBtn.getAttribute('aria-label')).toBe('Edit group'); + expect(editBtn.dataset.originalTitle).toBe('Edit group'); + expect(editBtn.querySelector('i.fa.fa-cogs')).toBeDefined(); + + newVm.$destroy(); + }); + + it('should render Leave Group button with correct attribute values', () => { + const group = Object.assign({}, mockParentGroupItem); + group.canLeave = true; + const newVm = createComponent(group); + + const leaveBtn = newVm.$el.querySelector('a.leave-group'); + expect(leaveBtn).toBeDefined(); + expect(leaveBtn.classList.contains('no-expand')).toBeTruthy(); + expect(leaveBtn.getAttribute('href')).toBe(group.leavePath); + expect(leaveBtn.getAttribute('aria-label')).toBe('Leave this group'); + expect(leaveBtn.dataset.originalTitle).toBe('Leave this group'); + expect(leaveBtn.querySelector('i.fa.fa-sign-out')).toBeDefined(); + + newVm.$destroy(); + }); + + it('should show modal dialog when `dialogStatus` is set to `true`', () => { + vm.dialogStatus = true; + const modalDialogEl = vm.$el.querySelector('.modal.popup-dialog'); + expect(modalDialogEl).toBeDefined(); + expect(modalDialogEl.querySelector('.modal-title').innerText.trim()).toBe('Are you sure?'); + expect(modalDialogEl.querySelector('.btn.btn-warning').innerText.trim()).toBe('Leave'); + }); + }); +}); diff --git a/spec/javascripts/groups/components/item_caret_spec.js b/spec/javascripts/groups/components/item_caret_spec.js new file mode 100644 index 00000000000..4310a07e6e6 --- /dev/null +++ b/spec/javascripts/groups/components/item_caret_spec.js @@ -0,0 +1,40 @@ +import Vue from 'vue'; + +import itemCaretComponent from '~/groups/components/item_caret.vue'; + +import mountComponent from '../../helpers/vue_mount_component_helper'; + +const createComponent = (isGroupOpen = false) => { + const Component = Vue.extend(itemCaretComponent); + + return mountComponent(Component, { + isGroupOpen, + }); +}; + +describe('ItemCaretComponent', () => { + describe('template', () => { + it('should render component template correctly', () => { + const vm = createComponent(); + vm.$mount(); + expect(vm.$el.classList.contains('folder-caret')).toBeTruthy(); + vm.$destroy(); + }); + + it('should render caret down icon if `isGroupOpen` prop is `true`', () => { + const vm = createComponent(true); + vm.$mount(); + expect(vm.$el.querySelectorAll('i.fa.fa-caret-down').length).toBe(1); + expect(vm.$el.querySelectorAll('i.fa.fa-caret-right').length).toBe(0); + vm.$destroy(); + }); + + it('should render caret right icon if `isGroupOpen` prop is `false`', () => { + const vm = createComponent(); + vm.$mount(); + expect(vm.$el.querySelectorAll('i.fa.fa-caret-down').length).toBe(0); + expect(vm.$el.querySelectorAll('i.fa.fa-caret-right').length).toBe(1); + vm.$destroy(); + }); + }); +}); diff --git a/spec/javascripts/groups/components/item_stats_spec.js b/spec/javascripts/groups/components/item_stats_spec.js new file mode 100644 index 00000000000..e200f9f08bd --- /dev/null +++ b/spec/javascripts/groups/components/item_stats_spec.js @@ -0,0 +1,159 @@ +import Vue from 'vue'; + +import itemStatsComponent from '~/groups/components/item_stats.vue'; +import { + mockParentGroupItem, + ITEM_TYPE, + VISIBILITY_TYPE_ICON, + GROUP_VISIBILITY_TYPE, + PROJECT_VISIBILITY_TYPE, +} from '../mock_data'; + +import mountComponent from '../../helpers/vue_mount_component_helper'; + +const createComponent = (item = mockParentGroupItem) => { + const Component = Vue.extend(itemStatsComponent); + + return mountComponent(Component, { + item, + }); +}; + +describe('ItemStatsComponent', () => { + describe('computed', () => { + describe('visibilityIcon', () => { + it('should return icon class based on `item.visibility` value', () => { + Object.keys(VISIBILITY_TYPE_ICON).forEach((visibility) => { + const item = Object.assign({}, mockParentGroupItem, { visibility }); + const vm = createComponent(item); + vm.$mount(); + expect(vm.visibilityIcon).toBe(VISIBILITY_TYPE_ICON[visibility]); + vm.$destroy(); + }); + }); + }); + + describe('visibilityTooltip', () => { + it('should return tooltip string for Group based on `item.visibility` value', () => { + Object.keys(GROUP_VISIBILITY_TYPE).forEach((visibility) => { + const item = Object.assign({}, mockParentGroupItem, { + visibility, + type: ITEM_TYPE.GROUP, + }); + const vm = createComponent(item); + vm.$mount(); + expect(vm.visibilityTooltip).toBe(GROUP_VISIBILITY_TYPE[visibility]); + vm.$destroy(); + }); + }); + + it('should return tooltip string for Project based on `item.visibility` value', () => { + Object.keys(PROJECT_VISIBILITY_TYPE).forEach((visibility) => { + const item = Object.assign({}, mockParentGroupItem, { + visibility, + type: ITEM_TYPE.PROJECT, + }); + const vm = createComponent(item); + vm.$mount(); + expect(vm.visibilityTooltip).toBe(PROJECT_VISIBILITY_TYPE[visibility]); + vm.$destroy(); + }); + }); + }); + + describe('isProject', () => { + it('should return boolean value representing whether `item.type` is Project or not', () => { + let item; + let vm; + + item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.PROJECT }); + vm = createComponent(item); + vm.$mount(); + expect(vm.isProject).toBeTruthy(); + vm.$destroy(); + + item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.GROUP }); + vm = createComponent(item); + vm.$mount(); + expect(vm.isProject).toBeFalsy(); + vm.$destroy(); + }); + }); + + describe('isGroup', () => { + it('should return boolean value representing whether `item.type` is Group or not', () => { + let item; + let vm; + + item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.GROUP }); + vm = createComponent(item); + vm.$mount(); + expect(vm.isGroup).toBeTruthy(); + vm.$destroy(); + + item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.PROJECT }); + vm = createComponent(item); + vm.$mount(); + expect(vm.isGroup).toBeFalsy(); + vm.$destroy(); + }); + }); + }); + + describe('template', () => { + it('should render component template correctly', () => { + const vm = createComponent(); + vm.$mount(); + + const visibilityIconEl = vm.$el.querySelector('.item-visibility'); + expect(vm.$el.classList.contains('.stats')).toBeDefined(); + expect(visibilityIconEl).toBeDefined(); + expect(visibilityIconEl.dataset.originalTitle).toBe(vm.visibilityTooltip); + expect(visibilityIconEl.querySelector('i.fa')).toBeDefined(); + + vm.$destroy(); + }); + + it('should render stat icons if `item.type` is Group', () => { + const item = Object.assign({}, mockParentGroupItem, { type: ITEM_TYPE.GROUP }); + const vm = createComponent(item); + vm.$mount(); + + const subgroupIconEl = vm.$el.querySelector('span.number-subgroups'); + expect(subgroupIconEl).toBeDefined(); + expect(subgroupIconEl.dataset.originalTitle).toBe('Subgroups'); + expect(subgroupIconEl.querySelector('i.fa.fa-folder')).toBeDefined(); + expect(subgroupIconEl.innerText.trim()).toBe(`${vm.item.subgroupCount}`); + + const projectsIconEl = vm.$el.querySelector('span.number-projects'); + expect(projectsIconEl).toBeDefined(); + expect(projectsIconEl.dataset.originalTitle).toBe('Projects'); + expect(projectsIconEl.querySelector('i.fa.fa-bookmark')).toBeDefined(); + expect(projectsIconEl.innerText.trim()).toBe(`${vm.item.projectCount}`); + + const membersIconEl = vm.$el.querySelector('span.number-users'); + expect(membersIconEl).toBeDefined(); + expect(membersIconEl.dataset.originalTitle).toBe('Members'); + expect(membersIconEl.querySelector('i.fa.fa-users')).toBeDefined(); + expect(membersIconEl.innerText.trim()).toBe(`${vm.item.memberCount}`); + + vm.$destroy(); + }); + + it('should render stat icons if `item.type` is Project', () => { + const item = Object.assign({}, mockParentGroupItem, { + type: ITEM_TYPE.PROJECT, + starCount: 4, + }); + const vm = createComponent(item); + vm.$mount(); + + const projectStarIconEl = vm.$el.querySelector('.project-stars'); + expect(projectStarIconEl).toBeDefined(); + expect(projectStarIconEl.querySelector('i.fa.fa-star')).toBeDefined(); + expect(projectStarIconEl.innerText.trim()).toBe(`${vm.item.starCount}`); + + vm.$destroy(); + }); + }); +}); diff --git a/spec/javascripts/groups/components/item_type_icon_spec.js b/spec/javascripts/groups/components/item_type_icon_spec.js new file mode 100644 index 00000000000..528e6ed1b4c --- /dev/null +++ b/spec/javascripts/groups/components/item_type_icon_spec.js @@ -0,0 +1,54 @@ +import Vue from 'vue'; + +import itemTypeIconComponent from '~/groups/components/item_type_icon.vue'; +import { ITEM_TYPE } from '../mock_data'; + +import mountComponent from '../../helpers/vue_mount_component_helper'; + +const createComponent = (itemType = ITEM_TYPE.GROUP, isGroupOpen = false) => { + const Component = Vue.extend(itemTypeIconComponent); + + return mountComponent(Component, { + itemType, + isGroupOpen, + }); +}; + +describe('ItemTypeIconComponent', () => { + describe('template', () => { + it('should render component template correctly', () => { + const vm = createComponent(); + vm.$mount(); + expect(vm.$el.classList.contains('item-type-icon')).toBeTruthy(); + vm.$destroy(); + }); + + it('should render folder open or close icon based `isGroupOpen` prop value', () => { + let vm; + + vm = createComponent(ITEM_TYPE.GROUP, true); + vm.$mount(); + expect(vm.$el.querySelector('i.fa.fa-folder-open')).toBeDefined(); + vm.$destroy(); + + vm = createComponent(ITEM_TYPE.GROUP); + vm.$mount(); + expect(vm.$el.querySelector('i.fa.fa-folder')).toBeDefined(); + vm.$destroy(); + }); + + it('should render bookmark icon based on `isProject` prop value', () => { + let vm; + + vm = createComponent(ITEM_TYPE.PROJECT); + vm.$mount(); + expect(vm.$el.querySelectorAll('i.fa.fa-bookmark').length).toBe(1); + vm.$destroy(); + + vm = createComponent(ITEM_TYPE.GROUP); + vm.$mount(); + expect(vm.$el.querySelectorAll('i.fa.fa-bookmark').length).toBe(0); + vm.$destroy(); + }); + }); +}); diff --git a/spec/javascripts/groups/group_item_spec.js b/spec/javascripts/groups/group_item_spec.js deleted file mode 100644 index 25e10552d95..00000000000 --- a/spec/javascripts/groups/group_item_spec.js +++ /dev/null @@ -1,102 +0,0 @@ -import Vue from 'vue'; -import groupItemComponent from '~/groups/components/group_item.vue'; -import GroupsStore from '~/groups/stores/groups_store'; -import { group1 } from './mock_data'; - -describe('Groups Component', () => { - let GroupItemComponent; - let component; - let store; - let group; - - describe('group with default data', () => { - beforeEach((done) => { - GroupItemComponent = Vue.extend(groupItemComponent); - store = new GroupsStore(); - group = store.decorateGroup(group1); - - component = new GroupItemComponent({ - propsData: { - group, - }, - }).$mount(); - - Vue.nextTick(() => { - done(); - }); - }); - - afterEach(() => { - component.$destroy(); - }); - - it('should render the group item correctly', () => { - expect(component.$el.classList.contains('group-row')).toBe(true); - expect(component.$el.classList.contains('.no-description')).toBe(false); - expect(component.$el.querySelector('.number-projects').textContent).toContain(group.numberProjects); - expect(component.$el.querySelector('.number-users').textContent).toContain(group.numberUsers); - expect(component.$el.querySelector('.group-visibility')).toBeDefined(); - expect(component.$el.querySelector('.avatar-container')).toBeDefined(); - expect(component.$el.querySelector('.title').textContent).toContain(group.name); - expect(component.$el.querySelector('.access-type').textContent).toContain(group.permissions.humanGroupAccess); - expect(component.$el.querySelector('.description').textContent).toContain(group.description); - expect(component.$el.querySelector('.edit-group')).toBeDefined(); - expect(component.$el.querySelector('.leave-group')).toBeDefined(); - }); - }); - - describe('group without description', () => { - beforeEach((done) => { - GroupItemComponent = Vue.extend(groupItemComponent); - store = new GroupsStore(); - group1.description = ''; - group = store.decorateGroup(group1); - - component = new GroupItemComponent({ - propsData: { - group, - }, - }).$mount(); - - Vue.nextTick(() => { - done(); - }); - }); - - afterEach(() => { - component.$destroy(); - }); - - it('should render group item correctly', () => { - expect(component.$el.querySelector('.description').textContent).toBe(''); - expect(component.$el.classList.contains('.no-description')).toBe(false); - }); - }); - - describe('user has not access to group', () => { - beforeEach((done) => { - GroupItemComponent = Vue.extend(groupItemComponent); - store = new GroupsStore(); - group1.permissions.human_group_access = null; - group = store.decorateGroup(group1); - - component = new GroupItemComponent({ - propsData: { - group, - }, - }).$mount(); - - Vue.nextTick(() => { - done(); - }); - }); - - afterEach(() => { - component.$destroy(); - }); - - it('should not display access type', () => { - expect(component.$el.querySelector('.access-type')).toBeNull(); - }); - }); -}); diff --git a/spec/javascripts/groups/groups_spec.js b/spec/javascripts/groups/groups_spec.js deleted file mode 100644 index b14153dbbfa..00000000000 --- a/spec/javascripts/groups/groups_spec.js +++ /dev/null @@ -1,99 +0,0 @@ -import Vue from 'vue'; -import eventHub from '~/groups/event_hub'; -import groupFolderComponent from '~/groups/components/group_folder.vue'; -import groupItemComponent from '~/groups/components/group_item.vue'; -import groupsComponent from '~/groups/components/groups.vue'; -import GroupsStore from '~/groups/stores/groups_store'; -import { groupsData } from './mock_data'; - -describe('Groups Component', () => { - let GroupsComponent; - let store; - let component; - let groups; - - beforeEach((done) => { - Vue.component('group-folder', groupFolderComponent); - Vue.component('group-item', groupItemComponent); - - store = new GroupsStore(); - groups = store.setGroups(groupsData.groups); - - store.storePagination(groupsData.pagination); - - GroupsComponent = Vue.extend(groupsComponent); - - component = new GroupsComponent({ - propsData: { - groups: store.state.groups, - pageInfo: store.state.pageInfo, - }, - }).$mount(); - - Vue.nextTick(() => { - done(); - }); - }); - - afterEach(() => { - component.$destroy(); - }); - - describe('with data', () => { - it('should render a list of groups', () => { - expect(component.$el.classList.contains('groups-list-tree-container')).toBe(true); - expect(component.$el.querySelector('#group-12')).toBeDefined(); - expect(component.$el.querySelector('#group-1119')).toBeDefined(); - expect(component.$el.querySelector('#group-1120')).toBeDefined(); - }); - - it('should respect the order of groups', () => { - const wrap = component.$el.querySelector('.groups-list-tree-container > .group-list-tree'); - expect(wrap.querySelector('.group-row:nth-child(1)').id).toBe('group-12'); - expect(wrap.querySelector('.group-row:nth-child(2)').id).toBe('group-1119'); - }); - - it('should render group and its subgroup', () => { - const lists = component.$el.querySelectorAll('.group-list-tree'); - - expect(lists.length).toBe(3); // one parent and two subgroups - - expect(lists[0].querySelector('#group-1119').classList.contains('is-open')).toBe(true); - expect(lists[0].querySelector('#group-1119').classList.contains('has-subgroups')).toBe(true); - - expect(lists[2].querySelector('#group-1120').textContent).toContain(groups.id1119.subGroups.id1120.name); - }); - - it('should render group identicon when group avatar is not present', () => { - const avatar = component.$el.querySelector('#group-12 .avatar-container .avatar'); - expect(avatar.nodeName).toBe('DIV'); - expect(avatar.classList.contains('identicon')).toBeTruthy(); - expect(avatar.getAttribute('style').indexOf('background-color') > -1).toBeTruthy(); - }); - - it('should render group avatar when group avatar is present', () => { - const avatar = component.$el.querySelector('#group-1120 .avatar-container .avatar'); - expect(avatar.nodeName).toBe('IMG'); - expect(avatar.classList.contains('identicon')).toBeFalsy(); - }); - - it('should remove prefix of parent group', () => { - expect(component.$el.querySelector('#group-12 #group-1128 .title').textContent).toContain('level2 / level3 / level4'); - }); - - it('should remove the group after leaving the group', (done) => { - spyOn(window, 'confirm').and.returnValue(true); - - eventHub.$on('leaveGroup', (group, collection) => { - store.removeGroup(group, collection); - }); - - component.$el.querySelector('#group-12 .leave-group').click(); - - Vue.nextTick(() => { - expect(component.$el.querySelector('#group-12')).toBeNull(); - done(); - }); - }); - }); -}); diff --git a/spec/javascripts/groups/mock_data.js b/spec/javascripts/groups/mock_data.js index 5bb84b591f4..6184d671790 100644 --- a/spec/javascripts/groups/mock_data.js +++ b/spec/javascripts/groups/mock_data.js @@ -1,114 +1,380 @@ -const group1 = { - id: 12, - name: 'level1', - path: 'level1', - description: 'foo', - visibility: 'public', - avatar_url: null, - web_url: 'http://localhost:3000/groups/level1', - group_path: '/level1', - full_name: 'level1', - full_path: 'level1', - parent_id: null, - created_at: '2017-05-15T19:01:23.670Z', - updated_at: '2017-05-15T19:01:23.670Z', - number_projects_with_delimiter: '1', - number_users_with_delimiter: '1', - has_subgroups: true, - permissions: { - human_group_access: 'Master', - }, +export const mockEndpoint = '/dashboard/groups.json'; + +export const ITEM_TYPE = { + PROJECT: 'project', + GROUP: 'group', }; -// This group has no direct parent, should be placed as subgroup of group1 -const group14 = { - id: 1128, - name: 'level4', - path: 'level4', - description: 'foo', - visibility: 'public', - avatar_url: null, - web_url: 'http://localhost:3000/groups/level1/level2/level3/level4', - group_path: '/level1/level2/level3/level4', - full_name: 'level1 / level2 / level3 / level4', - full_path: 'level1/level2/level3/level4', - parent_id: 1127, - created_at: '2017-05-15T19:02:01.645Z', - updated_at: '2017-05-15T19:02:01.645Z', - number_projects_with_delimiter: '1', - number_users_with_delimiter: '1', - has_subgroups: true, - permissions: { - human_group_access: 'Master', - }, +export const GROUP_VISIBILITY_TYPE = { + public: 'Public - The group and any public projects can be viewed without any authentication.', + internal: 'Internal - The group and any internal projects can be viewed by any logged in user.', + private: 'Private - The group and its projects can only be viewed by members.', }; -const group2 = { - id: 1119, - name: 'devops', - path: 'devops', - description: 'foo', - visibility: 'public', - avatar_url: null, - web_url: 'http://localhost:3000/groups/devops', - group_path: '/devops', - full_name: 'devops', - full_path: 'devops', - parent_id: null, - created_at: '2017-05-11T19:35:09.635Z', - updated_at: '2017-05-11T19:35:09.635Z', - number_projects_with_delimiter: '1', - number_users_with_delimiter: '1', - has_subgroups: true, - permissions: { - human_group_access: 'Master', - }, +export const PROJECT_VISIBILITY_TYPE = { + public: 'Public - The project can be accessed without any authentication.', + internal: 'Internal - The project can be accessed by any logged in user.', + private: 'Private - Project access must be granted explicitly to each user.', +}; + +export const VISIBILITY_TYPE_ICON = { + public: 'fa-globe', + internal: 'fa-shield', + private: 'fa-lock', }; -const group21 = { - id: 1120, - name: 'chef', - path: 'chef', - description: 'foo', +export const mockParentGroupItem = { + id: 55, + name: 'hardware', + description: '', visibility: 'public', - avatar_url: '/uploads/-/system/group/avatar/2/GitLab.png', - web_url: 'http://localhost:3000/groups/devops/chef', - group_path: '/devops/chef', - full_name: 'devops / chef', - full_path: 'devops/chef', - parent_id: 1119, - created_at: '2017-05-11T19:51:04.060Z', - updated_at: '2017-05-11T19:51:04.060Z', - number_projects_with_delimiter: '1', - number_users_with_delimiter: '1', - has_subgroups: true, - permissions: { - human_group_access: 'Master', - }, + fullName: 'platform / hardware', + relativePath: '/platform/hardware', + canEdit: true, + type: 'group', + avatarUrl: null, + permission: 'Owner', + editPath: '/groups/platform/hardware/edit', + childrenCount: 3, + leavePath: '/groups/platform/hardware/group_members/leave', + parentId: 54, + memberCount: '1', + projectCount: 1, + subgroupCount: 2, + canLeave: false, + children: [], + isOpen: true, + isChildrenLoading: false, + isBeingRemoved: false, }; -const groupsData = { - groups: [group1, group14, group2, group21], - pagination: { - Date: 'Mon, 22 May 2017 22:31:52 GMT', - 'X-Prev-Page': '1', - 'X-Content-Type-Options': 'nosniff', - 'X-Total': '31', - 'Transfer-Encoding': 'chunked', - 'X-Runtime': '0.611144', - 'X-Xss-Protection': '1; mode=block', - 'X-Request-Id': 'f5db8368-3ce5-4aa4-89d2-a125d9dead09', - 'X-Ua-Compatible': 'IE=edge', - 'X-Per-Page': '20', - Link: '<http://localhost:3000/dashboard/groups.json?page=1&per_page=20>; rel="prev", <http://localhost:3000/dashboard/groups.json?page=1&per_page=20>; rel="first", <http://localhost:3000/dashboard/groups.json?page=2&per_page=20>; rel="last"', - 'X-Next-Page': '', - Etag: 'W/"a82f846947136271cdb7d55d19ef33d2"', - 'X-Frame-Options': 'DENY', - 'Content-Type': 'application/json; charset=utf-8', - 'Cache-Control': 'max-age=0, private, must-revalidate', - 'X-Total-Pages': '2', - 'X-Page': '2', +export const mockRawChildren = [ + { + id: 57, + name: 'bsp', + description: '', + visibility: 'public', + full_name: 'platform / hardware / bsp', + relative_path: '/platform/hardware/bsp', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/platform/hardware/bsp/edit', + children_count: 6, + leave_path: '/groups/platform/hardware/bsp/group_members/leave', + parent_id: 55, + number_users_with_delimiter: '1', + project_count: 4, + subgroup_count: 2, + can_leave: false, + children: [], + }, +]; + +export const mockChildren = [ + { + id: 57, + name: 'bsp', + description: '', + visibility: 'public', + fullName: 'platform / hardware / bsp', + relativePath: '/platform/hardware/bsp', + canEdit: true, + type: 'group', + avatarUrl: null, + permission: 'Owner', + editPath: '/groups/platform/hardware/bsp/edit', + childrenCount: 6, + leavePath: '/groups/platform/hardware/bsp/group_members/leave', + parentId: 55, + memberCount: '1', + projectCount: 4, + subgroupCount: 2, + canLeave: false, + children: [], + isOpen: true, + isChildrenLoading: false, + isBeingRemoved: false, }, +]; + +export const mockGroups = [ + { + id: 75, + name: 'test-group', + description: '', + visibility: 'public', + full_name: 'test-group', + relative_path: '/test-group', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/test-group/edit', + children_count: 2, + leave_path: '/groups/test-group/group_members/leave', + parent_id: null, + number_users_with_delimiter: '1', + project_count: 2, + subgroup_count: 0, + can_leave: false, + }, + { + id: 67, + name: 'open-source', + description: '', + visibility: 'private', + full_name: 'open-source', + relative_path: '/open-source', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/open-source/edit', + children_count: 0, + leave_path: '/groups/open-source/group_members/leave', + parent_id: null, + number_users_with_delimiter: '1', + project_count: 0, + subgroup_count: 0, + can_leave: false, + }, + { + id: 54, + name: 'platform', + description: '', + visibility: 'public', + full_name: 'platform', + relative_path: '/platform', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/platform/edit', + children_count: 1, + leave_path: '/groups/platform/group_members/leave', + parent_id: null, + number_users_with_delimiter: '1', + project_count: 0, + subgroup_count: 1, + can_leave: false, + }, + { + id: 5, + name: 'H5bp', + description: 'Minus dolor consequuntur qui nam recusandae quam incidunt.', + visibility: 'public', + full_name: 'H5bp', + relative_path: '/h5bp', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/h5bp/edit', + children_count: 1, + leave_path: '/groups/h5bp/group_members/leave', + parent_id: null, + number_users_with_delimiter: '5', + project_count: 1, + subgroup_count: 0, + can_leave: false, + }, + { + id: 4, + name: 'Twitter', + description: 'Deserunt hic nostrum placeat veniam.', + visibility: 'public', + full_name: 'Twitter', + relative_path: '/twitter', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/twitter/edit', + children_count: 2, + leave_path: '/groups/twitter/group_members/leave', + parent_id: null, + number_users_with_delimiter: '5', + project_count: 2, + subgroup_count: 0, + can_leave: false, + }, + { + id: 3, + name: 'Documentcloud', + description: 'Consequatur saepe totam ea pariatur maxime.', + visibility: 'public', + full_name: 'Documentcloud', + relative_path: '/documentcloud', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/documentcloud/edit', + children_count: 1, + leave_path: '/groups/documentcloud/group_members/leave', + parent_id: null, + number_users_with_delimiter: '5', + project_count: 1, + subgroup_count: 0, + can_leave: false, + }, + { + id: 2, + name: 'Gitlab Org', + description: 'Debitis ea quas aperiam velit doloremque ab.', + visibility: 'public', + full_name: 'Gitlab Org', + relative_path: '/gitlab-org', + can_edit: true, + type: 'group', + avatar_url: '/uploads/-/system/group/avatar/2/GitLab.png', + permission: 'Owner', + edit_path: '/groups/gitlab-org/edit', + children_count: 4, + leave_path: '/groups/gitlab-org/group_members/leave', + parent_id: null, + number_users_with_delimiter: '5', + project_count: 4, + subgroup_count: 0, + can_leave: false, + }, +]; + +export const mockSearchedGroups = [ + { + id: 55, + name: 'hardware', + description: '', + visibility: 'public', + full_name: 'platform / hardware', + relative_path: '/platform/hardware', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/platform/hardware/edit', + children_count: 3, + leave_path: '/groups/platform/hardware/group_members/leave', + parent_id: 54, + number_users_with_delimiter: '1', + project_count: 1, + subgroup_count: 2, + can_leave: false, + children: [ + { + id: 57, + name: 'bsp', + description: '', + visibility: 'public', + full_name: 'platform / hardware / bsp', + relative_path: '/platform/hardware/bsp', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/platform/hardware/bsp/edit', + children_count: 6, + leave_path: '/groups/platform/hardware/bsp/group_members/leave', + parent_id: 55, + number_users_with_delimiter: '1', + project_count: 4, + subgroup_count: 2, + can_leave: false, + children: [ + { + id: 60, + name: 'kernel', + description: '', + visibility: 'public', + full_name: 'platform / hardware / bsp / kernel', + relative_path: '/platform/hardware/bsp/kernel', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/platform/hardware/bsp/kernel/edit', + children_count: 1, + leave_path: '/groups/platform/hardware/bsp/kernel/group_members/leave', + parent_id: 57, + number_users_with_delimiter: '1', + project_count: 0, + subgroup_count: 1, + can_leave: false, + children: [ + { + id: 61, + name: 'common', + description: '', + visibility: 'public', + full_name: 'platform / hardware / bsp / kernel / common', + relative_path: '/platform/hardware/bsp/kernel/common', + can_edit: true, + type: 'group', + avatar_url: null, + permission: 'Owner', + edit_path: '/groups/platform/hardware/bsp/kernel/common/edit', + children_count: 2, + leave_path: '/groups/platform/hardware/bsp/kernel/common/group_members/leave', + parent_id: 60, + number_users_with_delimiter: '1', + project_count: 2, + subgroup_count: 0, + can_leave: false, + children: [ + { + id: 17, + name: 'v4.4', + description: 'Voluptatem qui ea error aperiam veritatis doloremque consequatur temporibus.', + visibility: 'public', + full_name: 'platform / hardware / bsp / kernel / common / v4.4', + relative_path: '/platform/hardware/bsp/kernel/common/v4.4', + can_edit: true, + type: 'project', + avatar_url: null, + permission: null, + edit_path: '/platform/hardware/bsp/kernel/common/v4.4/edit', + star_count: 0, + }, + { + id: 16, + name: 'v4.1', + description: 'Rerum expedita voluptatem doloribus neque ducimus ut hic.', + visibility: 'public', + full_name: 'platform / hardware / bsp / kernel / common / v4.1', + relative_path: '/platform/hardware/bsp/kernel/common/v4.1', + can_edit: true, + type: 'project', + avatar_url: null, + permission: null, + edit_path: '/platform/hardware/bsp/kernel/common/v4.1/edit', + star_count: 0, + }, + ], + }, + ], + }, + ], + }, + ], + }, +]; + +export const mockRawPageInfo = { + 'x-per-page': 10, + 'x-page': 10, + 'x-total': 10, + 'x-total-pages': 10, + 'x-next-page': 10, + 'x-prev-page': 10, }; -export { groupsData, group1 }; +export const mockPageInfo = { + perPage: 10, + page: 10, + total: 10, + totalPages: 10, + nextPage: 10, + prevPage: 10, +}; diff --git a/spec/javascripts/groups/service/groups_service_spec.js b/spec/javascripts/groups/service/groups_service_spec.js new file mode 100644 index 00000000000..222e75d24a4 --- /dev/null +++ b/spec/javascripts/groups/service/groups_service_spec.js @@ -0,0 +1,41 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; + +import GroupsService from '~/groups/service/groups_service'; +import { mockEndpoint, mockParentGroupItem } from '../mock_data'; + +Vue.use(VueResource); + +describe('GroupsService', () => { + let service; + + beforeEach(() => { + service = new GroupsService(mockEndpoint); + }); + + describe('getGroups', () => { + it('should return promise for `GET` request on provided endpoint', () => { + spyOn(service.groups, 'get').and.stub(); + const queryParams = { + page: 2, + filter: 'git', + sort: 'created_asc', + }; + + service.getGroups(55, 2, 'git', 'created_asc'); + expect(service.groups.get).toHaveBeenCalledWith({ parent_id: 55 }); + + service.getGroups(null, 2, 'git', 'created_asc'); + expect(service.groups.get).toHaveBeenCalledWith(queryParams); + }); + }); + + describe('leaveGroup', () => { + it('should return promise for `DELETE` request on provided endpoint', () => { + spyOn(Vue.http, 'delete').and.stub(); + + service.leaveGroup(mockParentGroupItem.leavePath); + expect(Vue.http.delete).toHaveBeenCalledWith(mockParentGroupItem.leavePath); + }); + }); +}); diff --git a/spec/javascripts/groups/store/groups_store_spec.js b/spec/javascripts/groups/store/groups_store_spec.js new file mode 100644 index 00000000000..d74f38f476e --- /dev/null +++ b/spec/javascripts/groups/store/groups_store_spec.js @@ -0,0 +1,110 @@ +import GroupsStore from '~/groups/store/groups_store'; +import { + mockGroups, mockSearchedGroups, + mockParentGroupItem, mockRawChildren, + mockRawPageInfo, +} from '../mock_data'; + +describe('ProjectsStore', () => { + describe('constructor', () => { + it('should initialize default state', () => { + let store; + + store = new GroupsStore(); + expect(Object.keys(store.state).length).toBe(2); + expect(Array.isArray(store.state.groups)).toBeTruthy(); + expect(Object.keys(store.state.pageInfo).length).toBe(0); + expect(store.hideProjects).not.toBeDefined(); + + store = new GroupsStore(true); + expect(store.hideProjects).toBeTruthy(); + }); + }); + + describe('setGroups', () => { + it('should set groups to state', () => { + const store = new GroupsStore(); + spyOn(store, 'formatGroupItem').and.callThrough(); + + store.setGroups(mockGroups); + expect(store.state.groups.length).toBe(mockGroups.length); + expect(store.formatGroupItem).toHaveBeenCalledWith(jasmine.any(Object)); + expect(Object.keys(store.state.groups[0]).indexOf('fullName') > -1).toBeTruthy(); + }); + }); + + describe('setSearchedGroups', () => { + it('should set searched groups to state', () => { + const store = new GroupsStore(); + spyOn(store, 'formatGroupItem').and.callThrough(); + + store.setSearchedGroups(mockSearchedGroups); + expect(store.state.groups.length).toBe(mockSearchedGroups.length); + expect(store.formatGroupItem).toHaveBeenCalledWith(jasmine.any(Object)); + expect(Object.keys(store.state.groups[0]).indexOf('fullName') > -1).toBeTruthy(); + expect(Object.keys(store.state.groups[0].children[0]).indexOf('fullName') > -1).toBeTruthy(); + }); + }); + + describe('setGroupChildren', () => { + it('should set children to group item in state', () => { + const store = new GroupsStore(); + spyOn(store, 'formatGroupItem').and.callThrough(); + + store.setGroupChildren(mockParentGroupItem, mockRawChildren); + expect(store.formatGroupItem).toHaveBeenCalledWith(jasmine.any(Object)); + expect(mockParentGroupItem.children.length).toBe(1); + expect(Object.keys(mockParentGroupItem.children[0]).indexOf('fullName') > -1).toBeTruthy(); + expect(mockParentGroupItem.isOpen).toBeTruthy(); + expect(mockParentGroupItem.isChildrenLoading).toBeFalsy(); + }); + }); + + describe('setPaginationInfo', () => { + it('should parse and set pagination info in state', () => { + const store = new GroupsStore(); + + store.setPaginationInfo(mockRawPageInfo); + expect(store.state.pageInfo.perPage).toBe(10); + expect(store.state.pageInfo.page).toBe(10); + expect(store.state.pageInfo.total).toBe(10); + expect(store.state.pageInfo.totalPages).toBe(10); + expect(store.state.pageInfo.nextPage).toBe(10); + expect(store.state.pageInfo.previousPage).toBe(10); + }); + }); + + describe('formatGroupItem', () => { + it('should parse group item object and return updated object', () => { + let store; + let updatedGroupItem; + + store = new GroupsStore(); + updatedGroupItem = store.formatGroupItem(mockRawChildren[0]); + expect(Object.keys(updatedGroupItem).indexOf('fullName') > -1).toBeTruthy(); + expect(updatedGroupItem.childrenCount).toBe(mockRawChildren[0].children_count); + expect(updatedGroupItem.isChildrenLoading).toBe(false); + expect(updatedGroupItem.isBeingRemoved).toBe(false); + + store = new GroupsStore(true); + updatedGroupItem = store.formatGroupItem(mockRawChildren[0]); + expect(Object.keys(updatedGroupItem).indexOf('fullName') > -1).toBeTruthy(); + expect(updatedGroupItem.childrenCount).toBe(mockRawChildren[0].subgroup_count); + }); + }); + + describe('removeGroup', () => { + it('should remove children from group item in state', () => { + const store = new GroupsStore(); + const rawParentGroup = Object.assign({}, mockGroups[0]); + const rawChildGroup = Object.assign({}, mockGroups[1]); + + store.setGroups([rawParentGroup]); + store.setGroupChildren(store.state.groups[0], [rawChildGroup]); + const childItem = store.state.groups[0].children[0]; + + store.removeGroup(childItem, store.state.groups[0]); + expect(store.state.groups[0].children.length).toBe(0); + }); + }); +}); diff --git a/spec/lib/gitlab/group_hierarchy_spec.rb b/spec/lib/gitlab/group_hierarchy_spec.rb index 8dc83a6db7f..30686634af4 100644 --- a/spec/lib/gitlab/group_hierarchy_spec.rb +++ b/spec/lib/gitlab/group_hierarchy_spec.rb @@ -18,6 +18,12 @@ describe Gitlab::GroupHierarchy, :postgresql do expect(relation).to include(parent, child1) end + it 'can find ancestors upto a certain level' do + relation = described_class.new(Group.where(id: child2)).base_and_ancestors(upto: child1) + + expect(relation).to contain_exactly(child2) + end + it 'uses ancestors_base #initialize argument' do relation = described_class.new(Group.where(id: child2.id), Group.none).base_and_ancestors @@ -55,6 +61,28 @@ describe Gitlab::GroupHierarchy, :postgresql do end end + describe '#descendants' do + it 'includes only the descendants' do + relation = described_class.new(Group.where(id: parent)).descendants + + expect(relation).to contain_exactly(child1, child2) + end + end + + describe '#ancestors' do + it 'includes only the ancestors' do + relation = described_class.new(Group.where(id: child2)).ancestors + + expect(relation).to contain_exactly(child1, parent) + end + + it 'can find ancestors upto a certain level' do + relation = described_class.new(Group.where(id: child2)).ancestors(upto: child1) + + expect(relation).to be_empty + end + end + describe '#all_groups' do let(:relation) do described_class.new(Group.where(id: child1.id)).all_groups diff --git a/spec/lib/gitlab/multi_collection_paginator_spec.rb b/spec/lib/gitlab/multi_collection_paginator_spec.rb new file mode 100644 index 00000000000..385cfa63dda --- /dev/null +++ b/spec/lib/gitlab/multi_collection_paginator_spec.rb @@ -0,0 +1,46 @@ +require 'spec_helper' + +describe Gitlab::MultiCollectionPaginator do + subject(:paginator) { described_class.new(Project.all, Group.all, per_page: 3) } + + it 'combines both collections' do + project = create(:project) + group = create(:group) + + expect(paginator.paginate(1)).to eq([project, group]) + end + + it 'includes elements second collection if first collection is empty' do + group = create(:group) + + expect(paginator.paginate(1)).to eq([group]) + end + + context 'with a full first page' do + let!(:all_groups) { create_list(:group, 4) } + let!(:all_projects) { create_list(:project, 4) } + + it 'knows the total count of the collection' do + expect(paginator.total_count).to eq(8) + end + + it 'fills the first page with elements of the first collection' do + expect(paginator.paginate(1)).to eq(all_projects.take(3)) + end + + it 'fils the second page with a mixture of of the first & second collection' do + first_collection_element = all_projects.last + second_collection_elements = all_groups.take(2) + + expected_collection = [first_collection_element] + second_collection_elements + + expect(paginator.paginate(2)).to eq(expected_collection) + end + + it 'fils the last page with elements from the second collection' do + expected_collection = all_groups[-2..-1] + + expect(paginator.paginate(3)).to eq(expected_collection) + end + end +end diff --git a/spec/lib/gitlab/path_regex_spec.rb b/spec/lib/gitlab/path_regex_spec.rb index 1f1c48ee9b5..f1f188cbfb5 100644 --- a/spec/lib/gitlab/path_regex_spec.rb +++ b/spec/lib/gitlab/path_regex_spec.rb @@ -213,7 +213,7 @@ describe Gitlab::PathRegex do it 'accepts group routes' do expect(subject).to match('activity/') expect(subject).to match('group_members/') - expect(subject).to match('subgroups/') + expect(subject).to match('labels/') end it 'is not case sensitive' do @@ -246,7 +246,7 @@ describe Gitlab::PathRegex do it 'accepts group routes' do expect(subject).to match('activity/') expect(subject).to match('group_members/') - expect(subject).to match('subgroups/') + expect(subject).to match('labels/') end end @@ -268,7 +268,7 @@ describe Gitlab::PathRegex do it 'accepts group routes' do expect(subject).to match('activity/more/') expect(subject).to match('group_members/more/') - expect(subject).to match('subgroups/more/') + expect(subject).to match('labels/more/') end end end @@ -292,7 +292,7 @@ describe Gitlab::PathRegex do it 'rejects group routes' do expect(subject).not_to match('root/activity/') expect(subject).not_to match('root/group_members/') - expect(subject).not_to match('root/subgroups/') + expect(subject).not_to match('root/labels/') end end @@ -314,7 +314,7 @@ describe Gitlab::PathRegex do it 'rejects group routes' do expect(subject).not_to match('root/activity/more/') expect(subject).not_to match('root/group_members/more/') - expect(subject).not_to match('root/subgroups/more/') + expect(subject).not_to match('root/labels/more/') end end end @@ -349,7 +349,7 @@ describe Gitlab::PathRegex do it 'accepts group routes' do expect(subject).to match('activity/') expect(subject).to match('group_members/') - expect(subject).to match('subgroups/') + expect(subject).to match('labels/') end it 'is not case sensitive' do @@ -382,7 +382,7 @@ describe Gitlab::PathRegex do it 'accepts group routes' do expect(subject).to match('root/activity/') expect(subject).to match('root/group_members/') - expect(subject).to match('root/subgroups/') + expect(subject).to match('root/labels/') end it 'is not case sensitive' do diff --git a/spec/lib/gitlab/sql/union_spec.rb b/spec/lib/gitlab/sql/union_spec.rb index 8026fba9f0a..fe6422c32b6 100644 --- a/spec/lib/gitlab/sql/union_spec.rb +++ b/spec/lib/gitlab/sql/union_spec.rb @@ -29,5 +29,12 @@ describe Gitlab::SQL::Union do expect(union.to_sql).to include('UNION ALL') end + + it 'returns `NULL` if all relations are empty' do + empty_relation = User.none + union = described_class.new([empty_relation, empty_relation]) + + expect(union.to_sql).to eq('NULL') + end end end diff --git a/spec/lib/gitlab/utils/merge_hash_spec.rb b/spec/lib/gitlab/utils/merge_hash_spec.rb new file mode 100644 index 00000000000..4fa7bb31301 --- /dev/null +++ b/spec/lib/gitlab/utils/merge_hash_spec.rb @@ -0,0 +1,33 @@ +require 'spec_helper' +describe Gitlab::Utils::MergeHash do + describe '.crush' do + it 'can flatten a hash to each element' do + input = { hello: "world", this: { crushes: ["an entire", "hash"] } } + expected_result = [:hello, "world", :this, :crushes, "an entire", "hash"] + + expect(described_class.crush(input)).to eq(expected_result) + end + end + + describe '.elements' do + it 'deep merges an array of elements' do + input = [{ hello: ["world"] }, + { hello: "Everyone" }, + { hello: { greetings: ['Bonjour', 'Hello', 'Hallo', 'Dzień dobry'] } }, + "Goodbye", "Hallo"] + expected_output = [ + { + hello: + [ + "world", + "Everyone", + { greetings: ['Bonjour', 'Hello', 'Hallo', 'Dzień dobry'] } + ] + }, + "Goodbye" + ] + + expect(described_class.merge(input)).to eq(expected_output) + end + end +end diff --git a/spec/models/concerns/group_descendant_spec.rb b/spec/models/concerns/group_descendant_spec.rb new file mode 100644 index 00000000000..c163fb01a81 --- /dev/null +++ b/spec/models/concerns/group_descendant_spec.rb @@ -0,0 +1,166 @@ +require 'spec_helper' + +describe GroupDescendant, :nested_groups do + let(:parent) { create(:group) } + let(:subgroup) { create(:group, parent: parent) } + let(:subsub_group) { create(:group, parent: subgroup) } + + def all_preloaded_groups(*groups) + groups + [parent, subgroup, subsub_group] + end + + context 'for a group' do + describe '#hierarchy' do + it 'only queries once for the ancestors' do + # make sure the subsub_group does not have anything cached + test_group = create(:group, parent: subsub_group).reload + + query_count = ActiveRecord::QueryRecorder.new { test_group.hierarchy }.count + + expect(query_count).to eq(1) + end + + it 'only queries once for the ancestors when a top is given' do + test_group = create(:group, parent: subsub_group).reload + + recorder = ActiveRecord::QueryRecorder.new { test_group.hierarchy(subgroup) } + expect(recorder.count).to eq(1) + end + + it 'builds a hierarchy for a group' do + expected_hierarchy = { parent => { subgroup => subsub_group } } + + expect(subsub_group.hierarchy).to eq(expected_hierarchy) + end + + it 'builds a hierarchy upto a specified parent' do + expected_hierarchy = { subgroup => subsub_group } + + expect(subsub_group.hierarchy(parent)).to eq(expected_hierarchy) + end + + it 'raises an error if specifying a base that is not part of the tree' do + expect { subsub_group.hierarchy(build_stubbed(:group)) } + .to raise_error('specified top is not part of the tree') + end + end + + describe '.build_hierarchy' do + it 'combines hierarchies until the top' do + other_subgroup = create(:group, parent: parent) + other_subsub_group = create(:group, parent: subgroup) + + groups = all_preloaded_groups(other_subgroup, subsub_group, other_subsub_group) + + expected_hierarchy = { parent => [other_subgroup, { subgroup => [subsub_group, other_subsub_group] }] } + + expect(described_class.build_hierarchy(groups)).to eq(expected_hierarchy) + end + + it 'combines upto a given parent' do + other_subgroup = create(:group, parent: parent) + other_subsub_group = create(:group, parent: subgroup) + + groups = [other_subgroup, subsub_group, other_subsub_group] + groups << subgroup # Add the parent as if it was preloaded + + expected_hierarchy = [other_subgroup, { subgroup => [subsub_group, other_subsub_group] }] + expect(described_class.build_hierarchy(groups, parent)).to eq(expected_hierarchy) + end + + it 'handles building a tree out of order' do + other_subgroup = create(:group, parent: parent) + other_subgroup2 = create(:group, parent: parent) + other_subsub_group = create(:group, parent: other_subgroup) + + groups = all_preloaded_groups(subsub_group, other_subgroup2, other_subsub_group, other_subgroup) + expected_hierarchy = { parent => [{ subgroup => subsub_group }, other_subgroup2, { other_subgroup => other_subsub_group }] } + + expect(described_class.build_hierarchy(groups)).to eq(expected_hierarchy) + end + + it 'raises an error if not all elements were preloaded' do + expect { described_class.build_hierarchy([subsub_group]) } + .to raise_error('parent was not preloaded') + end + end + end + + context 'for a project' do + let(:project) { create(:project, namespace: subsub_group) } + + describe '#hierarchy' do + it 'builds a hierarchy for a project' do + expected_hierarchy = { parent => { subgroup => { subsub_group => project } } } + + expect(project.hierarchy).to eq(expected_hierarchy) + end + + it 'builds a hierarchy upto a specified parent' do + expected_hierarchy = { subsub_group => project } + + expect(project.hierarchy(subgroup)).to eq(expected_hierarchy) + end + end + + describe '.build_hierarchy' do + it 'combines hierarchies until the top' do + other_project = create(:project, namespace: parent) + other_subgroup_project = create(:project, namespace: subgroup) + + elements = all_preloaded_groups(other_project, subsub_group, other_subgroup_project) + + expected_hierarchy = { parent => [other_project, { subgroup => [subsub_group, other_subgroup_project] }] } + + expect(described_class.build_hierarchy(elements)).to eq(expected_hierarchy) + end + + it 'combines upto a given parent' do + other_project = create(:project, namespace: parent) + other_subgroup_project = create(:project, namespace: subgroup) + + elements = [other_project, subsub_group, other_subgroup_project] + elements << subgroup # Added as if it was preloaded + + expected_hierarchy = [other_project, { subgroup => [subsub_group, other_subgroup_project] }] + + expect(described_class.build_hierarchy(elements, parent)).to eq(expected_hierarchy) + end + + it 'merges to elements in the same hierarchy' do + expected_hierarchy = { parent => subgroup } + + expect(described_class.build_hierarchy([parent, subgroup])).to eq(expected_hierarchy) + end + + it 'merges complex hierarchies' do + project = create(:project, namespace: parent) + sub_project = create(:project, namespace: subgroup) + subsubsub_group = create(:group, parent: subsub_group) + subsub_project = create(:project, namespace: subsub_group) + subsubsub_project = create(:project, namespace: subsubsub_group) + other_subgroup = create(:group, parent: parent) + other_subproject = create(:project, namespace: other_subgroup) + + elements = [project, subsubsub_project, sub_project, other_subproject, subsub_project] + # Add parent groups as if they were preloaded + elements += [other_subgroup, subsubsub_group, subsub_group, subgroup] + + expected_hierarchy = [ + project, + { + subgroup => [ + { subsub_group => [{ subsubsub_group => subsubsub_project }, subsub_project] }, + sub_project + ] + }, + { other_subgroup => other_subproject } + ] + + actual_hierarchy = described_class.build_hierarchy(elements, parent) + + expect(actual_hierarchy).to eq(expected_hierarchy) + end + end + end +end diff --git a/spec/models/concerns/loaded_in_group_list_spec.rb b/spec/models/concerns/loaded_in_group_list_spec.rb new file mode 100644 index 00000000000..d64b288aa0c --- /dev/null +++ b/spec/models/concerns/loaded_in_group_list_spec.rb @@ -0,0 +1,28 @@ +require 'spec_helper' + +describe LoadedInGroupList do + let(:parent) { create(:group) } + subject(:found_group) { Group.with_selects_for_list.find_by(id: parent.id) } + + before do + create(:group, parent: parent) + create(:project, namespace: parent) + parent.add_developer(create(:user)) + end + + describe '.with_selects_for_list' do + it 'includes the preloaded counts for groups' do + found_group = Group.with_selects_for_list.find_by(id: parent.id) + + expect(found_group.preloaded_project_count).to eq(1) + expect(found_group.preloaded_subgroup_count).to eq(1) + expect(found_group.preloaded_member_count).to eq(1) + end + end + + describe '#children_count' do + it 'counts groups and projects' do + expect(found_group.children_count).to eq(2) + end + end +end diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index 2ebf6acd42a..1bd8e8a5415 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -153,6 +153,20 @@ describe Namespace do end end + describe '#ancestors_upto', :nested_groups do + let(:parent) { create(:group) } + let(:child) { create(:group, parent: parent) } + let(:child2) { create(:group, parent: child) } + + it 'returns all ancestors when no namespace is given' do + expect(child2.ancestors_upto).to contain_exactly(child, parent) + end + + it 'includes ancestors upto but excluding the given ancestor' do + expect(child2.ancestors_upto(parent)).to contain_exactly(child) + end + end + describe '#move_dir' do let(:namespace) { create(:namespace) } let!(:project) { create(:project_empty_repo, namespace: namespace) } diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index cf26dbfea49..984832b6959 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -1761,6 +1761,21 @@ describe Project do it { expect(project.gitea_import?).to be true } end + describe '#ancestors_upto', :nested_groups do + let(:parent) { create(:group) } + let(:child) { create(:group, parent: parent) } + let(:child2) { create(:group, parent: child) } + let(:project) { create(:project, namespace: child2) } + + it 'returns all ancestors when no namespace is given' do + expect(project.ancestors_upto).to contain_exactly(child2, child, parent) + end + + it 'includes ancestors upto but excluding the given ancestor' do + expect(project.ancestors_upto(parent)).to contain_exactly(child2, child) + end + end + describe '#lfs_enabled?' do let(:project) { create(:project) } @@ -2178,6 +2193,13 @@ describe Project do it { expect(project.parent).to eq(project.namespace) } end + describe '#parent_id' do + let(:project) { create(:project) } + + it { expect(project.parent_id).to eq(project.namespace_id) } + end + + describe '#parent_changed?' do let(:project) { create(:project) } diff --git a/spec/serializers/group_child_entity_spec.rb b/spec/serializers/group_child_entity_spec.rb new file mode 100644 index 00000000000..64000385781 --- /dev/null +++ b/spec/serializers/group_child_entity_spec.rb @@ -0,0 +1,92 @@ +require 'spec_helper' + +describe GroupChildEntity do + let(:user) { create(:user) } + let(:request) { double('request') } + let(:entity) { described_class.new(object, request: request) } + subject(:json) { entity.as_json } + + before do + allow(request).to receive(:current_user).and_return(user) + end + + shared_examples 'group child json' do + it 'renders json' do + is_expected.not_to be_nil + end + + %w[id + full_name + avatar_url + name + description + visibility + type + can_edit + visibility + edit_path + permission + relative_path].each do |attribute| + it "includes #{attribute}" do + expect(json[attribute.to_sym]).to be_present + end + end + end + + describe 'for a project' do + let(:object) do + create(:project, :with_avatar, + description: 'Awesomeness') + end + + before do + object.add_master(user) + end + + it 'has the correct type' do + expect(json[:type]).to eq('project') + end + + it 'includes the star count' do + expect(json[:star_count]).to be_present + end + + it_behaves_like 'group child json' + end + + describe 'for a group', :nested_groups do + let(:object) do + create(:group, :nested, :with_avatar, + description: 'Awesomeness') + end + + before do + object.add_owner(user) + end + + it 'has the correct type' do + expect(json[:type]).to eq('group') + end + + it 'counts projects and subgroups as children' do + create(:project, namespace: object) + create(:group, parent: object) + + expect(json[:children_count]).to eq(2) + end + + %w[children_count leave_path parent_id number_projects_with_delimiter number_users_with_delimiter project_count subgroup_count].each do |attribute| + it "includes #{attribute}" do + expect(json[attribute.to_sym]).to be_present + end + end + + it 'allows an owner to leave when there is another one' do + object.add_owner(create(:user)) + + expect(json[:can_leave]).to be_truthy + end + + it_behaves_like 'group child json' + end +end diff --git a/spec/serializers/group_child_serializer_spec.rb b/spec/serializers/group_child_serializer_spec.rb new file mode 100644 index 00000000000..30333386058 --- /dev/null +++ b/spec/serializers/group_child_serializer_spec.rb @@ -0,0 +1,102 @@ +require 'spec_helper' + +describe GroupChildSerializer do + let(:request) { double('request') } + let(:user) { create(:user) } + subject(:serializer) { described_class.new(current_user: user) } + + describe '#represent' do + context 'for groups' do + it 'can render a single group' do + expect(serializer.represent(build(:group))).to be_kind_of(Hash) + end + + it 'can render a collection of groups' do + expect(serializer.represent(build_list(:group, 2))).to be_kind_of(Array) + end + end + + context 'with a hierarchy', :nested_groups do + let(:parent) { create(:group) } + + subject(:serializer) do + described_class.new(current_user: user).expand_hierarchy(parent) + end + + it 'expands the subgroups' do + subgroup = create(:group, parent: parent) + subsub_group = create(:group, parent: subgroup) + + json = serializer.represent([subgroup, subsub_group]).first + subsub_group_json = json[:children].first + + expect(json[:id]).to eq(subgroup.id) + expect(subsub_group_json).not_to be_nil + expect(subsub_group_json[:id]).to eq(subsub_group.id) + end + + it 'can render a nested tree' do + subgroup1 = create(:group, parent: parent) + subsub_group1 = create(:group, parent: subgroup1) + subgroup2 = create(:group, parent: parent) + subsub_group2 = create(:group, parent: subgroup2) + + json = serializer.represent([subgroup1, subsub_group1, subgroup1, subgroup2]) + subgroup1_json = json.first + subsub_group1_json = subgroup1_json[:children].first + + expect(json.size).to eq(2) + expect(subgroup1_json[:id]).to eq(subgroup1.id) + expect(subsub_group1_json[:id]).to eq(subsub_group1.id) + end + + context 'without a specified parent' do + subject(:serializer) do + described_class.new(current_user: user).expand_hierarchy + end + + it 'can render a tree' do + subgroup = create(:group, parent: parent) + + json = serializer.represent([parent, subgroup]) + parent_json = json.first + + expect(parent_json[:id]).to eq(parent.id) + expect(parent_json[:children].first[:id]).to eq(subgroup.id) + end + end + end + + context 'for projects' do + it 'can render a single project' do + expect(serializer.represent(build(:project))).to be_kind_of(Hash) + end + + it 'can render a collection of projects' do + expect(serializer.represent(build_list(:project, 2))).to be_kind_of(Array) + end + + context 'with a hierarchy', :nested_groups do + let(:parent) { create(:group) } + + subject(:serializer) do + described_class.new(current_user: user).expand_hierarchy(parent) + end + + it 'can render a nested tree' do + subgroup1 = create(:group, parent: parent) + project1 = create(:project, namespace: subgroup1) + subgroup2 = create(:group, parent: parent) + project2 = create(:project, namespace: subgroup2) + + json = serializer.represent([project1, project2, subgroup1, subgroup2]) + project1_json, project2_json = json.map { |group_json| group_json[:children].first } + + expect(json.size).to eq(2) + expect(project1_json[:id]).to eq(project1.id) + expect(project2_json[:id]).to eq(project2.id) + end + end + end + end +end |