diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-09-15 21:11:29 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-09-15 21:11:29 +0300 |
commit | 27d1ed4ddff6c2649544a968c2842140272d9c9d (patch) | |
tree | 93a68b94ece233b47284a9c7ad8cabf31465212c /spec | |
parent | 6e2dde590e694c13efdd441e058a925dcff17258 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec')
33 files changed, 590 insertions, 48 deletions
diff --git a/spec/controllers/explore/projects_controller_spec.rb b/spec/controllers/explore/projects_controller_spec.rb index a2b62aa49d2..02ed954508e 100644 --- a/spec/controllers/explore/projects_controller_spec.rb +++ b/spec/controllers/explore/projects_controller_spec.rb @@ -200,6 +200,24 @@ RSpec.describe Explore::ProjectsController do let(:sorting_param) { 'created_asc' } end end + + describe 'GET #index' do + let(:controller_action) { :index } + let(:params_with_name) { { name: 'some project' } } + + context 'when disable_anonymous_search is enabled' do + before do + stub_feature_flags(disable_anonymous_search: true) + end + + it 'does not show a flash message' do + sign_in(create(:user)) + get controller_action, params: params_with_name + + expect(flash[:notice]).to be_nil + end + end + end end context 'when user is not signed in' do @@ -229,5 +247,50 @@ RSpec.describe Explore::ProjectsController do expect(response).to redirect_to new_user_session_path end end + + describe 'GET #index' do + let(:controller_action) { :index } + let(:params_with_name) { { name: 'some project' } } + + context 'when disable_anonymous_search is enabled' do + before do + stub_feature_flags(disable_anonymous_search: true) + end + + it 'shows a flash message' do + get controller_action, params: params_with_name + + expect(flash[:notice]).to eq('You must sign in to search for specific projects.') + end + + context 'when search param is not given' do + it 'does not show a flash message' do + get controller_action + + expect(flash[:notice]).to be_nil + end + end + + context 'when format is not HTML' do + it 'does not show a flash message' do + get controller_action, params: params_with_name.merge(format: :atom) + + expect(flash[:notice]).to be_nil + end + end + end + + context 'when disable_anonymous_search is disabled' do + before do + stub_feature_flags(disable_anonymous_search: false) + end + + it 'does not show a flash message' do + get controller_action, params: params_with_name + + expect(flash[:notice]).to be_nil + end + end + end end end diff --git a/spec/features/admin/admin_users_impersonation_tokens_spec.rb b/spec/features/admin/admin_users_impersonation_tokens_spec.rb index 7466150addf..0966032ff37 100644 --- a/spec/features/admin/admin_users_impersonation_tokens_spec.rb +++ b/spec/features/admin/admin_users_impersonation_tokens_spec.rb @@ -42,7 +42,7 @@ RSpec.describe 'Admin > Users > Impersonation Tokens', :js do click_on "Create impersonation token" expect(active_impersonation_tokens).to have_text(name) - expect(active_impersonation_tokens).to have_text('In') + expect(active_impersonation_tokens).to have_text('in') expect(active_impersonation_tokens).to have_text('api') expect(active_impersonation_tokens).to have_text('read_user') expect(PersonalAccessTokensFinder.new(impersonation: true).execute.count).to equal(1) @@ -59,6 +59,14 @@ RSpec.describe 'Admin > Users > Impersonation Tokens', :js do expect(active_impersonation_tokens).to have_text(impersonation_token.name) expect(active_impersonation_tokens).not_to have_text(personal_access_token.name) + expect(active_impersonation_tokens).to have_text('in') + end + + it 'shows absolute times' do + admin.update!(time_display_relative: false) + visit admin_user_impersonation_tokens_path(user_id: user.username) + + expect(active_impersonation_tokens).to have_text(personal_access_token.expires_at.strftime('%b %d')) end end diff --git a/spec/features/groups/settings/repository_spec.rb b/spec/features/groups/settings/repository_spec.rb index 7082b2b20bd..d95eaf3c92c 100644 --- a/spec/features/groups/settings/repository_spec.rb +++ b/spec/features/groups/settings/repository_spec.rb @@ -18,11 +18,11 @@ RSpec.describe 'Group Repository settings' do before do stub_container_registry_config(enabled: true) - visit group_settings_repository_path(group) end it_behaves_like 'a deploy token in settings' do let(:entity_type) { 'group' } + let(:page_path) { group_settings_repository_path(group) } end end diff --git a/spec/features/issues/issue_sidebar_spec.rb b/spec/features/issues/issue_sidebar_spec.rb index e198d9d4ebb..bd4be755a92 100644 --- a/spec/features/issues/issue_sidebar_spec.rb +++ b/spec/features/issues/issue_sidebar_spec.rb @@ -117,7 +117,7 @@ RSpec.describe 'Issue Sidebar' do page.within '.dropdown-menu-user' do expect(page).to have_link('Invite members') - expect(page).to have_selector('[data-track-event="click_invite_members"]') + expect(page).to have_selector('[data-track-action="click_invite_members"]') expect(page).to have_selector('[data-track-label="edit_assignee"]') end diff --git a/spec/features/merge_request/user_edits_reviewers_sidebar_spec.rb b/spec/features/merge_request/user_edits_reviewers_sidebar_spec.rb index 45ee914de9d..caf0c609f64 100644 --- a/spec/features/merge_request/user_edits_reviewers_sidebar_spec.rb +++ b/spec/features/merge_request/user_edits_reviewers_sidebar_spec.rb @@ -26,7 +26,7 @@ RSpec.describe 'Merge request > User edits reviewers sidebar', :js do page.within '.dropdown-menu-user' do expect(page).to have_link('Invite Members') - expect(page).to have_selector('[data-track-event="click_invite_members"]') + expect(page).to have_selector('[data-track-action="click_invite_members"]') expect(page).to have_selector('[data-track-label="edit_reviewer"]') end diff --git a/spec/features/profiles/personal_access_tokens_spec.rb b/spec/features/profiles/personal_access_tokens_spec.rb index de511e99182..8025db9f86d 100644 --- a/spec/features/profiles/personal_access_tokens_spec.rb +++ b/spec/features/profiles/personal_access_tokens_spec.rb @@ -56,7 +56,7 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do click_on "Create personal access token" expect(active_personal_access_tokens).to have_text(name) - expect(active_personal_access_tokens).to have_text('In') + expect(active_personal_access_tokens).to have_text('in') expect(active_personal_access_tokens).to have_text('api') expect(active_personal_access_tokens).to have_text('read_user') expect(created_personal_access_token).not_to be_empty @@ -85,6 +85,18 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do expect(active_personal_access_tokens).to have_text(personal_access_token.name) expect(active_personal_access_tokens).not_to have_text(impersonation_token.name) end + + context 'when User#time_display_relative is false' do + before do + user.update!(time_display_relative: false) + end + + it 'shows absolute times for expires_at' do + visit profile_personal_access_tokens_path + + expect(active_personal_access_tokens).to have_text(PersonalAccessToken.last.expires_at.strftime('%b %d')) + end + end end describe "inactive tokens" do diff --git a/spec/features/projects/settings/access_tokens_spec.rb b/spec/features/projects/settings/access_tokens_spec.rb index 33e2623522e..deeab084c5f 100644 --- a/spec/features/projects/settings/access_tokens_spec.rb +++ b/spec/features/projects/settings/access_tokens_spec.rb @@ -65,7 +65,7 @@ RSpec.describe 'Project > Settings > Access Tokens', :js do click_on 'Create project access token' expect(active_project_access_tokens).to have_text(name) - expect(active_project_access_tokens).to have_text('In') + expect(active_project_access_tokens).to have_text('in') expect(active_project_access_tokens).to have_text('api') expect(active_project_access_tokens).to have_text('read_api') expect(active_project_access_tokens).to have_text('Maintainer') @@ -156,6 +156,18 @@ RSpec.describe 'Project > Settings > Access Tokens', :js do expect(active_project_access_tokens).to have_text(project_access_token.name) end + + context 'when User#time_display_relative is false' do + before do + user.update!(time_display_relative: false) + end + + it 'shows absolute times for expires_at' do + visit project_settings_access_tokens_path(project) + + expect(active_project_access_tokens).to have_text(PersonalAccessToken.last.expires_at.strftime('%b %d')) + end + end end describe 'inactive tokens' do diff --git a/spec/features/projects/settings/repository_settings_spec.rb b/spec/features/projects/settings/repository_settings_spec.rb index f420a8a76b9..4e1b55d3d70 100644 --- a/spec/features/projects/settings/repository_settings_spec.rb +++ b/spec/features/projects/settings/repository_settings_spec.rb @@ -31,11 +31,11 @@ RSpec.describe 'Projects > Settings > Repository settings' do before do stub_container_registry_config(enabled: true) stub_feature_flags(ajax_new_deploy_token: project) - visit project_settings_repository_path(project) end it_behaves_like 'a deploy token in settings' do let(:entity_type) { 'project' } + let(:page_path) { project_settings_repository_path(project) } end end diff --git a/spec/finders/projects_finder_spec.rb b/spec/finders/projects_finder_spec.rb index 1fdc05ca2d2..de244856e12 100644 --- a/spec/finders/projects_finder_spec.rb +++ b/spec/finders/projects_finder_spec.rb @@ -190,6 +190,32 @@ RSpec.describe ProjectsFinder do it { is_expected.to eq([public_project]) } end + context 'with anonymous user' do + let(:public_project_2) { create(:project, :public, group: group, name: 'E', path: 'E') } + let(:current_user) { nil } + let(:params) { { search: 'C' } } + + context 'with disable_anonymous_search feature flag enabled' do + before do + stub_feature_flags(disable_anonymous_search: true) + end + + it 'does not perform search' do + is_expected.to eq([public_project_2, public_project]) + end + end + + context 'with disable_anonymous_search feature flag disabled' do + before do + stub_feature_flags(disable_anonymous_search: false) + end + + it 'finds one public project' do + is_expected.to eq([public_project]) + end + end + end + describe 'filter by name for backward compatibility' do let(:params) { { name: 'C' } } diff --git a/spec/frontend/authentication/two_factor_auth/components/recovery_codes_spec.js b/spec/frontend/authentication/two_factor_auth/components/recovery_codes_spec.js index b77def195b6..2dcc537809f 100644 --- a/spec/frontend/authentication/two_factor_auth/components/recovery_codes_spec.js +++ b/spec/frontend/authentication/two_factor_auth/components/recovery_codes_spec.js @@ -78,7 +78,7 @@ describe('RecoveryCodes', () => { it('fires Snowplow event', () => { expect(findProceedButton().attributes()).toMatchObject({ - 'data-track-event': 'click_button', + 'data-track-action': 'click_button', 'data-track-label': '2fa_recovery_codes_proceed_button', }); }); diff --git a/spec/frontend/deprecated_jquery_dropdown_spec.js b/spec/frontend/deprecated_jquery_dropdown_spec.js index 7858f88f8c3..4a6dee31cd5 100644 --- a/spec/frontend/deprecated_jquery_dropdown_spec.js +++ b/spec/frontend/deprecated_jquery_dropdown_spec.js @@ -323,7 +323,7 @@ describe('deprecatedJQueryDropdown', () => { const li = dropdown.renderItem(item, null, 3); const link = li.querySelector('a'); - expect(link).toHaveAttr('data-track-event', 'click_text'); + expect(link).toHaveAttr('data-track-action', 'click_text'); expect(link).toHaveAttr('data-track-label', 'some_value_for_label'); expect(link).toHaveAttr('data-track-value', '3'); expect(link).toHaveAttr('data-track-property', 'suggestion-category'); diff --git a/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap b/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap index d9f5ba0bade..4dc8eaea174 100644 --- a/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap +++ b/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Design reply form component renders button text as "Comment" when creating a comment 1`] = ` -"<button data-track-event=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn gl-mr-3 gl-w-auto! btn-confirm btn-md disabled gl-button\\"> +"<button data-track-action=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn gl-mr-3 gl-w-auto! btn-confirm btn-md disabled gl-button\\"> <!----> <!----> <span class=\\"gl-button-text\\"> Comment @@ -9,7 +9,7 @@ exports[`Design reply form component renders button text as "Comment" when creat `; exports[`Design reply form component renders button text as "Save comment" when creating a comment 1`] = ` -"<button data-track-event=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn gl-mr-3 gl-w-auto! btn-confirm btn-md disabled gl-button\\"> +"<button data-track-action=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn gl-mr-3 gl-w-auto! btn-confirm btn-md disabled gl-button\\"> <!----> <!----> <span class=\\"gl-button-text\\"> Save comment diff --git a/spec/frontend/header_search/components/app_spec.js b/spec/frontend/header_search/components/app_spec.js index 523d3bd7b23..2cbcb73ce5b 100644 --- a/spec/frontend/header_search/components/app_spec.js +++ b/spec/frontend/header_search/components/app_spec.js @@ -1,14 +1,41 @@ import { GlSearchBoxByType } from '@gitlab/ui'; +import Vue from 'vue'; +import Vuex from 'vuex'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import HeaderSearchApp from '~/header_search/components/app.vue'; -import { ESC_KEY } from '~/lib/utils/keys'; -import { MOCK_USERNAME } from '../mock_data'; +import HeaderSearchDefaultItems from '~/header_search/components/header_search_default_items.vue'; +import HeaderSearchScopedItems from '~/header_search/components/header_search_scoped_items.vue'; +import { ENTER_KEY, ESC_KEY } from '~/lib/utils/keys'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { MOCK_SEARCH, MOCK_SEARCH_QUERY, MOCK_USERNAME } from '../mock_data'; + +Vue.use(Vuex); + +jest.mock('~/lib/utils/url_utility', () => ({ + visitUrl: jest.fn(), +})); describe('HeaderSearchApp', () => { let wrapper; - const createComponent = () => { - wrapper = shallowMountExtended(HeaderSearchApp); + const actionSpies = { + setSearch: jest.fn(), + }; + + const createComponent = (initialState) => { + const store = new Vuex.Store({ + state: { + ...initialState, + }, + actions: actionSpies, + getters: { + searchQuery: () => MOCK_SEARCH_QUERY, + }, + }); + + wrapper = shallowMountExtended(HeaderSearchApp, { + store, + }); }; afterEach(() => { @@ -17,6 +44,8 @@ describe('HeaderSearchApp', () => { const findHeaderSearchInput = () => wrapper.findComponent(GlSearchBoxByType); const findHeaderSearchDropdown = () => wrapper.findByTestId('header-search-dropdown-menu'); + const findHeaderSearchDefaultItems = () => wrapper.findComponent(HeaderSearchDefaultItems); + const findHeaderSearchScopedItems = () => wrapper.findComponent(HeaderSearchScopedItems); describe('template', () => { it('always renders Header Search Input', () => { @@ -43,6 +72,29 @@ describe('HeaderSearchApp', () => { }); }); }); + + describe.each` + search | showDefault | showScoped + ${null} | ${true} | ${false} + ${''} | ${true} | ${false} + ${MOCK_SEARCH} | ${false} | ${true} + `('Header Search Dropdown Items', ({ search, showDefault, showScoped }) => { + describe(`when search is ${search}`, () => { + beforeEach(() => { + createComponent({ search }); + window.gon.current_username = MOCK_USERNAME; + wrapper.setData({ showDropdown: true }); + }); + + it(`should${showDefault ? '' : ' not'} render the Default Dropdown Items`, () => { + expect(findHeaderSearchDefaultItems().exists()).toBe(showDefault); + }); + + it(`should${showScoped ? '' : ' not'} render the Scoped Dropdown Items`, () => { + expect(findHeaderSearchScopedItems().exists()).toBe(showScoped); + }); + }); + }); }); describe('events', () => { @@ -86,6 +138,22 @@ describe('HeaderSearchApp', () => { expect(findHeaderSearchDropdown().exists()).toBe(false); }); }); + + it('calls setSearch when search input event is fired', async () => { + findHeaderSearchInput().vm.$emit('input', MOCK_SEARCH); + + await wrapper.vm.$nextTick(); + + expect(actionSpies.setSearch).toHaveBeenCalledWith(expect.any(Object), MOCK_SEARCH); + }); + + it('submits a search onKey-Enter', async () => { + findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); + + await wrapper.vm.$nextTick(); + + expect(visitUrl).toHaveBeenCalledWith(MOCK_SEARCH_QUERY); + }); }); }); }); diff --git a/spec/frontend/header_search/components/header_search_scoped_items_spec.js b/spec/frontend/header_search/components/header_search_scoped_items_spec.js new file mode 100644 index 00000000000..f0e5e182ec4 --- /dev/null +++ b/spec/frontend/header_search/components/header_search_scoped_items_spec.js @@ -0,0 +1,61 @@ +import { GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import Vuex from 'vuex'; +import { trimText } from 'helpers/text_helper'; +import HeaderSearchScopedItems from '~/header_search/components/header_search_scoped_items.vue'; +import { MOCK_SEARCH, MOCK_SCOPED_SEARCH_OPTIONS } from '../mock_data'; + +Vue.use(Vuex); + +describe('HeaderSearchScopedItems', () => { + let wrapper; + + const createComponent = (initialState) => { + const store = new Vuex.Store({ + state: { + search: MOCK_SEARCH, + ...initialState, + }, + getters: { + scopedSearchOptions: () => MOCK_SCOPED_SEARCH_OPTIONS, + }, + }); + + wrapper = shallowMount(HeaderSearchScopedItems, { + store, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => trimText(w.text())); + const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href')); + + describe('template', () => { + describe('Dropdown items', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders item for each option in scopedSearchOptions', () => { + expect(findDropdownItems()).toHaveLength(MOCK_SCOPED_SEARCH_OPTIONS.length); + }); + + it('renders titles correctly', () => { + const expectedTitles = MOCK_SCOPED_SEARCH_OPTIONS.map((o) => + trimText(`"${MOCK_SEARCH}" ${o.description} ${o.scope || ''}`), + ); + expect(findDropdownItemTitles()).toStrictEqual(expectedTitles); + }); + + it('renders links correctly', () => { + const expectedLinks = MOCK_SCOPED_SEARCH_OPTIONS.map((o) => o.url); + expect(findDropdownItemLinks()).toStrictEqual(expectedLinks); + }); + }); + }); +}); diff --git a/spec/frontend/header_search/mock_data.js b/spec/frontend/header_search/mock_data.js index 680b6522d98..5963ad9c279 100644 --- a/spec/frontend/header_search/mock_data.js +++ b/spec/frontend/header_search/mock_data.js @@ -4,14 +4,37 @@ import { MSG_MR_ASSIGNED_TO_ME, MSG_MR_IM_REVIEWER, MSG_MR_IVE_CREATED, + MSG_IN_PROJECT, + MSG_IN_GROUP, + MSG_IN_ALL_GITLAB, } from '~/header_search/constants'; export const MOCK_USERNAME = 'anyone'; +export const MOCK_SEARCH_PATH = '/search'; + export const MOCK_ISSUE_PATH = '/dashboard/issues'; export const MOCK_MR_PATH = '/dashboard/merge_requests'; +export const MOCK_ALL_PATH = '/'; + +export const MOCK_PROJECT = { + id: 123, + name: 'MockProject', + path: '/mock-project', +}; + +export const MOCK_GROUP = { + id: 321, + name: 'MockGroup', + path: '/mock-group', +}; + +export const MOCK_SEARCH_QUERY = 'http://gitlab.com/search?search=test'; + +export const MOCK_SEARCH = 'test'; + export const MOCK_SEARCH_CONTEXT = { project: null, project_metadata: {}, @@ -41,3 +64,20 @@ export const MOCK_DEFAULT_SEARCH_OPTIONS = [ url: `${MOCK_MR_PATH}/?author_username=${MOCK_USERNAME}`, }, ]; + +export const MOCK_SCOPED_SEARCH_OPTIONS = [ + { + scope: MOCK_PROJECT.name, + description: MSG_IN_PROJECT, + url: MOCK_PROJECT.path, + }, + { + scope: MOCK_GROUP.name, + description: MSG_IN_GROUP, + url: MOCK_GROUP.path, + }, + { + description: MSG_IN_ALL_GITLAB, + url: MOCK_ALL_PATH, + }, +]; diff --git a/spec/frontend/header_search/store/actions_spec.js b/spec/frontend/header_search/store/actions_spec.js new file mode 100644 index 00000000000..4530df0d91c --- /dev/null +++ b/spec/frontend/header_search/store/actions_spec.js @@ -0,0 +1,28 @@ +import testAction from 'helpers/vuex_action_helper'; +import * as actions from '~/header_search/store/actions'; +import * as types from '~/header_search/store/mutation_types'; +import createState from '~/header_search/store/state'; +import { MOCK_SEARCH } from '../mock_data'; + +describe('Header Search Store Actions', () => { + let state; + + beforeEach(() => { + state = createState({}); + }); + + afterEach(() => { + state = null; + }); + + describe('setSearch', () => { + it('calls the SET_SEARCH mutation', () => { + return testAction({ + action: actions.setSearch, + payload: MOCK_SEARCH, + state, + expectedMutations: [{ type: types.SET_SEARCH, payload: MOCK_SEARCH }], + }); + }); + }); +}); diff --git a/spec/frontend/header_search/store/getters_spec.js b/spec/frontend/header_search/store/getters_spec.js index f87a58a0560..2ad0a082f6a 100644 --- a/spec/frontend/header_search/store/getters_spec.js +++ b/spec/frontend/header_search/store/getters_spec.js @@ -2,10 +2,16 @@ import * as getters from '~/header_search/store/getters'; import initState from '~/header_search/store/state'; import { MOCK_USERNAME, + MOCK_SEARCH_PATH, MOCK_ISSUE_PATH, MOCK_MR_PATH, MOCK_SEARCH_CONTEXT, MOCK_DEFAULT_SEARCH_OPTIONS, + MOCK_SCOPED_SEARCH_OPTIONS, + MOCK_PROJECT, + MOCK_GROUP, + MOCK_ALL_PATH, + MOCK_SEARCH, } from '../mock_data'; describe('Header Search Store Getters', () => { @@ -13,6 +19,7 @@ describe('Header Search Store Getters', () => { const createState = (initialState) => { state = initState({ + searchPath: MOCK_SEARCH_PATH, issuesPath: MOCK_ISSUE_PATH, mrPath: MOCK_MR_PATH, searchContext: MOCK_SEARCH_CONTEXT, @@ -25,6 +32,30 @@ describe('Header Search Store Getters', () => { }); describe.each` + group | project | expectedPath + ${null} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=undefined&group_id=undefined&scope=issues`} + ${MOCK_GROUP} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=undefined&group_id=${MOCK_GROUP.id}&scope=issues`} + ${MOCK_GROUP} | ${MOCK_PROJECT} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues`} + `('searchQuery', ({ group, project, expectedPath }) => { + describe(`when group is ${group?.name} and project is ${project?.name}`, () => { + beforeEach(() => { + createState({ + searchContext: { + group, + project, + scope: 'issues', + }, + }); + state.search = MOCK_SEARCH; + }); + + it(`should return ${expectedPath}`, () => { + expect(getters.searchQuery(state)).toBe(expectedPath); + }); + }); + }); + + describe.each` group | group_metadata | project | project_metadata | expectedPath ${null} | ${null} | ${null} | ${null} | ${MOCK_ISSUE_PATH} ${{ name: 'Test Group' }} | ${{ issues_path: 'group/path' }} | ${null} | ${null} | ${'group/path'} @@ -72,6 +103,71 @@ describe('Header Search Store Getters', () => { }); }); + describe.each` + group | project | expectedPath + ${null} | ${null} | ${null} + ${MOCK_GROUP} | ${null} | ${null} + ${MOCK_GROUP} | ${MOCK_PROJECT} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&project_id=${MOCK_PROJECT.id}&group_id=${MOCK_GROUP.id}&scope=issues`} + `('projectUrl', ({ group, project, expectedPath }) => { + describe(`when group is ${group?.name} and project is ${project?.name}`, () => { + beforeEach(() => { + createState({ + searchContext: { + group, + project, + scope: 'issues', + }, + }); + state.search = MOCK_SEARCH; + }); + + it(`should return ${expectedPath}`, () => { + expect(getters.projectUrl(state)).toBe(expectedPath); + }); + }); + }); + + describe.each` + group | project | expectedPath + ${null} | ${null} | ${null} + ${MOCK_GROUP} | ${null} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}&scope=issues`} + ${MOCK_GROUP} | ${MOCK_PROJECT} | ${`${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&group_id=${MOCK_GROUP.id}&scope=issues`} + `('groupUrl', ({ group, project, expectedPath }) => { + describe(`when group is ${group?.name} and project is ${project?.name}`, () => { + beforeEach(() => { + createState({ + searchContext: { + group, + project, + scope: 'issues', + }, + }); + state.search = MOCK_SEARCH; + }); + + it(`should return ${expectedPath}`, () => { + expect(getters.groupUrl(state)).toBe(expectedPath); + }); + }); + }); + + describe('allUrl', () => { + const expectedPath = `${MOCK_SEARCH_PATH}?search=${MOCK_SEARCH}&nav_source=navbar&scope=issues`; + + beforeEach(() => { + createState({ + searchContext: { + scope: 'issues', + }, + }); + state.search = MOCK_SEARCH; + }); + + it(`should return ${expectedPath}`, () => { + expect(getters.allUrl(state)).toBe(expectedPath); + }); + }); + describe('defaultSearchOptions', () => { const mockGetters = { scopedIssuesPath: MOCK_ISSUE_PATH, @@ -89,4 +185,27 @@ describe('Header Search Store Getters', () => { ); }); }); + + describe('scopedSearchOptions', () => { + const mockGetters = { + projectUrl: MOCK_PROJECT.path, + groupUrl: MOCK_GROUP.path, + allUrl: MOCK_ALL_PATH, + }; + + beforeEach(() => { + createState({ + searchContext: { + project: MOCK_PROJECT, + group: MOCK_GROUP, + }, + }); + }); + + it('returns the correct array', () => { + expect(getters.scopedSearchOptions(state, mockGetters)).toStrictEqual( + MOCK_SCOPED_SEARCH_OPTIONS, + ); + }); + }); }); diff --git a/spec/frontend/header_search/store/mutations_spec.js b/spec/frontend/header_search/store/mutations_spec.js new file mode 100644 index 00000000000..8196c06099d --- /dev/null +++ b/spec/frontend/header_search/store/mutations_spec.js @@ -0,0 +1,20 @@ +import * as types from '~/header_search/store/mutation_types'; +import mutations from '~/header_search/store/mutations'; +import createState from '~/header_search/store/state'; +import { MOCK_SEARCH } from '../mock_data'; + +describe('Header Search Store Mutations', () => { + let state; + + beforeEach(() => { + state = createState({}); + }); + + describe('SET_SEARCH', () => { + it('sets search to value', () => { + mutations[types.SET_SEARCH](state, MOCK_SEARCH); + + expect(state.search).toBe(MOCK_SEARCH); + }); + }); +}); diff --git a/spec/frontend/header_spec.js b/spec/frontend/header_spec.js index 4ca6d7259bd..0d43accb7e5 100644 --- a/spec/frontend/header_spec.js +++ b/spec/frontend/header_spec.js @@ -59,8 +59,8 @@ describe('Header', () => { beforeEach(() => { setFixtures(` <li class="js-nav-user-dropdown"> - <a class="js-buy-pipeline-minutes-link" data-track-event="click_buy_ci_minutes" data-track-label="free" data-track-property="user_dropdown">Buy Pipeline minutes</a> - <a class="js-upgrade-plan-link" data-track-event="click_upgrade_link" data-track-label="free" data-track-property="user_dropdown">Upgrade</a> + <a class="js-buy-pipeline-minutes-link" data-track-action="click_buy_ci_minutes" data-track-label="free" data-track-property="user_dropdown">Buy Pipeline minutes</a> + <a class="js-upgrade-plan-link" data-track-action="click_upgrade_link" data-track-label="free" data-track-property="user_dropdown">Upgrade</a> </li>`); trackingSpy = mockTracking('_category_', $('.js-nav-user-dropdown').element, jest.spyOn); diff --git a/spec/frontend/invite_members/components/invite_members_trigger_spec.js b/spec/frontend/invite_members/components/invite_members_trigger_spec.js index f57af61ad5b..b2ebb9e4a47 100644 --- a/spec/frontend/invite_members/components/invite_members_trigger_spec.js +++ b/spec/frontend/invite_members/components/invite_members_trigger_spec.js @@ -79,14 +79,14 @@ describe.each(['button', 'anchor'])('with triggerElement as %s', (triggerElement it('does not add tracking attributes', () => { createComponent(); - expect(findButton().attributes('data-track-event')).toBeUndefined(); + expect(findButton().attributes('data-track-action')).toBeUndefined(); expect(findButton().attributes('data-track-label')).toBeUndefined(); }); it('adds tracking attributes', () => { createComponent({ label: '_label_', event: '_event_' }); - expect(findButton().attributes('data-track-event')).toBe('_event_'); + expect(findButton().attributes('data-track-action')).toBe('_event_'); expect(findButton().attributes('data-track-label')).toBe('_label_'); }); }); diff --git a/spec/frontend/pages/dashboard/projects/index/components/customize_homepage_banner_spec.js b/spec/frontend/pages/dashboard/projects/index/components/customize_homepage_banner_spec.js index 63c1260560b..f84800d8266 100644 --- a/spec/frontend/pages/dashboard/projects/index/components/customize_homepage_banner_spec.js +++ b/spec/frontend/pages/dashboard/projects/index/components/customize_homepage_banner_spec.js @@ -65,7 +65,7 @@ describe('CustomizeHomepageBanner', () => { await wrapper.vm.$nextTick(); const button = wrapper.find(`[href='${wrapper.vm.preferencesBehaviorPath}']`); - expect(button.attributes('data-track-event')).toEqual(preferencesTrackingEvent); + expect(button.attributes('data-track-action')).toEqual(preferencesTrackingEvent); expect(button.attributes('data-track-label')).toEqual(provide.trackLabel); }); diff --git a/spec/frontend/sidebar/track_invite_members_spec.js b/spec/frontend/sidebar/track_invite_members_spec.js index 6c96e4cfc76..5946e3320c4 100644 --- a/spec/frontend/sidebar/track_invite_members_spec.js +++ b/spec/frontend/sidebar/track_invite_members_spec.js @@ -10,7 +10,7 @@ describe('Track user dropdown open', () => { document.body.innerHTML = ` <div id="dummy-wrapper-element"> <div class="js-sidebar-assignee-dropdown"> - <div class="js-invite-members-track" data-track-event="_track_event_" data-track-label="_track_label_"> + <div class="js-invite-members-track" data-track-action="_track_event_" data-track-label="_track_label_"> </div> </div> </div> diff --git a/spec/frontend/vue_mr_widget/components/states/merge_checks_failed_spec.js b/spec/frontend/vue_mr_widget/components/states/merge_checks_failed_spec.js new file mode 100644 index 00000000000..bdad0bada5f --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/states/merge_checks_failed_spec.js @@ -0,0 +1,49 @@ +import { shallowMount } from '@vue/test-utils'; +import MergeChecksFailed from '~/vue_merge_request_widget/components/states/merge_checks_failed.vue'; + +let wrapper; + +function factory(propsData = {}) { + wrapper = shallowMount(MergeChecksFailed, { + propsData, + }); +} + +describe('Merge request widget merge checks failed state component', () => { + afterEach(() => { + wrapper.destroy(); + }); + + it.each` + mrState | displayText + ${{ isPipelineFailed: true }} | ${'pipelineFailed'} + ${{ approvals: true, isApproved: false }} | ${'approvalNeeded'} + ${{ hasMergeableDiscussionsState: true }} | ${'unresolvedDiscussions'} + `('display $displayText text for $mrState', ({ mrState, displayText }) => { + factory({ mr: mrState }); + + expect(wrapper.text()).toContain(MergeChecksFailed.i18n[displayText]); + }); + + describe('unresolved discussions', () => { + it('renders jump to button', () => { + factory({ mr: { hasMergeableDiscussionsState: true } }); + + expect(wrapper.find('[data-testid="jumpToUnresolved"]').exists()).toBe(true); + }); + + it('renders resolve thread button', () => { + factory({ + mr: { + hasMergeableDiscussionsState: true, + createIssueToResolveDiscussionsPath: 'https://gitlab.com', + }, + }); + + expect(wrapper.find('[data-testid="resolveIssue"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="resolveIssue"]').attributes('href')).toBe( + 'https://gitlab.com', + ); + }); + }); +}); diff --git a/spec/helpers/nav/new_dropdown_helper_spec.rb b/spec/helpers/nav/new_dropdown_helper_spec.rb index 79b49be92a5..64f4d5ff797 100644 --- a/spec/helpers/nav/new_dropdown_helper_spec.rb +++ b/spec/helpers/nav/new_dropdown_helper_spec.rb @@ -51,7 +51,7 @@ RSpec.describe Nav::NewDropdownHelper do title: 'Invite members', href: expected_href, data: { - track_event: 'click_link', + track_action: 'click_link', track_label: 'test_tracking_label', track_property: :invite_members_new_dropdown } @@ -104,7 +104,7 @@ RSpec.describe Nav::NewDropdownHelper do id: 'general_new_project', title: 'New project/repository', href: '/projects/new', - data: { track_event: 'click_link_new_project', track_label: 'plus_menu_dropdown', qa_selector: 'global_new_project_link' } + data: { track_action: 'click_link_new_project', track_label: 'plus_menu_dropdown', qa_selector: 'global_new_project_link' } ) ) ) @@ -122,7 +122,7 @@ RSpec.describe Nav::NewDropdownHelper do id: 'general_new_group', title: 'New group', href: '/groups/new', - data: { track_event: 'click_link_new_group', track_label: 'plus_menu_dropdown' } + data: { track_action: 'click_link_new_group', track_label: 'plus_menu_dropdown' } ) ) ) @@ -140,7 +140,7 @@ RSpec.describe Nav::NewDropdownHelper do id: 'general_new_snippet', title: 'New snippet', href: '/-/snippets/new', - data: { track_event: 'click_link_new_snippet_parent', track_label: 'plus_menu_dropdown', qa_selector: 'global_new_snippet_link' } + data: { track_action: 'click_link_new_snippet_parent', track_label: 'plus_menu_dropdown', qa_selector: 'global_new_snippet_link' } ) ) ) @@ -178,7 +178,7 @@ RSpec.describe Nav::NewDropdownHelper do id: 'new_project', title: 'New project/repository', href: "/projects/new?namespace_id=#{group.id}", - data: { track_event: 'click_link_new_project_group', track_label: 'plus_menu_dropdown' } + data: { track_action: 'click_link_new_project_group', track_label: 'plus_menu_dropdown' } ) ) ) @@ -196,7 +196,7 @@ RSpec.describe Nav::NewDropdownHelper do id: 'new_subgroup', title: 'New subgroup', href: "/groups/new?parent_id=#{group.id}", - data: { track_event: 'click_link_new_subgroup', track_label: 'plus_menu_dropdown' } + data: { track_action: 'click_link_new_subgroup', track_label: 'plus_menu_dropdown' } ) ) ) @@ -245,7 +245,7 @@ RSpec.describe Nav::NewDropdownHelper do id: 'new_issue', title: 'New issue', href: "/#{project.path_with_namespace}/-/issues/new", - data: { track_event: 'click_link_new_issue', track_label: 'plus_menu_dropdown', qa_selector: 'new_issue_link' } + data: { track_action: 'click_link_new_issue', track_label: 'plus_menu_dropdown', qa_selector: 'new_issue_link' } ) ) ) @@ -263,7 +263,7 @@ RSpec.describe Nav::NewDropdownHelper do id: 'new_mr', title: 'New merge request', href: "/#{merge_project.path_with_namespace}/-/merge_requests/new", - data: { track_event: 'click_link_new_mr', track_label: 'plus_menu_dropdown' } + data: { track_action: 'click_link_new_mr', track_label: 'plus_menu_dropdown' } ) ) ) @@ -281,7 +281,7 @@ RSpec.describe Nav::NewDropdownHelper do id: 'new_snippet', title: 'New snippet', href: "/#{project.path_with_namespace}/-/snippets/new", - data: { track_event: 'click_link_new_snippet_project', track_label: 'plus_menu_dropdown' } + data: { track_action: 'click_link_new_snippet_project', track_label: 'plus_menu_dropdown' } ) ) ) diff --git a/spec/helpers/nav/top_nav_helper_spec.rb b/spec/helpers/nav/top_nav_helper_spec.rb index 4d6da258536..da7e5d5dce2 100644 --- a/spec/helpers/nav/top_nav_helper_spec.rb +++ b/spec/helpers/nav/top_nav_helper_spec.rb @@ -142,7 +142,7 @@ RSpec.describe Nav::TopNavHelper do expected_primary = ::Gitlab::Nav::TopNavMenuItem.build( css_class: 'qa-projects-dropdown', data: { - track_event: 'click_dropdown', + track_action: 'click_dropdown', track_label: 'projects_dropdown' }, icon: 'project', @@ -248,7 +248,7 @@ RSpec.describe Nav::TopNavHelper do expected_primary = ::Gitlab::Nav::TopNavMenuItem.build( css_class: 'qa-groups-dropdown', data: { - track_event: 'click_dropdown', + track_action: 'click_dropdown', track_label: 'groups_dropdown' }, icon: 'group', diff --git a/spec/lib/gitlab/github_import/user_finder_spec.rb b/spec/lib/gitlab/github_import/user_finder_spec.rb index f81fa3b1e2e..8eb6eedd72d 100644 --- a/spec/lib/gitlab/github_import/user_finder_spec.rb +++ b/spec/lib/gitlab/github_import/user_finder_spec.rb @@ -195,7 +195,7 @@ RSpec.describe Gitlab::GithubImport::UserFinder, :clean_gitlab_redis_cache do expect(Gitlab::Cache::Import::Caching) .to receive(:write) - .with(an_instance_of(String), email) + .with(an_instance_of(String), email, timeout: Gitlab::Cache::Import::Caching::TIMEOUT) finder.email_for_github_username('kittens') end @@ -211,6 +211,16 @@ RSpec.describe Gitlab::GithubImport::UserFinder, :clean_gitlab_redis_cache do expect(finder.email_for_github_username('kittens')).to be_nil end + + it 'shortens the timeout for Email address in cache when an Email address is private/nil from GitHub' do + user = double(:user, email: nil) + expect(client).to receive(:user).with('kittens').and_return(user) + + expect(Gitlab::Cache::Import::Caching) + .to receive(:write).with(an_instance_of(String), nil, timeout: Gitlab::Cache::Import::Caching::SHORTER_TIMEOUT) + + expect(finder.email_for_github_username('kittens')).to be_nil + end end end diff --git a/spec/support/shared_contexts/services/service_ping/stubbed_service_ping_metrics_definitions_shared_context.rb b/spec/support/shared_contexts/services/service_ping/stubbed_service_ping_metrics_definitions_shared_context.rb index 6b49a415889..2b810e790f0 100644 --- a/spec/support/shared_contexts/services/service_ping/stubbed_service_ping_metrics_definitions_shared_context.rb +++ b/spec/support/shared_contexts/services/service_ping/stubbed_service_ping_metrics_definitions_shared_context.rb @@ -6,21 +6,25 @@ RSpec.shared_context 'stubbed service ping metrics definitions' do let(:metrics_definitions) { standard_metrics + subscription_metrics + operational_metrics + optional_metrics } let(:standard_metrics) do [ - metric_attributes('uuid', "standard") + metric_attributes('uuid', 'standard'), + metric_attributes('recorded_at', 'standard'), + metric_attributes('settings.collected_data_categories', 'standard', 'object') ] end let(:operational_metrics) do [ - metric_attributes('counts.merge_requests', "operational"), + metric_attributes('counts.merge_requests', 'operational'), metric_attributes('counts.todos', "operational") ] end let(:optional_metrics) do [ - metric_attributes('counts.boards', "optional"), - metric_attributes('gitaly.filesystems', '').except('data_category') + metric_attributes('counts.boards', 'optional', 'number'), + metric_attributes('gitaly.filesystems', '').except('data_category'), + metric_attributes('usage_activity_by_stage.monitor.projects_with_enabled_alert_integrations_histogram', 'optional', 'object'), + metric_attributes('topology', 'optional', 'object') ] end @@ -34,10 +38,11 @@ RSpec.shared_context 'stubbed service ping metrics definitions' do ) end - def metric_attributes(key_path, category) + def metric_attributes(key_path, category, value_type = 'string') { 'key_path' => key_path, - 'data_category' => category + 'data_category' => category, + 'value_type' => value_type } end end diff --git a/spec/support/shared_examples/features/deploy_token_shared_examples.rb b/spec/support/shared_examples/features/deploy_token_shared_examples.rb index fd77297a490..e70f9b52c09 100644 --- a/spec/support/shared_examples/features/deploy_token_shared_examples.rb +++ b/spec/support/shared_examples/features/deploy_token_shared_examples.rb @@ -1,15 +1,22 @@ # frozen_string_literal: true RSpec.shared_examples 'a deploy token in settings' do - it 'view deploy tokens' do + it 'view deploy tokens', :js do + user.update!(time_display_relative: true) + + visit page_path + within('.deploy-tokens') do expect(page).to have_content(deploy_token.name) expect(page).to have_content('read_repository') expect(page).to have_content('read_registry') + expect(page).to have_content('in 4 days') end end it 'add a new deploy token' do + visit page_path + fill_in 'deploy_token_name', with: 'new_deploy_key' fill_in 'deploy_token_expires_at', with: (Date.today + 1.month).to_s fill_in 'deploy_token_username', with: 'deployer' @@ -24,4 +31,18 @@ RSpec.shared_examples 'a deploy token in settings' do expect(page).to have_selector("input[name='deploy-token'][readonly='readonly']") end end + + context 'when User#time_display_relative is false', :js do + before do + user.update!(time_display_relative: false) + end + + it 'shows absolute times for expires_at' do + visit page_path + + within('.deploy-tokens') do + expect(page).to have_content(deploy_token.expires_at.strftime('%b %d')) + end + end + end end diff --git a/spec/support/shared_examples/features/discussion_comments_shared_example.rb b/spec/support/shared_examples/features/discussion_comments_shared_example.rb index fb2e422559d..99004e8283c 100644 --- a/spec/support/shared_examples/features/discussion_comments_shared_example.rb +++ b/spec/support/shared_examples/features/discussion_comments_shared_example.rb @@ -197,6 +197,8 @@ RSpec.shared_examples 'thread comments for issue, epic and merge request' do |re find(submit_button_selector).click + wait_for_all_requests + expect(page).to have_content(comment) new_comment = all(comments_selector).last diff --git a/spec/support/shared_examples/features/issuable_invite_members_shared_examples.rb b/spec/support/shared_examples/features/issuable_invite_members_shared_examples.rb index c0cfc27ceaf..149486320ae 100644 --- a/spec/support/shared_examples/features/issuable_invite_members_shared_examples.rb +++ b/spec/support/shared_examples/features/issuable_invite_members_shared_examples.rb @@ -15,7 +15,7 @@ RSpec.shared_examples 'issuable invite members' do page.within '.dropdown-menu-user' do expect(page).to have_link('Invite Members') - expect(page).to have_selector('[data-track-event="click_invite_members"]') + expect(page).to have_selector('[data-track-action="click_invite_members"]') expect(page).to have_selector('[data-track-label="edit_assignee"]') end diff --git a/spec/views/projects/empty.html.haml_spec.rb b/spec/views/projects/empty.html.haml_spec.rb index 70da4fc9e27..416dfc10174 100644 --- a/spec/views/projects/empty.html.haml_spec.rb +++ b/spec/views/projects/empty.html.haml_spec.rb @@ -53,7 +53,7 @@ RSpec.describe 'projects/empty' do it 'shows invite members info', :aggregate_failures do render - expect(rendered).to have_selector('[data-track-event=render]') + expect(rendered).to have_selector('[data-track-action=render]') expect(rendered).to have_selector('[data-track-label=invite_members_empty_project]') expect(rendered).to have_content('Invite your team') expect(rendered).to have_content('Add members to this project and start collaborating with your team.') diff --git a/spec/views/search/_results.html.haml_spec.rb b/spec/views/search/_results.html.haml_spec.rb index ecfcf74edc1..dcf1f46b46c 100644 --- a/spec/views/search/_results.html.haml_spec.rb +++ b/spec/views/search/_results.html.haml_spec.rb @@ -74,7 +74,7 @@ RSpec.describe 'search/_results' do it 'renders the click text event tracking attributes' do render - expect(rendered).to have_selector('[data-track-event=click_text]') + expect(rendered).to have_selector('[data-track-action=click_text]') expect(rendered).to have_selector('[data-track-property=search_result]') end end @@ -83,7 +83,7 @@ RSpec.describe 'search/_results' do it 'does not render the click text event tracking attributes' do render - expect(rendered).not_to have_selector('[data-track-event=click_text]') + expect(rendered).not_to have_selector('[data-track-action=click_text]') expect(rendered).not_to have_selector('[data-track-property=search_result]') end end @@ -105,7 +105,7 @@ RSpec.describe 'search/_results' do it 'renders the click text event tracking attributes' do render - expect(rendered).to have_selector('[data-track-event=click_text]') + expect(rendered).to have_selector('[data-track-action=click_text]') expect(rendered).to have_selector('[data-track-property=search_result]') end end @@ -114,7 +114,7 @@ RSpec.describe 'search/_results' do it 'does not render the click text event tracking attributes' do render - expect(rendered).not_to have_selector('[data-track-event=click_text]') + expect(rendered).not_to have_selector('[data-track-action=click_text]') expect(rendered).not_to have_selector('[data-track-property=search_result]') end end diff --git a/spec/views/shared/access_tokens/_table.html.haml_spec.rb b/spec/views/shared/access_tokens/_table.html.haml_spec.rb index 489675b5683..0a23768b4f1 100644 --- a/spec/views/shared/access_tokens/_table.html.haml_spec.rb +++ b/spec/views/shared/access_tokens/_table.html.haml_spec.rb @@ -19,7 +19,6 @@ RSpec.describe 'shared/access_tokens/_table.html.haml' do allow(view).to receive(:personal_access_token_expiration_enforced?).and_return(token_expiry_enforced?) allow(view).to receive(:show_profile_token_expiry_notification?).and_return(true) - allow(view).to receive(:distance_of_time_in_words_to_now).and_return('4 days') if project project.add_maintainer(user) @@ -140,7 +139,6 @@ RSpec.describe 'shared/access_tokens/_table.html.haml' do # Expiry expect(rendered).to have_content 'Expired', count: 2 - expect(rendered).to have_content 'In 4 days' # Revoke buttons expect(rendered).to have_link 'Revoke', href: 'path/', class: 'btn-danger-secondary', count: 1 |