diff options
Diffstat (limited to 'spec/frontend/members')
22 files changed, 813 insertions, 349 deletions
diff --git a/spec/frontend/members/components/action_buttons/access_request_action_buttons_spec.js b/spec/frontend/members/components/action_buttons/access_request_action_buttons_spec.js index df5c884f42e..b94964dc482 100644 --- a/spec/frontend/members/components/action_buttons/access_request_action_buttons_spec.js +++ b/spec/frontend/members/components/action_buttons/access_request_action_buttons_spec.js @@ -38,7 +38,6 @@ describe('AccessRequestActionButtons', () => { title: 'Deny access', isAccessRequest: true, isInvite: false, - icon: 'close', }); }); diff --git a/spec/frontend/members/components/action_buttons/invite_action_buttons_spec.js b/spec/frontend/members/components/action_buttons/invite_action_buttons_spec.js index ea819b4fb83..68009708c99 100644 --- a/spec/frontend/members/components/action_buttons/invite_action_buttons_spec.js +++ b/spec/frontend/members/components/action_buttons/invite_action_buttons_spec.js @@ -39,12 +39,10 @@ describe('InviteActionButtons', () => { it('sets props correctly', () => { expect(findRemoveMemberButton().props()).toMatchObject({ memberId: member.id, - memberType: null, message: `Are you sure you want to revoke the invitation for ${member.invite.email} to join "${member.source.fullName}"`, title: 'Revoke invite', isAccessRequest: false, isInvite: true, - icon: 'remove', }); }); }); diff --git a/spec/frontend/members/components/action_buttons/leave_button_spec.js b/spec/frontend/members/components/action_buttons/leave_button_spec.js deleted file mode 100644 index ecfbf4460a6..00000000000 --- a/spec/frontend/members/components/action_buttons/leave_button_spec.js +++ /dev/null @@ -1,59 +0,0 @@ -import { GlButton } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import LeaveButton from '~/members/components/action_buttons/leave_button.vue'; -import LeaveModal from '~/members/components/modals/leave_modal.vue'; -import { LEAVE_MODAL_ID } from '~/members/constants'; -import { member } from '../../mock_data'; - -describe('LeaveButton', () => { - let wrapper; - - const createComponent = (propsData = {}) => { - wrapper = shallowMount(LeaveButton, { - propsData: { - member, - ...propsData, - }, - directives: { - GlTooltip: createMockDirective(), - GlModal: createMockDirective(), - }, - }); - }; - - const findButton = () => wrapper.findComponent(GlButton); - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('displays a tooltip', () => { - const button = findButton(); - - expect(getBinding(button.element, 'gl-tooltip')).not.toBeUndefined(); - expect(button.attributes('title')).toBe('Leave'); - }); - - it('sets `aria-label` attribute', () => { - expect(findButton().attributes('aria-label')).toBe('Leave'); - }); - - it('renders leave modal', () => { - const leaveModal = wrapper.findComponent(LeaveModal); - - expect(leaveModal.exists()).toBe(true); - expect(leaveModal.props('member')).toEqual(member); - }); - - it('triggers leave modal', () => { - const binding = getBinding(findButton().element, 'gl-modal'); - - expect(binding).not.toBeUndefined(); - expect(binding.value).toBe(LEAVE_MODAL_ID); - }); -}); diff --git a/spec/frontend/members/components/action_buttons/remove_member_button_spec.js b/spec/frontend/members/components/action_buttons/remove_member_button_spec.js index 0e5b667eb9b..cca340169b7 100644 --- a/spec/frontend/members/components/action_buttons/remove_member_button_spec.js +++ b/spec/frontend/members/components/action_buttons/remove_member_button_spec.js @@ -39,7 +39,6 @@ describe('RemoveMemberButton', () => { }, propsData: { memberId: 1, - memberType: 'GroupMember', message: 'Are you sure you want to remove John Smith?', title: 'Remove member', isAccessRequest: true, @@ -77,20 +76,9 @@ describe('RemoveMemberButton', () => { it('calls Vuex action to show `remove member` modal when clicked', () => { findButton().vm.$emit('click'); - expect(actions.showRemoveMemberModal).toHaveBeenCalledWith(expect.any(Object), modalData); - }); - - describe('button optional properties', () => { - it('has default value for category and text', () => { - createComponent(); - expect(findButton().props('category')).toBe('secondary'); - expect(findButton().text()).toBe(''); - }); - - it('allow changing value of button category and text', () => { - createComponent({ buttonCategory: 'primary', buttonText: 'Decline request' }); - expect(findButton().props('category')).toBe('primary'); - expect(findButton().text()).toBe('Decline request'); + expect(actions.showRemoveMemberModal).toHaveBeenCalledWith(expect.any(Object), { + ...modalData, + memberModelType: undefined, }); }); }); diff --git a/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js b/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js deleted file mode 100644 index 6ac46619bc9..00000000000 --- a/spec/frontend/members/components/action_buttons/user_action_buttons_spec.js +++ /dev/null @@ -1,161 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import LeaveButton from '~/members/components/action_buttons/leave_button.vue'; -import RemoveMemberButton from '~/members/components/action_buttons/remove_member_button.vue'; -import UserActionButtons from '~/members/components/action_buttons/user_action_buttons.vue'; -import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils'; -import { member, orphanedMember } from '../../mock_data'; - -describe('UserActionButtons', () => { - let wrapper; - - const createComponent = (propsData = {}) => { - wrapper = shallowMount(UserActionButtons, { - propsData: { - member, - isCurrentUser: false, - isInvitedUser: false, - ...propsData, - }, - }); - }; - - const findRemoveMemberButton = () => wrapper.findComponent(RemoveMemberButton); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('when user has `canRemove` permissions', () => { - beforeEach(() => { - createComponent({ - permissions: { - canRemove: true, - }, - }); - }); - - it('renders remove member button', () => { - expect(findRemoveMemberButton().exists()).toBe(true); - }); - - it('sets props correctly', () => { - expect(findRemoveMemberButton().props()).toEqual({ - memberId: member.id, - memberType: 'GroupMember', - message: `Are you sure you want to remove ${member.user.name} from "${member.source.fullName}"?`, - title: null, - isAccessRequest: false, - isInvite: false, - icon: '', - buttonCategory: 'secondary', - buttonText: 'Remove member', - userDeletionObstacles: { - name: member.user.name, - obstacles: parseUserDeletionObstacles(member.user), - }, - }); - }); - - describe('when member is orphaned', () => { - it('sets `message` prop correctly', () => { - createComponent({ - member: orphanedMember, - permissions: { - canRemove: true, - }, - }); - - expect(findRemoveMemberButton().props('message')).toBe( - `Are you sure you want to remove this orphaned member from "${orphanedMember.source.fullName}"?`, - ); - }); - }); - - describe('when member is the current user', () => { - it('renders leave button', () => { - createComponent({ - isCurrentUser: true, - permissions: { - canRemove: true, - }, - }); - - expect(wrapper.findComponent(LeaveButton).exists()).toBe(true); - }); - }); - }); - - describe('when user does not have `canRemove` permissions', () => { - it('does not render remove member button', () => { - createComponent({ - permissions: { - canRemove: false, - }, - }); - - expect(findRemoveMemberButton().exists()).toBe(false); - }); - }); - - describe('when group member', () => { - beforeEach(() => { - createComponent({ - member: { - ...member, - type: 'GroupMember', - }, - permissions: { - canRemove: true, - }, - }); - }); - - it('sets member type correctly', () => { - expect(findRemoveMemberButton().props().memberType).toBe('GroupMember'); - }); - }); - - describe('when project member', () => { - beforeEach(() => { - createComponent({ - member: { - ...member, - type: 'ProjectMember', - }, - permissions: { - canRemove: true, - }, - }); - }); - - it('sets member type correctly', () => { - expect(findRemoveMemberButton().props().memberType).toBe('ProjectMember'); - }); - }); - - describe('isInvitedUser', () => { - it.each` - isInvitedUser | icon | buttonText | buttonCategory - ${true} | ${'remove'} | ${null} | ${'primary'} - ${false} | ${''} | ${'Remove member'} | ${'secondary'} - `( - 'passes the correct props to remove-member-button when isInvitedUser is $isInvitedUser', - ({ isInvitedUser, icon, buttonText, buttonCategory }) => { - createComponent({ - isInvitedUser, - permissions: { - canRemove: true, - }, - }); - - expect(findRemoveMemberButton().props()).toEqual( - expect.objectContaining({ - icon, - buttonText, - buttonCategory, - }), - ); - }, - ); - }); -}); diff --git a/spec/frontend/members/components/action_dropdowns/leave_group_dropdown_item_spec.js b/spec/frontend/members/components/action_dropdowns/leave_group_dropdown_item_spec.js new file mode 100644 index 00000000000..90f5b217007 --- /dev/null +++ b/spec/frontend/members/components/action_dropdowns/leave_group_dropdown_item_spec.js @@ -0,0 +1,54 @@ +import { GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import LeaveGroupDropdownItem from '~/members/components/action_dropdowns/leave_group_dropdown_item.vue'; +import LeaveModal from '~/members/components/modals/leave_modal.vue'; +import { LEAVE_MODAL_ID } from '~/members/constants'; +import { member, permissions } from '../../mock_data'; + +describe('LeaveGroupDropdownItem', () => { + let wrapper; + const text = 'dummy'; + + const createComponent = (propsData = {}) => { + wrapper = shallowMount(LeaveGroupDropdownItem, { + propsData: { + member, + permissions, + ...propsData, + }, + directives: { + GlModal: createMockDirective(), + }, + slots: { + default: text, + }, + }); + }; + + const findDropdownItem = () => wrapper.findComponent(GlDropdownItem); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders a slot with red text', () => { + expect(findDropdownItem().html()).toContain(`<span class="gl-text-red-500">${text}</span>`); + }); + + it('contains LeaveModal component', () => { + const leaveModal = wrapper.findComponent(LeaveModal); + + expect(leaveModal.props()).toEqual({ member, permissions }); + }); + + it('binds to the LeaveModal component', () => { + const binding = getBinding(findDropdownItem().element, 'gl-modal'); + + expect(binding.value).toBe(LEAVE_MODAL_ID); + }); +}); diff --git a/spec/frontend/members/components/action_dropdowns/remove_member_dropdown_item_spec.js b/spec/frontend/members/components/action_dropdowns/remove_member_dropdown_item_spec.js new file mode 100644 index 00000000000..e1c498249d7 --- /dev/null +++ b/spec/frontend/members/components/action_dropdowns/remove_member_dropdown_item_spec.js @@ -0,0 +1,77 @@ +import { GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import Vuex from 'vuex'; +import { modalData } from 'jest/members/mock_data'; +import RemoveMemberDropdownItem from '~/members/components/action_dropdowns/remove_member_dropdown_item.vue'; +import { MEMBER_TYPES, MEMBER_MODEL_TYPE_GROUP_MEMBER } from '~/members/constants'; + +Vue.use(Vuex); + +describe('RemoveMemberDropdownItem', () => { + let wrapper; + const text = 'dummy'; + + const actions = { + showRemoveMemberModal: jest.fn(), + }; + + const createStore = (state = {}) => { + return new Vuex.Store({ + modules: { + [MEMBER_TYPES.user]: { + namespaced: true, + state: { + memberPath: '/groups/foo-bar/-/group_members/:id', + ...state, + }, + actions, + }, + }, + }); + }; + + const createComponent = (propsData = {}, state) => { + wrapper = shallowMount(RemoveMemberDropdownItem, { + store: createStore(state), + provide: { + namespace: MEMBER_TYPES.user, + }, + propsData: { + memberId: 1, + memberModelType: MEMBER_MODEL_TYPE_GROUP_MEMBER, + modalMessage: 'Are you sure you want to remove John Smith?', + isAccessRequest: true, + isInvite: true, + userDeletionObstacles: { name: 'user', obstacles: [] }, + ...propsData, + }, + slots: { + default: text, + }, + }); + }; + + const findDropdownItem = () => wrapper.findComponent(GlDropdownItem); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders a slot with red text', () => { + expect(findDropdownItem().html()).toContain(`<span class="gl-text-red-500">${text}</span>`); + }); + + it('calls Vuex action to show `remove member` modal when clicked', () => { + findDropdownItem().vm.$emit('click'); + + expect(actions.showRemoveMemberModal).toHaveBeenCalledWith(expect.any(Object), { + ...modalData, + preventRemoval: false, + }); + }); +}); diff --git a/spec/frontend/members/components/action_dropdowns/user_action_dropdown_spec.js b/spec/frontend/members/components/action_dropdowns/user_action_dropdown_spec.js new file mode 100644 index 00000000000..5a2de1cac80 --- /dev/null +++ b/spec/frontend/members/components/action_dropdowns/user_action_dropdown_spec.js @@ -0,0 +1,220 @@ +import { shallowMount } from '@vue/test-utils'; +import { sprintf } from '~/locale'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import LeaveGroupDropdownItem from '~/members/components/action_dropdowns/leave_group_dropdown_item.vue'; +import RemoveMemberDropdownItem from '~/members/components/action_dropdowns/remove_member_dropdown_item.vue'; +import UserActionDropdown from '~/members/components/action_dropdowns/user_action_dropdown.vue'; +import { I18N } from '~/members/components/action_dropdowns/constants'; +import { + MEMBER_MODEL_TYPE_GROUP_MEMBER, + MEMBER_MODEL_TYPE_PROJECT_MEMBER, +} from '~/members/constants'; +import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils'; +import { member, orphanedMember } from '../../mock_data'; + +describe('UserActionDropdown', () => { + let wrapper; + + const createComponent = (propsData = {}) => { + wrapper = shallowMount(UserActionDropdown, { + propsData: { + member, + isCurrentUser: false, + isInvitedUser: false, + ...propsData, + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + const findRemoveMemberDropdownItem = () => wrapper.findComponent(RemoveMemberDropdownItem); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when user has `canRemove` permissions', () => { + beforeEach(() => { + createComponent({ + permissions: { + canRemove: true, + }, + }); + }); + + it('renders remove member dropdown with correct text', () => { + const removeMemberDropdownItem = findRemoveMemberDropdownItem(); + expect(removeMemberDropdownItem.exists()).toBe(true); + expect(removeMemberDropdownItem.html()).toContain(I18N.removeMember); + }); + + it('displays a tooltip', () => { + const tooltip = getBinding(wrapper.element, 'gl-tooltip'); + expect(tooltip).not.toBeUndefined(); + expect(tooltip.value).toBe(I18N.actions); + }); + + it('sets props correctly', () => { + expect(findRemoveMemberDropdownItem().props()).toEqual({ + memberId: member.id, + memberModelType: MEMBER_MODEL_TYPE_GROUP_MEMBER, + modalMessage: sprintf( + I18N.confirmNormalUserRemoval, + { + userName: member.user.name, + group: member.source.fullName, + }, + false, + ), + isAccessRequest: false, + isInvite: false, + userDeletionObstacles: { + name: member.user.name, + obstacles: parseUserDeletionObstacles(member.user), + }, + preventRemoval: false, + }); + }); + + describe('when member is orphaned', () => { + it('sets `message` prop correctly', () => { + createComponent({ + member: orphanedMember, + permissions: { + canRemove: true, + }, + }); + + expect(findRemoveMemberDropdownItem().props('modalMessage')).toBe( + sprintf(I18N.confirmOrphanedUserRemoval, { group: orphanedMember.source.fullName }), + ); + }); + }); + + describe('when member is the current user', () => { + it('renders leave dropdown with correct text', () => { + createComponent({ + isCurrentUser: true, + permissions: { + canRemove: true, + }, + }); + + const leaveGroupDropdownItem = wrapper.findComponent(LeaveGroupDropdownItem); + expect(leaveGroupDropdownItem.exists()).toBe(true); + expect(leaveGroupDropdownItem.html()).toContain(I18N.leaveGroup); + }); + }); + }); + + describe('when user does not have `canRemove` permissions', () => { + it('does not render remove member dropdown', () => { + createComponent({ + permissions: { + canRemove: false, + }, + }); + + expect(findRemoveMemberDropdownItem().exists()).toBe(false); + }); + }); + + describe('when user can remove but it is blocked by last owner', () => { + const permissions = { + canRemove: false, + canRemoveBlockedByLastOwner: true, + }; + + it('renders remove member dropdown', () => { + createComponent({ + permissions, + }); + + expect(findRemoveMemberDropdownItem().exists()).toBe(true); + }); + + describe('when member model type is `GroupMember`', () => { + it('passes correct message to the modal', () => { + createComponent({ + permissions, + }); + + expect(findRemoveMemberDropdownItem().props('modalMessage')).toBe( + I18N.lastGroupOwnerCannotBeRemoved, + ); + }); + }); + + describe('when member model type is `ProjectMember`', () => { + it('passes correct message to the modal', () => { + createComponent({ + member: { + ...member, + type: MEMBER_MODEL_TYPE_PROJECT_MEMBER, + }, + permissions, + }); + + expect(findRemoveMemberDropdownItem().props('modalMessage')).toBe( + I18N.personalProjectOwnerCannotBeRemoved, + ); + }); + }); + + describe('when member is the current user', () => { + it('renders leave dropdown with correct props', () => { + createComponent({ + isCurrentUser: true, + permissions, + }); + + expect(wrapper.findComponent(LeaveGroupDropdownItem).props()).toEqual({ + member, + permissions, + }); + }); + }); + }); + + describe('when group member', () => { + beforeEach(() => { + createComponent({ + member: { + ...member, + type: MEMBER_MODEL_TYPE_GROUP_MEMBER, + }, + permissions: { + canRemove: true, + }, + }); + }); + + it('sets member type correctly', () => { + expect(findRemoveMemberDropdownItem().props().memberModelType).toBe( + MEMBER_MODEL_TYPE_GROUP_MEMBER, + ); + }); + }); + + describe('when project member', () => { + beforeEach(() => { + createComponent({ + member: { + ...member, + type: MEMBER_MODEL_TYPE_PROJECT_MEMBER, + }, + permissions: { + canRemove: true, + }, + }); + }); + + it('sets member type correctly', () => { + expect(findRemoveMemberDropdownItem().props().memberModelType).toBe( + MEMBER_MODEL_TYPE_PROJECT_MEMBER, + ); + }); + }); +}); diff --git a/spec/frontend/members/components/modals/leave_modal_spec.js b/spec/frontend/members/components/modals/leave_modal_spec.js index cdbabb2f646..ba587c6f0b3 100644 --- a/spec/frontend/members/components/modals/leave_modal_spec.js +++ b/spec/frontend/members/components/modals/leave_modal_spec.js @@ -1,11 +1,14 @@ import { GlModal, GlForm } from '@gitlab/ui'; -import { within } from '@testing-library/dom'; -import { mount, createWrapper } from '@vue/test-utils'; import { cloneDeep } from 'lodash'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; +import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper'; import LeaveModal from '~/members/components/modals/leave_modal.vue'; -import { LEAVE_MODAL_ID, MEMBER_TYPES } from '~/members/constants'; +import { + LEAVE_MODAL_ID, + MEMBER_TYPES, + MEMBER_MODEL_TYPE_PROJECT_MEMBER, +} from '~/members/constants'; import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue'; import { parseUserDeletionObstacles } from '~/vue_shared/components/user_deletion_obstacles/utils'; import { member } from '../../mock_data'; @@ -31,14 +34,17 @@ describe('LeaveModal', () => { }); }; - const createComponent = (propsData = {}, state) => { - wrapper = mount(LeaveModal, { + const createComponent = async (propsData = {}, state) => { + wrapper = mountExtended(LeaveModal, { store: createStore(state), provide: { namespace: MEMBER_TYPES.user, }, propsData: { member, + permissions: { + canRemove: true, + }, ...propsData, }, attrs: { @@ -46,39 +52,98 @@ describe('LeaveModal', () => { visible: true, }, }); + + await nextTick(); }; - const findModal = () => wrapper.findComponent(GlModal); + const findModal = () => extendedWrapper(wrapper.findComponent(GlModal)); const findForm = () => findModal().findComponent(GlForm); const findUserDeletionObstaclesList = () => findModal().findComponent(UserDeletionObstaclesList); - const getByText = (text, options) => - createWrapper(within(findModal().element).getByText(text, options)); - - beforeEach(async () => { - createComponent(); - await nextTick(); - }); - afterEach(() => { wrapper.destroy(); }); - it('sets modal ID', () => { + it('sets modal ID', async () => { + await createComponent(); + expect(findModal().props('modalId')).toBe(LEAVE_MODAL_ID); }); - it('displays modal title', () => { - expect(getByText(`Leave "${member.source.fullName}"`).exists()).toBe(true); + describe('when leave is allowed', () => { + it('displays modal title', async () => { + await createComponent(); + + expect(findModal().findByText(`Leave "${member.source.fullName}"`).exists()).toBe(true); + }); + + it('displays modal body', async () => { + await createComponent(); + + expect( + findModal() + .findByText(`Are you sure you want to leave "${member.source.fullName}"?`) + .exists(), + ).toBe(true); + }); }); - it('displays modal body', () => { - expect(getByText(`Are you sure you want to leave "${member.source.fullName}"?`).exists()).toBe( - true, - ); + describe('when leave is blocked by last owner', () => { + const permissions = { + canRemove: false, + canRemoveBlockedByLastOwner: true, + }; + + it('does not show primary action button', async () => { + await createComponent({ + permissions, + }); + + expect(findModal().props('actionPrimary')).toBe(null); + }); + + it('displays modal title', async () => { + await createComponent({ + permissions, + }); + + expect(findModal().findByText(`Cannot leave "${member.source.fullName}"`).exists()).toBe( + true, + ); + }); + + describe('when member model type is `GroupMember`', () => { + it('displays modal body', async () => { + await createComponent({ + permissions, + }); + + expect( + findModal().findByText(LeaveModal.i18n.preventedBodyGroupMemberModelType).exists(), + ).toBe(true); + }); + }); + + describe('when member model type is `ProjectMember`', () => { + it('displays modal body', async () => { + await createComponent({ + member: { + ...member, + type: MEMBER_MODEL_TYPE_PROJECT_MEMBER, + }, + permissions, + }); + + expect( + findModal().findByText(LeaveModal.i18n.preventedBodyProjectMemberModelType).exists(), + ).toBe(true); + }); + }); }); - it('displays form with correct action and inputs', () => { + it('displays form with correct action and inputs', async () => { + await createComponent(); + const form = findForm(); expect(form.attributes('action')).toBe('/groups/foo-bar/-/group_members/leave'); @@ -89,7 +154,9 @@ describe('LeaveModal', () => { }); describe('User deletion obstacles list', () => { - it("displays obstacles list when member's user is part of on-call management", () => { + it("displays obstacles list when member's user is part of on-call management", async () => { + await createComponent(); + const obstaclesList = findUserDeletionObstaclesList(); expect(obstaclesList.exists()).toBe(true); expect(obstaclesList.props()).toMatchObject({ @@ -105,17 +172,18 @@ describe('LeaveModal', () => { delete memberWithoutOncall.user.oncallSchedules; delete memberWithoutOncall.user.escalationPolicies; - createComponent({ member: memberWithoutOncall }); - await nextTick(); + await createComponent({ member: memberWithoutOncall }); expect(findUserDeletionObstaclesList().exists()).toBe(false); }); }); - it('submits the form when "Leave" button is clicked', () => { + it('submits the form when "Leave" button is clicked', async () => { + await createComponent(); + const submitSpy = jest.spyOn(findForm().element, 'submit'); - getByText('Leave').trigger('click'); + findModal().findByText('Leave').trigger('click'); expect(submitSpy).toHaveBeenCalled(); diff --git a/spec/frontend/members/components/modals/remove_member_modal_spec.js b/spec/frontend/members/components/modals/remove_member_modal_spec.js index 59b112492b8..47a03b5083a 100644 --- a/spec/frontend/members/components/modals/remove_member_modal_spec.js +++ b/spec/frontend/members/components/modals/remove_member_modal_spec.js @@ -3,7 +3,11 @@ import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import Vuex from 'vuex'; import RemoveMemberModal from '~/members/components/modals/remove_member_modal.vue'; -import { MEMBER_TYPES } from '~/members/constants'; +import { + MEMBER_TYPES, + MEMBER_MODEL_TYPE_GROUP_MEMBER, + MEMBER_MODEL_TYPE_PROJECT_MEMBER, +} from '~/members/constants'; import { OBSTACLE_TYPES } from '~/vue_shared/components/user_deletion_obstacles/constants'; import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue'; @@ -55,16 +59,16 @@ describe('RemoveMemberModal', () => { }); describe.each` - state | memberType | isAccessRequest | isInvite | actionText | removeSubMembershipsCheckboxExpected | unassignIssuablesCheckboxExpected | message | userDeletionObstacles | isPartOfOncall - ${'removing a group member'} | ${'GroupMember'} | ${false} | ${false} | ${'Remove member'} | ${true} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${{}} | ${false} - ${'removing a project member'} | ${'ProjectMember'} | ${false} | ${false} | ${'Remove member'} | ${false} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${mockObstacles} | ${true} - ${'denying an access request'} | ${'ProjectMember'} | ${true} | ${false} | ${'Deny access request'} | ${false} | ${false} | ${"Are you sure you want to deny Jane Doe's request to join the Gitlab Org / Gitlab Test project?"} | ${{}} | ${false} - ${'revoking invite'} | ${'ProjectMember'} | ${false} | ${true} | ${'Revoke invite'} | ${false} | ${false} | ${'Are you sure you want to revoke the invitation for foo@bar.com to join the Gitlab Org / Gitlab Test project?'} | ${mockObstacles} | ${false} + state | memberModelType | isAccessRequest | isInvite | actionText | removeSubMembershipsCheckboxExpected | unassignIssuablesCheckboxExpected | message | userDeletionObstacles | isPartOfOncall + ${'removing a group member'} | ${MEMBER_MODEL_TYPE_GROUP_MEMBER} | ${false} | ${false} | ${'Remove member'} | ${true} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${{}} | ${false} + ${'removing a project member'} | ${MEMBER_MODEL_TYPE_PROJECT_MEMBER} | ${false} | ${false} | ${'Remove member'} | ${false} | ${true} | ${'Are you sure you want to remove Jane Doe from the Gitlab Org / Gitlab Test project?'} | ${mockObstacles} | ${true} + ${'denying an access request'} | ${MEMBER_MODEL_TYPE_PROJECT_MEMBER} | ${true} | ${false} | ${'Deny access request'} | ${false} | ${false} | ${"Are you sure you want to deny Jane Doe's request to join the Gitlab Org / Gitlab Test project?"} | ${{}} | ${false} + ${'revoking invite'} | ${MEMBER_MODEL_TYPE_PROJECT_MEMBER} | ${false} | ${true} | ${'Revoke invite'} | ${false} | ${false} | ${'Are you sure you want to revoke the invitation for foo@bar.com to join the Gitlab Org / Gitlab Test project?'} | ${mockObstacles} | ${false} `( 'when $state', ({ actionText, - memberType, + memberModelType, isAccessRequest, isInvite, message, @@ -79,7 +83,7 @@ describe('RemoveMemberModal', () => { isInvite, message, memberPath, - memberType, + memberModelType, userDeletionObstacles, }); }); @@ -133,4 +137,28 @@ describe('RemoveMemberModal', () => { }); }, ); + + describe('when removal is prevented', () => { + const message = + 'A group must have at least one owner. To remove the member, assign a new owner.'; + + beforeEach(() => { + createComponent({ + actionText: 'Remove member', + memberModelType: MEMBER_MODEL_TYPE_GROUP_MEMBER, + isAccessRequest: false, + isInvite: false, + message, + preventRemoval: true, + }); + }); + + it('does not show primary action button', () => { + expect(findGlModal().props('actionPrimary')).toBe(null); + }); + + it('only shows the message', () => { + expect(findGlModal().text()).toBe(message); + }); + }); }); diff --git a/spec/frontend/members/components/table/__snapshots__/member_activity_spec.js.snap b/spec/frontend/members/components/table/__snapshots__/member_activity_spec.js.snap new file mode 100644 index 00000000000..a0d9bae8a0b --- /dev/null +++ b/spec/frontend/members/components/table/__snapshots__/member_activity_spec.js.snap @@ -0,0 +1,61 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MemberActivity with a member that does not have all of the fields renders \`User created\` field 1`] = ` +<div> + <!----> + + <div> + <strong> + Access granted: + </strong> + + <span> + + Aug 06, 2020 + + </span> + </div> + + <!----> +</div> +`; + +exports[`MemberActivity with a member that has all fields renders \`User created\`, \`Access granted\`, and \`Last activity\` fields 1`] = ` +<div> + <div> + <strong> + User created: + </strong> + + <span> + + Mar 10, 2022 + + </span> + </div> + + <div> + <strong> + Access granted: + </strong> + + <span> + + Jul 17, 2020 + + </span> + </div> + + <div> + <strong> + Last activity: + </strong> + + <span> + + Mar 15, 2022 + + </span> + </div> +</div> +`; diff --git a/spec/frontend/members/components/table/created_at_spec.js b/spec/frontend/members/components/table/created_at_spec.js index 793c122587d..fa31177564b 100644 --- a/spec/frontend/members/components/table/created_at_spec.js +++ b/spec/frontend/members/components/table/created_at_spec.js @@ -1,20 +1,18 @@ -import { within } from '@testing-library/dom'; -import { mount, createWrapper } from '@vue/test-utils'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import { useFakeDate } from 'helpers/fake_date'; import CreatedAt from '~/members/components/table/created_at.vue'; -import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; describe('CreatedAt', () => { // March 15th, 2020 useFakeDate(2020, 2, 15); const date = '2020-03-01T00:00:00.000'; - const dateTimeAgo = '2 weeks ago'; + const formattedDate = 'Mar 01, 2020'; let wrapper; const createComponent = (propsData) => { - wrapper = mount(CreatedAt, { + wrapper = mountExtended(CreatedAt, { propsData: { date, ...propsData, @@ -22,9 +20,6 @@ describe('CreatedAt', () => { }); }; - const getByText = (text, options) => - createWrapper(within(wrapper.element).getByText(text, options)); - afterEach(() => { wrapper.destroy(); }); @@ -35,11 +30,7 @@ describe('CreatedAt', () => { }); it('displays created at text', () => { - expect(getByText(dateTimeAgo).exists()).toBe(true); - }); - - it('uses `TimeAgoTooltip` component to display tooltip', () => { - expect(wrapper.findComponent(TimeAgoTooltip).exists()).toBe(true); + expect(wrapper.findByText(formattedDate).exists()).toBe(true); }); }); @@ -52,7 +43,7 @@ describe('CreatedAt', () => { }, }); - const link = getByText('Administrator'); + const link = wrapper.findByRole('link', { name: 'Administrator' }); expect(link.exists()).toBe(true); expect(link.attributes('href')).toBe('https://gitlab.com/root'); diff --git a/spec/frontend/members/components/table/member_action_buttons_spec.js b/spec/frontend/members/components/table/member_action_buttons_spec.js index 03cfc6ca0f6..402a5e9db27 100644 --- a/spec/frontend/members/components/table/member_action_buttons_spec.js +++ b/spec/frontend/members/components/table/member_action_buttons_spec.js @@ -2,7 +2,7 @@ import { shallowMount } from '@vue/test-utils'; import AccessRequestActionButtons from '~/members/components/action_buttons/access_request_action_buttons.vue'; import GroupActionButtons from '~/members/components/action_buttons/group_action_buttons.vue'; import InviteActionButtons from '~/members/components/action_buttons/invite_action_buttons.vue'; -import UserActionButtons from '~/members/components/action_buttons/user_action_buttons.vue'; +import UserActionDropdown from '~/members/components/action_dropdowns/user_action_dropdown.vue'; import MemberActionButtons from '~/members/components/table/member_action_buttons.vue'; import { MEMBER_TYPES } from '~/members/constants'; import { member as memberMock, group, invite, accessRequest } from '../../mock_data'; @@ -29,7 +29,7 @@ describe('MemberActionButtons', () => { it.each` memberType | member | expectedComponent | expectedComponentName - ${MEMBER_TYPES.user} | ${memberMock} | ${UserActionButtons} | ${'UserActionButtons'} + ${MEMBER_TYPES.user} | ${memberMock} | ${UserActionDropdown} | ${'UserActionDropdown'} ${MEMBER_TYPES.group} | ${group} | ${GroupActionButtons} | ${'GroupActionButtons'} ${MEMBER_TYPES.invite} | ${invite} | ${InviteActionButtons} | ${'InviteActionButtons'} ${MEMBER_TYPES.accessRequest} | ${accessRequest} | ${AccessRequestActionButtons} | ${'AccessRequestActionButtons'} diff --git a/spec/frontend/members/components/table/member_activity_spec.js b/spec/frontend/members/components/table/member_activity_spec.js new file mode 100644 index 00000000000..a372b40fd1f --- /dev/null +++ b/spec/frontend/members/components/table/member_activity_spec.js @@ -0,0 +1,40 @@ +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import MemberActivity from '~/members/components/table/member_activity.vue'; +import { member as memberMock, group as groupLinkMock } from '../../mock_data'; + +describe('MemberActivity', () => { + let wrapper; + + const defaultPropsData = { + member: memberMock, + }; + + const createComponent = ({ propsData = {} } = {}) => { + wrapper = mountExtended(MemberActivity, { + propsData: { + ...defaultPropsData, + ...propsData, + }, + }); + }; + + describe('with a member that has all fields', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders `User created`, `Access granted`, and `Last activity` fields', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + describe('with a member that does not have all of the fields', () => { + beforeEach(() => { + createComponent({ propsData: { member: groupLinkMock } }); + }); + + it('renders `User created` field', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); +}); diff --git a/spec/frontend/members/components/table/member_source_spec.js b/spec/frontend/members/components/table/member_source_spec.js index 2cd888207b1..fbfd0ca7ae7 100644 --- a/spec/frontend/members/components/table/member_source_spec.js +++ b/spec/frontend/members/components/table/member_source_spec.js @@ -1,19 +1,25 @@ -import { getByText as getByTextHelper } from '@testing-library/dom'; -import { mount, createWrapper } from '@vue/test-utils'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import MemberSource from '~/members/components/table/member_source.vue'; describe('MemberSource', () => { let wrapper; + const memberSource = { + id: 102, + fullName: 'Foo bar', + webUrl: 'https://gitlab.com/groups/foo-bar', + }; + + const createdBy = { + name: 'Administrator', + webUrl: 'https://gitlab.com/root', + }; + const createComponent = (propsData) => { - wrapper = mount(MemberSource, { + wrapper = mountExtended(MemberSource, { propsData: { - memberSource: { - id: 102, - fullName: 'Foo bar', - webUrl: 'https://gitlab.com/groups/foo-bar', - }, + memberSource, ...propsData, }, directives: { @@ -22,9 +28,6 @@ describe('MemberSource', () => { }); }; - const getByText = (text, options) => - createWrapper(getByTextHelper(wrapper.element, text, options)); - const getTooltipDirective = (elementWrapper) => getBinding(elementWrapper.element, 'gl-tooltip'); afterEach(() => { @@ -32,40 +35,69 @@ describe('MemberSource', () => { }); describe('direct member', () => { - it('displays "Direct member"', () => { - createComponent({ - isDirectMember: true, + describe('when created by is available', () => { + it('displays "Direct member by <user name>"', () => { + createComponent({ + isDirectMember: true, + createdBy, + }); + + expect(wrapper.text()).toBe('Direct member by Administrator'); + expect(wrapper.findByRole('link', { name: createdBy.name }).attributes('href')).toBe( + createdBy.webUrl, + ); }); + }); - expect(getByText('Direct member').exists()).toBe(true); + describe('when created by is not available', () => { + it('displays "Direct member"', () => { + createComponent({ + isDirectMember: true, + }); + + expect(wrapper.text()).toBe('Direct member'); + }); }); }); describe('inherited member', () => { - let sourceGroupLink; - - beforeEach(() => { - createComponent({ - isDirectMember: false, + describe('when created by is available', () => { + beforeEach(() => { + createComponent({ + isDirectMember: false, + createdBy, + }); }); - sourceGroupLink = getByText('Foo bar'); + it('displays "<group name> by <user name>"', () => { + expect(wrapper.text()).toBe('Foo bar by Administrator'); + expect(wrapper.findByRole('link', { name: memberSource.fullName }).attributes('href')).toBe( + memberSource.webUrl, + ); + expect(wrapper.findByRole('link', { name: createdBy.name }).attributes('href')).toBe( + createdBy.webUrl, + ); + }); }); - it('displays a link to source group', () => { - createComponent({ - isDirectMember: false, + describe('when created by is not available', () => { + beforeEach(() => { + createComponent({ + isDirectMember: false, + }); }); - expect(sourceGroupLink.exists()).toBe(true); - expect(sourceGroupLink.attributes('href')).toBe('https://gitlab.com/groups/foo-bar'); - }); + it('displays a link to source group', () => { + expect(wrapper.text()).toBe(memberSource.fullName); + expect(wrapper.attributes('href')).toBe(memberSource.webUrl); + }); - it('displays tooltip with "Inherited"', () => { - const tooltipDirective = getTooltipDirective(sourceGroupLink); + it('displays tooltip with "Inherited"', () => { + const tooltipDirective = getTooltipDirective(wrapper); - expect(tooltipDirective).not.toBeUndefined(); - expect(sourceGroupLink.attributes('title')).toBe('Inherited'); + expect(tooltipDirective).not.toBeUndefined(); + expect(tooltipDirective.value).toBe('Inherited'); + }); }); }); }); diff --git a/spec/frontend/members/components/table/members_table_cell_spec.js b/spec/frontend/members/components/table/members_table_cell_spec.js index 0b0140b0cdb..ac5d83d028d 100644 --- a/spec/frontend/members/components/table/members_table_cell_spec.js +++ b/spec/frontend/members/components/table/members_table_cell_spec.js @@ -3,6 +3,7 @@ import Vue from 'vue'; import Vuex from 'vuex'; import MembersTableCell from '~/members/components/table/members_table_cell.vue'; import { MEMBER_TYPES } from '~/members/constants'; +import { canRemoveBlockedByLastOwner } from '~/members/utils'; import { member as memberMock, directMember, @@ -12,6 +13,11 @@ import { accessRequest, } from '../../mock_data'; +jest.mock('~/members/utils', () => ({ + ...jest.requireActual('~/members/utils'), + canRemoveBlockedByLastOwner: jest.fn().mockImplementation(() => true), +})); + describe('MembersTableCell', () => { const WrappedComponent = { props: { @@ -55,6 +61,7 @@ describe('MembersTableCell', () => { provide: { sourceId: 1, currentUserId: 1, + canManageMembers: true, }, scopedSlots: { default: ` @@ -179,6 +186,15 @@ describe('MembersTableCell', () => { }); }); + describe('canRemoveBlockedByLastOwner', () => { + it('calls util and returns value', () => { + createComponentWithDirectMember(); + + expect(canRemoveBlockedByLastOwner).toHaveBeenCalledWith(directMember, true); + expect(findWrappedComponent().props('permissions').canRemoveBlockedByLastOwner).toBe(true); + }); + }); + describe('canResend', () => { describe('when member type is `invite`', () => { it('returns `true` when `canResend` is `true`', () => { diff --git a/spec/frontend/members/components/table/members_table_spec.js b/spec/frontend/members/components/table/members_table_spec.js index 0ed01396fcb..1d18026a410 100644 --- a/spec/frontend/members/components/table/members_table_spec.js +++ b/spec/frontend/members/components/table/members_table_spec.js @@ -8,9 +8,9 @@ import ExpirationDatepicker from '~/members/components/table/expiration_datepick import MemberActionButtons from '~/members/components/table/member_action_buttons.vue'; import MemberAvatar from '~/members/components/table/member_avatar.vue'; import MemberSource from '~/members/components/table/member_source.vue'; +import MemberActivity from '~/members/components/table/member_activity.vue'; import MembersTable from '~/members/components/table/members_table.vue'; import RoleDropdown from '~/members/components/table/role_dropdown.vue'; -import UserDate from '~/vue_shared/components/user_date.vue'; import { MEMBER_TYPES, MEMBER_STATE_CREATED, @@ -63,6 +63,7 @@ describe('MembersTable', () => { provide: { sourceId: 1, currentUserId: 1, + canManageMembers: true, namespace: MEMBER_TYPES.invite, ...provide, }, @@ -106,16 +107,14 @@ describe('MembersTable', () => { }; it.each` - field | label | member | expectedComponent - ${'account'} | ${'Account'} | ${memberMock} | ${MemberAvatar} - ${'source'} | ${'Source'} | ${memberMock} | ${MemberSource} - ${'granted'} | ${'Access granted'} | ${memberMock} | ${CreatedAt} - ${'invited'} | ${'Invited'} | ${invite} | ${CreatedAt} - ${'requested'} | ${'Requested'} | ${accessRequest} | ${CreatedAt} - ${'maxRole'} | ${'Max role'} | ${memberCanUpdate} | ${RoleDropdown} - ${'expiration'} | ${'Expiration'} | ${memberMock} | ${ExpirationDatepicker} - ${'userCreatedAt'} | ${'Created on'} | ${memberMock} | ${UserDate} - ${'lastActivityOn'} | ${'Last activity'} | ${memberMock} | ${UserDate} + field | label | member | expectedComponent + ${'account'} | ${'Account'} | ${memberMock} | ${MemberAvatar} + ${'source'} | ${'Source'} | ${memberMock} | ${MemberSource} + ${'invited'} | ${'Invited'} | ${invite} | ${CreatedAt} + ${'requested'} | ${'Requested'} | ${accessRequest} | ${CreatedAt} + ${'maxRole'} | ${'Max role'} | ${memberCanUpdate} | ${RoleDropdown} + ${'expiration'} | ${'Expiration'} | ${memberMock} | ${ExpirationDatepicker} + ${'activity'} | ${'Activity'} | ${memberMock} | ${MemberActivity} `('renders the $label field', ({ field, label, member, expectedComponent }) => { createComponent({ members: [member], @@ -202,16 +201,23 @@ describe('MembersTable', () => { canRemove: true, }; + const memberCanRemoveBlockedLastOwner = { + ...directMember, + canRemove: false, + isLastOwner: true, + }; + const memberNoPermissions = { ...memberMock, id: 2, }; describe.each` - permission | members - ${'canUpdate'} | ${[memberNoPermissions, memberCanUpdate]} - ${'canRemove'} | ${[memberNoPermissions, memberCanRemove]} - ${'canResend'} | ${[memberNoPermissions, invite]} + permission | members + ${'canUpdate'} | ${[memberNoPermissions, memberCanUpdate]} + ${'canRemove'} | ${[memberNoPermissions, memberCanRemove]} + ${'canRemoveBlockedByLastOwner'} | ${[memberNoPermissions, memberCanRemoveBlockedLastOwner]} + ${'canResend'} | ${[memberNoPermissions, invite]} `('when one of the members has $permission permissions', ({ members }) => { it('renders the "Actions" field', () => { createComponent({ members, tableFields: ['actions'] }); @@ -230,10 +236,11 @@ describe('MembersTable', () => { }); describe.each` - permission | members - ${'canUpdate'} | ${[memberMock]} - ${'canRemove'} | ${[memberMock]} - ${'canResend'} | ${[{ ...invite, invite: { ...invite.invite, canResend: false } }]} + permission | members + ${'canUpdate'} | ${[memberMock]} + ${'canRemove'} | ${[memberMock]} + ${'canRemoveBlockedByLastOwner'} | ${[memberMock]} + ${'canResend'} | ${[{ ...invite, invite: { ...invite.invite, canResend: false } }]} `('when none of the members have $permission permissions', ({ members }) => { it('does not render the "Actions" field', () => { createComponent({ members, tableFields: ['actions'] }); diff --git a/spec/frontend/members/components/table/role_dropdown_spec.js b/spec/frontend/members/components/table/role_dropdown_spec.js index b254cce4d72..a11f67be8f5 100644 --- a/spec/frontend/members/components/table/role_dropdown_spec.js +++ b/spec/frontend/members/components/table/role_dropdown_spec.js @@ -4,11 +4,14 @@ import { within } from '@testing-library/dom'; import { mount, createWrapper } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; +import waitForPromises from 'helpers/wait_for_promises'; import RoleDropdown from '~/members/components/table/role_dropdown.vue'; import { MEMBER_TYPES } from '~/members/constants'; +import { guestOverageConfirmAction } from 'ee_else_ce/members/guest_overage_confirm_action'; import { member } from '../../mock_data'; Vue.use(Vuex); +jest.mock('ee_else_ce/members/guest_overage_confirm_action'); describe('RoleDropdown', () => { let wrapper; @@ -33,6 +36,10 @@ describe('RoleDropdown', () => { wrapper = mount(RoleDropdown, { provide: { namespace: MEMBER_TYPES.user, + group: { + name: 'groupname', + path: '/grouppath/', + }, }, propsData: { member, @@ -63,12 +70,21 @@ describe('RoleDropdown', () => { const findDropdownToggle = () => wrapper.find('button[aria-haspopup="true"]'); const findDropdown = () => wrapper.findComponent(GlDropdown); + let originalGon; + + beforeEach(() => { + originalGon = window.gon; + gon.features = { showOverageOnRolePromotion: true }; + }); + afterEach(() => { + window.gon = originalGon; wrapper.destroy(); }); describe('when dropdown is open', () => { beforeEach(() => { + guestOverageConfirmAction.mockReturnValue(true); createComponent(); return findDropdownToggle().trigger('click'); @@ -113,12 +129,16 @@ describe('RoleDropdown', () => { expect($toast.show).toHaveBeenCalledWith('Role updated successfully.'); }); - it('disables dropdown while waiting for `updateMemberRole` to resolve', async () => { + it('puts dropdown in loading state while waiting for `updateMemberRole` to resolve', async () => { await getDropdownItemByText('Developer').trigger('click'); - expect(findDropdown().props('disabled')).toBe(true); + expect(findDropdown().props('loading')).toBe(true); + }); + + it('enables dropdown after `updateMemberRole` resolves', async () => { + await getDropdownItemByText('Developer').trigger('click'); - await nextTick(); + await waitForPromises(); expect(findDropdown().props('disabled')).toBe(false); }); @@ -148,4 +168,44 @@ describe('RoleDropdown', () => { expect(findDropdown().props('right')).toBe(false); }); + + describe('guestOverageConfirmAction', () => { + const mockConfirmAction = ({ confirmed }) => { + guestOverageConfirmAction.mockResolvedValueOnce(confirmed); + }; + + beforeEach(() => { + createComponent(); + + findDropdownToggle().trigger('click'); + }); + + afterEach(() => { + guestOverageConfirmAction.mockReset(); + }); + + describe('when guestOverageConfirmAction returns true', () => { + beforeEach(() => { + mockConfirmAction({ confirmed: true }); + + getDropdownItemByText('Reporter').trigger('click'); + }); + + it('calls updateMemberRole', () => { + expect(actions.updateMemberRole).toHaveBeenCalled(); + }); + }); + + describe('when guestOverageConfirmAction returns false', () => { + beforeEach(() => { + mockConfirmAction({ confirmed: false }); + + getDropdownItemByText('Reporter').trigger('click'); + }); + + it('does not call updateMemberRole', () => { + expect(actions.updateMemberRole).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/spec/frontend/members/guest_overage_confirm_action_spec.js b/spec/frontend/members/guest_overage_confirm_action_spec.js new file mode 100644 index 00000000000..d7ab54fa13b --- /dev/null +++ b/spec/frontend/members/guest_overage_confirm_action_spec.js @@ -0,0 +1,7 @@ +import { guestOverageConfirmAction } from '~/members/guest_overage_confirm_action'; + +describe('guestOverageConfirmAction', () => { + it('returns true', () => { + expect(guestOverageConfirmAction()).toBe(true); + }); +}); diff --git a/spec/frontend/members/mock_data.js b/spec/frontend/members/mock_data.js index 49c4c46c3ac..161e96c0c48 100644 --- a/spec/frontend/members/mock_data.js +++ b/spec/frontend/members/mock_data.js @@ -1,4 +1,8 @@ -import { MEMBER_TYPES, MEMBER_STATE_CREATED } from '~/members/constants'; +import { + MEMBER_TYPES, + MEMBER_STATE_CREATED, + MEMBER_MODEL_TYPE_GROUP_MEMBER, +} from '~/members/constants'; export const member = { requestedAt: null, @@ -13,7 +17,7 @@ export const member = { fullName: 'Foo Bar', webUrl: 'https://gitlab.com/groups/foo-bar', }, - type: 'GroupMember', + type: MEMBER_MODEL_TYPE_GROUP_MEMBER, state: MEMBER_STATE_CREATED, user: { id: 123, @@ -69,7 +73,7 @@ export const modalData = { isAccessRequest: true, isInvite: true, memberPath: '/groups/foo-bar/-/group_members/1', - memberType: 'GroupMember', + memberModelType: MEMBER_MODEL_TYPE_GROUP_MEMBER, message: 'Are you sure you want to remove John Smith?', userDeletionObstacles: { name: 'user', obstacles: [] }, }; @@ -123,7 +127,15 @@ export const dataAttribute = JSON.stringify({ pagination: paginationData, member_path: '/groups/foo-bar/-/group_members/:id', ldap_override_path: '/groups/ldap-group/-/group_members/:id/override', + disable_two_factor_path: '/groups/ldap-group/-/two_factor_auth', }, source_id: 234, can_manage_members: true, }); + +export const permissions = { + canRemove: true, + canRemoveBlockedByLastOwner: false, + canResend: true, + canUpdate: true, +}; diff --git a/spec/frontend/members/store/actions_spec.js b/spec/frontend/members/store/actions_spec.js index 20dce639177..38214048b23 100644 --- a/spec/frontend/members/store/actions_spec.js +++ b/spec/frontend/members/store/actions_spec.js @@ -4,7 +4,7 @@ import { noop } from 'lodash'; import { useFakeDate } from 'helpers/fake_date'; import testAction from 'helpers/vuex_action_helper'; import { members, group, modalData } from 'jest/members/mock_data'; -import httpStatusCodes from '~/lib/utils/http_status'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import { updateMemberRole, showRemoveGroupLinkModal, @@ -44,7 +44,7 @@ describe('Vuex members actions', () => { describe('successful request', () => { it(`commits ${types.RECEIVE_MEMBER_ROLE_SUCCESS} mutation`, async () => { - mock.onPut().replyOnce(httpStatusCodes.OK); + mock.onPut().replyOnce(HTTP_STATUS_OK); await testAction(updateMemberRole, payload, state, [ { @@ -83,7 +83,7 @@ describe('Vuex members actions', () => { describe('successful request', () => { describe('changing expiration date', () => { it(`commits ${types.RECEIVE_MEMBER_EXPIRATION_SUCCESS} mutation`, async () => { - mock.onPut().replyOnce(httpStatusCodes.OK); + mock.onPut().replyOnce(HTTP_STATUS_OK); await testAction(updateMemberExpiration, { memberId, expiresAt }, state, [ { @@ -98,7 +98,7 @@ describe('Vuex members actions', () => { describe('removing the expiration date', () => { it(`commits ${types.RECEIVE_MEMBER_EXPIRATION_SUCCESS} mutation`, async () => { - mock.onPut().replyOnce(httpStatusCodes.OK); + mock.onPut().replyOnce(HTTP_STATUS_OK); await testAction(updateMemberExpiration, { memberId, expiresAt: null }, state, [ { diff --git a/spec/frontend/members/utils_spec.js b/spec/frontend/members/utils_spec.js index 8bef2096a2a..9f200324c02 100644 --- a/spec/frontend/members/utils_spec.js +++ b/spec/frontend/members/utils_spec.js @@ -13,8 +13,10 @@ import { isDirectMember, isCurrentUser, canRemove, + canRemoveBlockedByLastOwner, canResend, canUpdate, + canDisableTwoFactor, canOverride, parseSortParam, buildSortHref, @@ -129,6 +131,17 @@ describe('Members Utils', () => { }); }); + describe('canRemoveBlockedByLastOwner', () => { + it.each` + member | canManageMembers | expected + ${{ ...directMember, isLastOwner: true }} | ${true} | ${true} + ${{ ...inheritedMember, isLastOwner: false }} | ${true} | ${false} + ${{ ...directMember, isLastOwner: true }} | ${false} | ${false} + `('returns $expected', ({ member, canManageMembers, expected }) => { + expect(canRemoveBlockedByLastOwner(member, canManageMembers)).toBe(expected); + }); + }); + describe('canResend', () => { it.each` member | expected @@ -151,6 +164,19 @@ describe('Members Utils', () => { }); }); + describe('canDisableTwoFactor', () => { + it.each` + member | expected + ${{ ...memberMock, canGetTwoFactorDisabled: true }} | ${false} + ${{ ...memberMock, canGetTwoFactorDisabled: false }} | ${false} + `( + 'returns $expected for members whose two factor authentication can be disabled', + ({ member, expected }) => { + expect(canDisableTwoFactor(member)).toBe(expected); + }, + ); + }); + describe('canOverride', () => { it('returns `false`', () => { expect(canOverride(memberMock)).toBe(false); |