diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-02-25 21:09:23 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-02-25 21:09:23 +0000 |
commit | 32fd4cd5e2134511936899d6bcc4aaf18b9be6fd (patch) | |
tree | 10378ceffed52dd0e160a0d9bcf3c5ab72c18958 /spec | |
parent | 951616a26a61e880860ad862c1d45a8e3762b4bc (diff) | |
download | gitlab-ce-32fd4cd5e2134511936899d6bcc4aaf18b9be6fd.tar.gz |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec')
23 files changed, 595 insertions, 349 deletions
diff --git a/spec/controllers/explore/snippets_controller_spec.rb b/spec/controllers/explore/snippets_controller_spec.rb index fa659c6df7f..ab91faa6cef 100644 --- a/spec/controllers/explore/snippets_controller_spec.rb +++ b/spec/controllers/explore/snippets_controller_spec.rb @@ -4,12 +4,33 @@ require 'spec_helper' describe Explore::SnippetsController do describe 'GET #index' do - it_behaves_like 'paginated collection' do - let(:collection) { Snippet.all } + let!(:project_snippet) { create_list(:project_snippet, 3, :public) } + let!(:personal_snippet) { create_list(:personal_snippet, 3, :public) } - before do - create(:personal_snippet, :public) - end + before do + allow(Kaminari.config).to receive(:default_per_page).and_return(2) + end + + it 'renders' do + get :index + + snippets = assigns(:snippets) + + expect(snippets).to be_a(::Kaminari::PaginatableWithoutCount) + expect(snippets.size).to eq(2) + expect(snippets).to all(be_a(PersonalSnippet)) + expect(response).to have_gitlab_http_status(:ok) + end + + it 'renders pagination' do + get :index, params: { page: 2 } + + snippets = assigns(:snippets) + + expect(snippets).to be_a(::Kaminari::PaginatableWithoutCount) + expect(snippets.size).to eq(1) + expect(assigns(:snippets)).to all(be_a(PersonalSnippet)) + expect(response).to have_gitlab_http_status(:ok) end end end diff --git a/spec/javascripts/badges/components/badge_form_spec.js b/spec/frontend/badges/components/badge_form_spec.js index c7aa7fa63b1..d61bd29ca9d 100644 --- a/spec/javascripts/badges/components/badge_form_spec.js +++ b/spec/frontend/badges/components/badge_form_spec.js @@ -1,11 +1,11 @@ import Vue from 'vue'; import MockAdapter from 'axios-mock-adapter'; -import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; import axios from '~/lib/utils/axios_utils'; import store from '~/badges/store'; import createEmptyBadge from '~/badges/empty_badge'; import BadgeForm from '~/badges/components/badge_form.vue'; -import { DUMMY_IMAGE_URL, TEST_HOST } from '../../test_constants'; +import { DUMMY_IMAGE_URL, TEST_HOST } from 'helpers/test_constants'; // avoid preview background process BadgeForm.methods.debouncedPreview = () => {}; @@ -41,7 +41,7 @@ describe('BadgeForm component', () => { describe('onCancel', () => { it('calls stopEditing', () => { - spyOn(vm, 'stopEditing'); + jest.spyOn(vm, 'stopEditing').mockImplementation(() => {}); vm.onCancel(); @@ -68,14 +68,14 @@ describe('BadgeForm component', () => { const expectInvalidInput = inputElementSelector => { const inputElement = vm.$el.querySelector(inputElementSelector); - expect(inputElement).toBeMatchedBy(':invalid'); + expect(inputElement.checkValidity()).toBe(false); const feedbackElement = vm.$el.querySelector(`${inputElementSelector} + .invalid-feedback`); expect(feedbackElement).toBeVisible(); }; - beforeEach(() => { - spyOn(vm, submitAction).and.returnValue(Promise.resolve()); + beforeEach(done => { + jest.spyOn(vm, submitAction).mockReturnValue(Promise.resolve()); store.replaceState({ ...store.state, badgeInAddForm: createEmptyBadge(), @@ -83,9 +83,14 @@ describe('BadgeForm component', () => { isSaving: false, }); - setValue(nameSelector, 'TestBadge'); - setValue(linkUrlSelector, `${TEST_HOST}/link/url`); - setValue(imageUrlSelector, `${window.location.origin}${DUMMY_IMAGE_URL}`); + Vue.nextTick() + .then(() => { + setValue(nameSelector, 'TestBadge'); + setValue(linkUrlSelector, `${TEST_HOST}/link/url`); + setValue(imageUrlSelector, `${window.location.origin}${DUMMY_IMAGE_URL}`); + }) + .then(done) + .catch(done.fail); }); it('returns immediately if imageUrl is empty', () => { @@ -131,8 +136,8 @@ describe('BadgeForm component', () => { it(`calls ${submitAction}`, () => { submitForm(); - expect(findImageUrlElement()).toBeMatchedBy(':valid'); - expect(findLinkUrlElement()).toBeMatchedBy(':valid'); + expect(findImageUrlElement().checkValidity()).toBe(true); + expect(findLinkUrlElement().checkValidity()).toBe(true); expect(vm[submitAction]).toHaveBeenCalled(); }); }; diff --git a/spec/javascripts/badges/components/badge_list_row_spec.js b/spec/frontend/badges/components/badge_list_row_spec.js index d1434737085..31f0d850857 100644 --- a/spec/javascripts/badges/components/badge_list_row_spec.js +++ b/spec/frontend/badges/components/badge_list_row_spec.js @@ -1,6 +1,5 @@ -import $ from 'jquery'; import Vue from 'vue'; -import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; import { GROUP_BADGE, PROJECT_BADGE } from '~/badges/constants'; import store from '~/badges/store'; import BadgeListRow from '~/badges/components/badge_list_row.vue'; @@ -40,15 +39,15 @@ describe('BadgeListRow component', () => { }); it('renders the badge name', () => { - expect(vm.$el).toContainText(badge.name); + expect(vm.$el.innerText).toMatch(badge.name); }); it('renders the badge link', () => { - expect(vm.$el).toContainText(badge.linkUrl); + expect(vm.$el.innerText).toMatch(badge.linkUrl); }); it('renders the badge kind', () => { - expect(vm.$el).toContainText('Project Badge'); + expect(vm.$el.innerText).toMatch('Project Badge'); }); it('shows edit and delete buttons', () => { @@ -66,7 +65,7 @@ describe('BadgeListRow component', () => { }); it('calls editBadge when clicking then edit button', () => { - spyOn(vm, 'editBadge'); + jest.spyOn(vm, 'editBadge').mockImplementation(() => {}); const editButton = vm.$el.querySelector('.table-button-footer button:first-of-type'); editButton.click(); @@ -75,13 +74,17 @@ describe('BadgeListRow component', () => { }); it('calls updateBadgeInModal and shows modal when clicking then delete button', done => { - spyOn(vm, 'updateBadgeInModal'); - $('#delete-badge-modal').on('shown.bs.modal', () => done()); + jest.spyOn(vm, 'updateBadgeInModal').mockImplementation(() => {}); const deleteButton = vm.$el.querySelector('.table-button-footer button:last-of-type'); deleteButton.click(); - expect(vm.updateBadgeInModal).toHaveBeenCalled(); + Vue.nextTick() + .then(() => { + expect(vm.updateBadgeInModal).toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); }); describe('for a group badge', () => { @@ -94,7 +97,7 @@ describe('BadgeListRow component', () => { }); it('renders the badge kind', () => { - expect(vm.$el).toContainText('Group Badge'); + expect(vm.$el.innerText).toMatch('Group Badge'); }); it('hides edit and delete buttons', () => { diff --git a/spec/javascripts/badges/components/badge_list_spec.js b/spec/frontend/badges/components/badge_list_spec.js index 3af194454e3..5ffc046eb97 100644 --- a/spec/javascripts/badges/components/badge_list_spec.js +++ b/spec/frontend/badges/components/badge_list_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; import { GROUP_BADGE, PROJECT_BADGE } from '~/badges/constants'; import store from '~/badges/store'; import BadgeList from '~/badges/components/badge_list.vue'; @@ -22,6 +22,10 @@ describe('BadgeList component', () => { kind: PROJECT_BADGE, isLoading: false, }); + + // Can be removed once GlLoadingIcon no longer throws a warning + jest.spyOn(global.console, 'warn').mockImplementation(() => jest.fn()); + vm = mountComponentWithStore(Component, { el: '#dummy-element', store, @@ -49,7 +53,7 @@ describe('BadgeList component', () => { Vue.nextTick() .then(() => { - expect(vm.$el).toContainText('This project has no badges'); + expect(vm.$el.innerText).toMatch('This project has no badges'); }) .then(done) .catch(done.fail); @@ -82,7 +86,7 @@ describe('BadgeList component', () => { Vue.nextTick() .then(() => { - expect(vm.$el).toContainText('This group has no badges'); + expect(vm.$el.innerText).toMatch('This group has no badges'); }) .then(done) .catch(done.fail); diff --git a/spec/javascripts/badges/components/badge_settings_spec.js b/spec/frontend/badges/components/badge_settings_spec.js index 479a905661b..8c3f1ea2749 100644 --- a/spec/javascripts/badges/components/badge_settings_spec.js +++ b/spec/frontend/badges/components/badge_settings_spec.js @@ -1,6 +1,5 @@ -import $ from 'jquery'; import Vue from 'vue'; -import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; import store from '~/badges/store'; import BadgeSettings from '~/badges/components/badge_settings.vue'; import { createDummyBadge } from '../dummy_badge'; @@ -19,6 +18,10 @@ describe('BadgeSettings component', () => { data-target="#delete-badge-modal" >Show modal</button> `); + + // Can be removed once GlLoadingIcon no longer throws a warning + jest.spyOn(global.console, 'warn').mockImplementation(() => jest.fn()); + vm = mountComponentWithStore(Component, { el: '#dummy-element', store, @@ -35,20 +38,16 @@ describe('BadgeSettings component', () => { const modal = vm.$el.querySelector('#delete-badge-modal'); const button = document.getElementById('dummy-modal-button'); - $(modal).on('shown.bs.modal', () => { - expect(modal).toContainText('Delete badge?'); - const badgeElement = modal.querySelector('img.project-badge'); - - expect(badgeElement).not.toBe(null); - expect(badgeElement.getAttribute('src')).toBe(badge.renderedImageUrl); - - done(); - }); + button.click(); Vue.nextTick() .then(() => { - button.click(); + expect(modal.innerText).toMatch('Delete badge?'); + const badgeElement = modal.querySelector('img.project-badge'); + expect(badgeElement).not.toBe(null); + expect(badgeElement.getAttribute('src')).toBe(badge.renderedImageUrl); }) + .then(done) .catch(done.fail); }); @@ -67,7 +66,7 @@ describe('BadgeSettings component', () => { expect(badgeListElement).not.toBe(null); expect(badgeListElement).toBeVisible(); - expect(badgeListElement).toContainText('Your badges'); + expect(badgeListElement.innerText).toMatch('Your badges'); }); describe('when editing', () => { @@ -103,7 +102,7 @@ describe('BadgeSettings component', () => { describe('methods', () => { describe('onSubmitModal', () => { it('triggers ', () => { - spyOn(vm, 'deleteBadge').and.callFake(() => Promise.resolve()); + jest.spyOn(vm, 'deleteBadge').mockImplementation(() => Promise.resolve()); const modal = vm.$el.querySelector('#delete-badge-modal'); const deleteButton = modal.querySelector('.btn-danger'); diff --git a/spec/javascripts/badges/components/badge_spec.js b/spec/frontend/badges/components/badge_spec.js index 14490b1bbd1..43004004fb2 100644 --- a/spec/javascripts/badges/components/badge_spec.js +++ b/spec/frontend/badges/components/badge_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import mountComponent from 'helpers/vue_mount_component_helper'; import { DUMMY_IMAGE_URL, TEST_HOST } from 'spec/test_constants'; import Badge from '~/badges/components/badge.vue'; @@ -23,9 +23,11 @@ describe('Badge component', () => { const createComponent = (props, el = null) => { vm = mountComponent(Component, props, el); const { badgeImage } = findElements(); - return new Promise(resolve => badgeImage.addEventListener('load', resolve)).then(() => - Vue.nextTick(), - ); + return new Promise(resolve => { + badgeImage.addEventListener('load', resolve); + // Manually dispatch load event as it is not triggered + badgeImage.dispatchEvent(new Event('load')); + }).then(() => Vue.nextTick()); }; afterEach(() => { @@ -111,7 +113,7 @@ describe('Badge component', () => { expect(badgeImage).toBeVisible(); expect(loadingIcon).toBeHidden(); expect(reloadButton).toBeHidden(); - expect(vm.$el.innerText).toBe(''); + expect(vm.$el.querySelector('.btn-group')).toBeHidden(); }); it('shows a loading icon when loading', done => { @@ -124,7 +126,7 @@ describe('Badge component', () => { expect(badgeImage).toBeHidden(); expect(loadingIcon).toBeVisible(); expect(reloadButton).toBeHidden(); - expect(vm.$el.innerText).toBe(''); + expect(vm.$el.querySelector('.btn-group')).toBeHidden(); }) .then(done) .catch(done.fail); diff --git a/spec/javascripts/badges/dummy_badge.js b/spec/frontend/badges/dummy_badge.js index a0dee89736e..a0dee89736e 100644 --- a/spec/javascripts/badges/dummy_badge.js +++ b/spec/frontend/badges/dummy_badge.js diff --git a/spec/javascripts/badges/store/actions_spec.js b/spec/frontend/badges/store/actions_spec.js index d92155d59b5..921c21cb55e 100644 --- a/spec/javascripts/badges/store/actions_spec.js +++ b/spec/frontend/badges/store/actions_spec.js @@ -1,6 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import { TEST_HOST } from 'spec/test_constants'; -import testAction from 'spec/helpers/vuex_action_helper'; +import testAction from 'helpers/vuex_action_helper'; import axios from '~/lib/utils/axios_utils'; import actions, { transformBackendBadge } from '~/badges/store/actions'; import mutationTypes from '~/badges/store/mutation_types'; @@ -76,7 +76,7 @@ describe('Badges store actions', () => { beforeEach(() => { endpointMock = axiosMock.onPost(dummyEndpointUrl); - dispatch = jasmine.createSpy('dispatch'); + dispatch = jest.fn(); badgeInAddForm = createDummyBadge(); state = { ...state, @@ -96,8 +96,8 @@ describe('Badges store actions', () => { }), ); - expect(dispatch.calls.allArgs()).toEqual([['requestNewBadge']]); - dispatch.calls.reset(); + expect(dispatch.mock.calls).toEqual([['requestNewBadge']]); + dispatch.mockClear(); return [200, dummyResponse]; }); @@ -105,7 +105,7 @@ describe('Badges store actions', () => { actions .addBadge({ state, dispatch }) .then(() => { - expect(dispatch.calls.allArgs()).toEqual([['receiveNewBadge', dummyBadge]]); + expect(dispatch.mock.calls).toEqual([['receiveNewBadge', dummyBadge]]); }) .then(done) .catch(done.fail); @@ -121,8 +121,8 @@ describe('Badges store actions', () => { }), ); - expect(dispatch.calls.allArgs()).toEqual([['requestNewBadge']]); - dispatch.calls.reset(); + expect(dispatch.mock.calls).toEqual([['requestNewBadge']]); + dispatch.mockClear(); return [500, '']; }); @@ -130,7 +130,7 @@ describe('Badges store actions', () => { .addBadge({ state, dispatch }) .then(() => done.fail('Expected Ajax call to fail!')) .catch(() => { - expect(dispatch.calls.allArgs()).toEqual([['receiveNewBadgeError']]); + expect(dispatch.mock.calls).toEqual([['receiveNewBadgeError']]); }) .then(done) .catch(done.fail); @@ -182,20 +182,20 @@ describe('Badges store actions', () => { beforeEach(() => { endpointMock = axiosMock.onDelete(`${dummyEndpointUrl}/${badgeId}`); - dispatch = jasmine.createSpy('dispatch'); + dispatch = jest.fn(); }); it('dispatches requestDeleteBadge and receiveDeleteBadge for successful response', done => { endpointMock.replyOnce(() => { - expect(dispatch.calls.allArgs()).toEqual([['requestDeleteBadge', badgeId]]); - dispatch.calls.reset(); + expect(dispatch.mock.calls).toEqual([['requestDeleteBadge', badgeId]]); + dispatch.mockClear(); return [200, '']; }); actions .deleteBadge({ state, dispatch }, { id: badgeId }) .then(() => { - expect(dispatch.calls.allArgs()).toEqual([['receiveDeleteBadge', badgeId]]); + expect(dispatch.mock.calls).toEqual([['receiveDeleteBadge', badgeId]]); }) .then(done) .catch(done.fail); @@ -203,8 +203,8 @@ describe('Badges store actions', () => { it('dispatches requestDeleteBadge and receiveDeleteBadgeError for error response', done => { endpointMock.replyOnce(() => { - expect(dispatch.calls.allArgs()).toEqual([['requestDeleteBadge', badgeId]]); - dispatch.calls.reset(); + expect(dispatch.mock.calls).toEqual([['requestDeleteBadge', badgeId]]); + dispatch.mockClear(); return [500, '']; }); @@ -212,7 +212,7 @@ describe('Badges store actions', () => { .deleteBadge({ state, dispatch }, { id: badgeId }) .then(() => done.fail('Expected Ajax call to fail!')) .catch(() => { - expect(dispatch.calls.allArgs()).toEqual([['receiveDeleteBadgeError', badgeId]]); + expect(dispatch.mock.calls).toEqual([['receiveDeleteBadgeError', badgeId]]); }) .then(done) .catch(done.fail); @@ -280,7 +280,7 @@ describe('Badges store actions', () => { beforeEach(() => { endpointMock = axiosMock.onGet(dummyEndpointUrl); - dispatch = jasmine.createSpy('dispatch'); + dispatch = jest.fn(); }); it('dispatches requestLoadBadges and receiveLoadBadges for successful response', done => { @@ -291,8 +291,8 @@ describe('Badges store actions', () => { createDummyBadgeResponse(), ]; endpointMock.replyOnce(() => { - expect(dispatch.calls.allArgs()).toEqual([['requestLoadBadges', dummyData]]); - dispatch.calls.reset(); + expect(dispatch.mock.calls).toEqual([['requestLoadBadges', dummyData]]); + dispatch.mockClear(); return [200, dummyReponse]; }); @@ -301,7 +301,7 @@ describe('Badges store actions', () => { .then(() => { const badges = dummyReponse.map(transformBackendBadge); - expect(dispatch.calls.allArgs()).toEqual([['receiveLoadBadges', badges]]); + expect(dispatch.mock.calls).toEqual([['receiveLoadBadges', badges]]); }) .then(done) .catch(done.fail); @@ -310,8 +310,8 @@ describe('Badges store actions', () => { it('dispatches requestLoadBadges and receiveLoadBadgesError for error response', done => { const dummyData = 'this is just some data'; endpointMock.replyOnce(() => { - expect(dispatch.calls.allArgs()).toEqual([['requestLoadBadges', dummyData]]); - dispatch.calls.reset(); + expect(dispatch.mock.calls).toEqual([['requestLoadBadges', dummyData]]); + dispatch.mockClear(); return [500, '']; }); @@ -319,7 +319,7 @@ describe('Badges store actions', () => { .loadBadges({ state, dispatch }, dummyData) .then(() => done.fail('Expected Ajax call to fail!')) .catch(() => { - expect(dispatch.calls.allArgs()).toEqual([['receiveLoadBadgesError']]); + expect(dispatch.mock.calls).toEqual([['receiveLoadBadgesError']]); }) .then(done) .catch(done.fail); @@ -382,11 +382,11 @@ describe('Badges store actions', () => { `image_url=${encodeURIComponent(badgeInForm.imageUrl)}`, ].join('&'); endpointMock = axiosMock.onGet(`${dummyEndpointUrl}/render?${urlParameters}`); - dispatch = jasmine.createSpy('dispatch'); + dispatch = jest.fn(); }); it('returns immediately if imageUrl is empty', done => { - spyOn(axios, 'get'); + jest.spyOn(axios, 'get').mockImplementation(() => {}); badgeInForm.imageUrl = ''; actions @@ -399,7 +399,7 @@ describe('Badges store actions', () => { }); it('returns immediately if linkUrl is empty', done => { - spyOn(axios, 'get'); + jest.spyOn(axios, 'get').mockImplementation(() => {}); badgeInForm.linkUrl = ''; actions @@ -412,19 +412,23 @@ describe('Badges store actions', () => { }); it('escapes user input', done => { - spyOn(axios, 'get').and.callFake(() => Promise.resolve({ data: createDummyBadgeResponse() })); + jest + .spyOn(axios, 'get') + .mockImplementation(() => Promise.resolve({ data: createDummyBadgeResponse() })); badgeInForm.imageUrl = '&make-sandwich=true'; badgeInForm.linkUrl = '<script>I am dangerous!</script>'; actions .renderBadge({ state, dispatch }) .then(() => { - expect(axios.get.calls.count()).toBe(1); - const url = axios.get.calls.argsFor(0)[0]; - - expect(url).toMatch(`^${dummyEndpointUrl}/render?`); - expect(url).toMatch('\\?link_url=%3Cscript%3EI%20am%20dangerous!%3C%2Fscript%3E&'); - expect(url).toMatch('&image_url=%26make-sandwich%3Dtrue$'); + expect(axios.get.mock.calls.length).toBe(1); + const url = axios.get.mock.calls[0][0]; + + expect(url).toMatch(new RegExp(`^${dummyEndpointUrl}/render?`)); + expect(url).toMatch( + new RegExp('\\?link_url=%3Cscript%3EI%20am%20dangerous!%3C%2Fscript%3E&'), + ); + expect(url).toMatch(new RegExp('&image_url=%26make-sandwich%3Dtrue$')); }) .then(done) .catch(done.fail); @@ -433,8 +437,8 @@ describe('Badges store actions', () => { it('dispatches requestRenderedBadge and receiveRenderedBadge for successful response', done => { const dummyReponse = createDummyBadgeResponse(); endpointMock.replyOnce(() => { - expect(dispatch.calls.allArgs()).toEqual([['requestRenderedBadge']]); - dispatch.calls.reset(); + expect(dispatch.mock.calls).toEqual([['requestRenderedBadge']]); + dispatch.mockClear(); return [200, dummyReponse]; }); @@ -443,7 +447,7 @@ describe('Badges store actions', () => { .then(() => { const renderedBadge = transformBackendBadge(dummyReponse); - expect(dispatch.calls.allArgs()).toEqual([['receiveRenderedBadge', renderedBadge]]); + expect(dispatch.mock.calls).toEqual([['receiveRenderedBadge', renderedBadge]]); }) .then(done) .catch(done.fail); @@ -451,8 +455,8 @@ describe('Badges store actions', () => { it('dispatches requestRenderedBadge and receiveRenderedBadgeError for error response', done => { endpointMock.replyOnce(() => { - expect(dispatch.calls.allArgs()).toEqual([['requestRenderedBadge']]); - dispatch.calls.reset(); + expect(dispatch.mock.calls).toEqual([['requestRenderedBadge']]); + dispatch.mockClear(); return [500, '']; }); @@ -460,7 +464,7 @@ describe('Badges store actions', () => { .renderBadge({ state, dispatch }) .then(() => done.fail('Expected Ajax call to fail!')) .catch(() => { - expect(dispatch.calls.allArgs()).toEqual([['receiveRenderedBadgeError']]); + expect(dispatch.mock.calls).toEqual([['receiveRenderedBadgeError']]); }) .then(done) .catch(done.fail); @@ -519,7 +523,7 @@ describe('Badges store actions', () => { badgeInEditForm, }; endpointMock = axiosMock.onPut(`${dummyEndpointUrl}/${badgeInEditForm.id}`); - dispatch = jasmine.createSpy('dispatch'); + dispatch = jest.fn(); }); it('dispatches requestUpdatedBadge and receiveUpdatedBadge for successful response', done => { @@ -534,8 +538,8 @@ describe('Badges store actions', () => { }), ); - expect(dispatch.calls.allArgs()).toEqual([['requestUpdatedBadge']]); - dispatch.calls.reset(); + expect(dispatch.mock.calls).toEqual([['requestUpdatedBadge']]); + dispatch.mockClear(); return [200, dummyResponse]; }); @@ -543,7 +547,7 @@ describe('Badges store actions', () => { actions .saveBadge({ state, dispatch }) .then(() => { - expect(dispatch.calls.allArgs()).toEqual([['receiveUpdatedBadge', updatedBadge]]); + expect(dispatch.mock.calls).toEqual([['receiveUpdatedBadge', updatedBadge]]); }) .then(done) .catch(done.fail); @@ -559,8 +563,8 @@ describe('Badges store actions', () => { }), ); - expect(dispatch.calls.allArgs()).toEqual([['requestUpdatedBadge']]); - dispatch.calls.reset(); + expect(dispatch.mock.calls).toEqual([['requestUpdatedBadge']]); + dispatch.mockClear(); return [500, '']; }); @@ -568,7 +572,7 @@ describe('Badges store actions', () => { .saveBadge({ state, dispatch }) .then(() => done.fail('Expected Ajax call to fail!')) .catch(() => { - expect(dispatch.calls.allArgs()).toEqual([['receiveUpdatedBadgeError']]); + expect(dispatch.mock.calls).toEqual([['receiveUpdatedBadgeError']]); }) .then(done) .catch(done.fail); diff --git a/spec/javascripts/badges/store/mutations_spec.js b/spec/frontend/badges/store/mutations_spec.js index 8d26f83339d..8d26f83339d 100644 --- a/spec/javascripts/badges/store/mutations_spec.js +++ b/spec/frontend/badges/store/mutations_spec.js diff --git a/spec/frontend/notes/components/discussion_counter_spec.js b/spec/frontend/notes/components/discussion_counter_spec.js new file mode 100644 index 00000000000..c9375df07e8 --- /dev/null +++ b/spec/frontend/notes/components/discussion_counter_spec.js @@ -0,0 +1,91 @@ +import Vuex from 'vuex'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import notesModule from '~/notes/stores/modules'; +import DiscussionCounter from '~/notes/components/discussion_counter.vue'; +import { noteableDataMock, discussionMock, notesDataMock, userDataMock } from '../mock_data'; +import * as types from '~/notes/stores/mutation_types'; + +describe('DiscussionCounter component', () => { + let store; + let wrapper; + const localVue = createLocalVue(); + + localVue.use(Vuex); + + beforeEach(() => { + window.mrTabs = {}; + const { state, getters, mutations, actions } = notesModule(); + + store = new Vuex.Store({ + state: { + ...state, + userData: userDataMock, + }, + getters, + mutations, + actions, + }); + store.dispatch('setNoteableData', { + ...noteableDataMock, + create_issue_to_resolve_discussions_path: '/test', + }); + store.dispatch('setNotesData', notesDataMock); + }); + + afterEach(() => { + wrapper.vm.$destroy(); + wrapper = null; + }); + + describe('has no discussions', () => { + it('does not render', () => { + wrapper = shallowMount(DiscussionCounter, { store, localVue }); + + expect(wrapper.find({ ref: 'discussionCounter' }).exists()).toBe(false); + }); + }); + + describe('has no resolvable discussions', () => { + it('does not render', () => { + store.commit(types.SET_INITIAL_DISCUSSIONS, [{ ...discussionMock, resolvable: false }]); + store.dispatch('updateResolvableDiscussionsCounts'); + wrapper = shallowMount(DiscussionCounter, { store, localVue }); + + expect(wrapper.find({ ref: 'discussionCounter' }).exists()).toBe(false); + }); + }); + + describe('has resolvable discussions', () => { + const updateStore = (note = {}) => { + discussionMock.notes[0] = { ...discussionMock.notes[0], ...note }; + store.commit(types.SET_INITIAL_DISCUSSIONS, [discussionMock]); + store.dispatch('updateResolvableDiscussionsCounts'); + }; + + afterEach(() => { + delete discussionMock.notes[0].resolvable; + delete discussionMock.notes[0].resolved; + }); + + it('renders', () => { + updateStore(); + wrapper = shallowMount(DiscussionCounter, { store, localVue }); + + expect(wrapper.find({ ref: 'discussionCounter' }).exists()).toBe(true); + }); + + it.each` + title | resolved | hasNextBtn | isActive | icon | groupLength + ${'hasNextButton'} | ${false} | ${true} | ${false} | ${'check-circle'} | ${2} + ${'allResolved'} | ${true} | ${false} | ${true} | ${'check-circle-filled'} | ${0} + `('renders correctly if $title', ({ resolved, hasNextBtn, isActive, icon, groupLength }) => { + updateStore({ resolvable: true, resolved }); + wrapper = shallowMount(DiscussionCounter, { store, localVue }); + + expect(wrapper.find(`.has-next-btn`).exists()).toBe(hasNextBtn); + expect(wrapper.find(`.is-active`).exists()).toBe(isActive); + expect(wrapper.find({ name: icon }).exists()).toBe(true); + expect(wrapper.findAll('[role="group"').length).toBe(groupLength); + }); + }); +}); diff --git a/spec/frontend/notes/components/discussion_jump_to_next_button_spec.js b/spec/frontend/notes/components/discussion_jump_to_next_button_spec.js index a00dd445c4f..2ff9dbc5c19 100644 --- a/spec/frontend/notes/components/discussion_jump_to_next_button_spec.js +++ b/spec/frontend/notes/components/discussion_jump_to_next_button_spec.js @@ -3,9 +3,12 @@ import JumpToNextDiscussionButton from '~/notes/components/discussion_jump_to_ne describe('JumpToNextDiscussionButton', () => { let wrapper; + const fromDiscussionId = 'abc123'; beforeEach(() => { - wrapper = shallowMount(JumpToNextDiscussionButton); + wrapper = shallowMount(JumpToNextDiscussionButton, { + propsData: { fromDiscussionId }, + }); }); afterEach(() => { @@ -15,4 +18,11 @@ describe('JumpToNextDiscussionButton', () => { it('matches the snapshot', () => { expect(wrapper.vm.$el).toMatchSnapshot(); }); + + it('calls jumpToNextRelativeDiscussion when clicked', () => { + const jumpToNextRelativeDiscussion = jest.fn(); + wrapper.setMethods({ jumpToNextRelativeDiscussion }); + wrapper.find({ ref: 'button' }).trigger('click'); + expect(jumpToNextRelativeDiscussion).toHaveBeenCalledWith(fromDiscussionId); + }); }); diff --git a/spec/frontend/notes/components/discussion_keyboard_navigator_spec.js b/spec/frontend/notes/components/discussion_keyboard_navigator_spec.js index 74e827784ec..e932133b869 100644 --- a/spec/frontend/notes/components/discussion_keyboard_navigator_spec.js +++ b/spec/frontend/notes/components/discussion_keyboard_navigator_spec.js @@ -1,84 +1,53 @@ /* global Mousetrap */ import 'mousetrap'; import { shallowMount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; import DiscussionKeyboardNavigator from '~/notes/components/discussion_keyboard_navigator.vue'; -import notesModule from '~/notes/stores/modules'; - -const localVue = createLocalVue(); -localVue.use(Vuex); - -const NEXT_ID = 'abc123'; -const PREV_ID = 'def456'; -const NEXT_DIFF_ID = 'abc123_diff'; -const PREV_DIFF_ID = 'def456_diff'; describe('notes/components/discussion_keyboard_navigator', () => { - let storeOptions; - let wrapper; - let store; + const localVue = createLocalVue(); - const createComponent = (options = {}) => { - store = new Vuex.Store(storeOptions); + let wrapper; + let jumpToNextDiscussion; + let jumpToPreviousDiscussion; + const createComponent = () => { wrapper = shallowMount(DiscussionKeyboardNavigator, { - localVue, - store, - ...options, + mixins: [ + localVue.extend({ + methods: { + jumpToNextDiscussion, + jumpToPreviousDiscussion, + }, + }), + ], }); - - wrapper.vm.jumpToDiscussion = jest.fn(); }; beforeEach(() => { - const notes = notesModule(); - - notes.getters.nextUnresolvedDiscussionId = () => (currId, isDiff) => - isDiff ? NEXT_DIFF_ID : NEXT_ID; - notes.getters.previousUnresolvedDiscussionId = () => (currId, isDiff) => - isDiff ? PREV_DIFF_ID : PREV_ID; - notes.getters.getDiscussion = () => id => ({ id }); - - storeOptions = { - modules: { - notes, - }, - }; + jumpToNextDiscussion = jest.fn(); + jumpToPreviousDiscussion = jest.fn(); }); afterEach(() => { wrapper.destroy(); - storeOptions = null; - store = null; + wrapper = null; }); - describe.each` - currentAction | expectedNextId | expectedPrevId - ${'diffs'} | ${NEXT_DIFF_ID} | ${PREV_DIFF_ID} - ${'show'} | ${NEXT_ID} | ${PREV_ID} - `('when isDiffView is $isDiffView', ({ currentAction, expectedNextId, expectedPrevId }) => { + describe('on mount', () => { beforeEach(() => { - window.mrTabs = { currentAction }; createComponent(); }); - afterEach(() => delete window.mrTabs); it('calls jumpToNextDiscussion when pressing `n`', () => { Mousetrap.trigger('n'); - expect(wrapper.vm.jumpToDiscussion).toHaveBeenCalledWith( - expect.objectContaining({ id: expectedNextId }), - ); - expect(wrapper.vm.currentDiscussionId).toEqual(expectedNextId); + expect(jumpToNextDiscussion).toHaveBeenCalled(); }); it('calls jumpToPreviousDiscussion when pressing `p`', () => { Mousetrap.trigger('p'); - expect(wrapper.vm.jumpToDiscussion).toHaveBeenCalledWith( - expect.objectContaining({ id: expectedPrevId }), - ); - expect(wrapper.vm.currentDiscussionId).toEqual(expectedPrevId); + expect(jumpToPreviousDiscussion).toHaveBeenCalled(); }); }); @@ -99,13 +68,13 @@ describe('notes/components/discussion_keyboard_navigator', () => { it('does not call jumpToNextDiscussion when pressing `n`', () => { Mousetrap.trigger('n'); - expect(wrapper.vm.jumpToDiscussion).not.toHaveBeenCalled(); + expect(jumpToNextDiscussion).not.toHaveBeenCalled(); }); it('does not call jumpToNextDiscussion when pressing `p`', () => { Mousetrap.trigger('p'); - expect(wrapper.vm.jumpToDiscussion).not.toHaveBeenCalled(); + expect(jumpToPreviousDiscussion).not.toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/notes/mixins/discussion_navigation_spec.js b/spec/frontend/notes/mixins/discussion_navigation_spec.js new file mode 100644 index 00000000000..4e5325b8bc3 --- /dev/null +++ b/spec/frontend/notes/mixins/discussion_navigation_spec.js @@ -0,0 +1,178 @@ +import Vuex from 'vuex'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import * as utils from '~/lib/utils/common_utils'; +import discussionNavigation from '~/notes/mixins/discussion_navigation'; +import eventHub from '~/notes/event_hub'; +import notesModule from '~/notes/stores/modules'; +import { setHTMLFixture } from 'helpers/fixtures'; + +const discussion = (id, index) => ({ + id, + resolvable: index % 2 === 0, + active: true, + notes: [{}], + diff_discussion: true, +}); +const createDiscussions = () => [...'abcde'].map(discussion); +const createComponent = () => ({ + mixins: [discussionNavigation], + render() { + return this.$slots.default; + }, +}); + +describe('Discussion navigation mixin', () => { + const localVue = createLocalVue(); + localVue.use(Vuex); + + let wrapper; + let store; + let expandDiscussion; + + beforeEach(() => { + setHTMLFixture( + [...'abcde'] + .map( + id => + `<ul class="notes" data-discussion-id="${id}"></ul> + <div class="discussion" data-discussion-id="${id}"></div>`, + ) + .join(''), + ); + + jest.spyOn(utils, 'scrollToElement'); + + expandDiscussion = jest.fn(); + const { actions, ...notesRest } = notesModule(); + store = new Vuex.Store({ + modules: { + notes: { + ...notesRest, + actions: { ...actions, expandDiscussion }, + }, + }, + }); + store.state.notes.discussions = createDiscussions(); + + wrapper = shallowMount(createComponent(), { store, localVue }); + }); + + afterEach(() => { + wrapper.vm.$destroy(); + jest.clearAllMocks(); + }); + + const findDiscussion = (selector, id) => + document.querySelector(`${selector}[data-discussion-id="${id}"]`); + + describe('cycle through discussions', () => { + beforeEach(() => { + // eslint-disable-next-line new-cap + window.mrTabs = { eventHub: new localVue(), tabShown: jest.fn() }; + }); + + describe.each` + fn | args | currentId | expected + ${'jumpToNextDiscussion'} | ${[]} | ${null} | ${'a'} + ${'jumpToNextDiscussion'} | ${[]} | ${'a'} | ${'c'} + ${'jumpToNextDiscussion'} | ${[]} | ${'e'} | ${'a'} + ${'jumpToPreviousDiscussion'} | ${[]} | ${null} | ${'e'} + ${'jumpToPreviousDiscussion'} | ${[]} | ${'e'} | ${'c'} + ${'jumpToPreviousDiscussion'} | ${[]} | ${'c'} | ${'a'} + ${'jumpToNextRelativeDiscussion'} | ${[null]} | ${null} | ${'a'} + ${'jumpToNextRelativeDiscussion'} | ${['a']} | ${null} | ${'c'} + ${'jumpToNextRelativeDiscussion'} | ${['e']} | ${'c'} | ${'a'} + `('$fn (args = $args, currentId = $currentId)', ({ fn, args, currentId, expected }) => { + beforeEach(() => { + store.state.notes.currentDiscussionId = currentId; + }); + + describe('on `show` active tab', () => { + beforeEach(() => { + window.mrTabs.currentAction = 'show'; + wrapper.vm[fn](...args); + }); + + it('sets current discussion', () => { + expect(store.state.notes.currentDiscussionId).toEqual(expected); + }); + + it('expands discussion', () => { + expect(expandDiscussion).toHaveBeenCalled(); + }); + + it('scrolls to element', () => { + expect(utils.scrollToElement).toHaveBeenCalledWith( + findDiscussion('div.discussion', expected), + ); + }); + }); + + describe('on `diffs` active tab', () => { + beforeEach(() => { + window.mrTabs.currentAction = 'diffs'; + wrapper.vm[fn](...args); + }); + + it('sets current discussion', () => { + expect(store.state.notes.currentDiscussionId).toEqual(expected); + }); + + it('expands discussion', () => { + expect(expandDiscussion).toHaveBeenCalled(); + }); + + it('scrolls when scrollToDiscussion is emitted', () => { + expect(utils.scrollToElement).not.toHaveBeenCalled(); + + eventHub.$emit('scrollToDiscussion'); + + expect(utils.scrollToElement).toHaveBeenCalledWith(findDiscussion('ul.notes', expected)); + }); + }); + + describe('on `other` active tab', () => { + beforeEach(() => { + window.mrTabs.currentAction = 'other'; + wrapper.vm[fn](...args); + }); + + it('sets current discussion', () => { + expect(store.state.notes.currentDiscussionId).toEqual(expected); + }); + + it('does not expand discussion yet', () => { + expect(expandDiscussion).not.toHaveBeenCalled(); + }); + + it('shows mrTabs', () => { + expect(window.mrTabs.tabShown).toHaveBeenCalledWith('show'); + }); + + describe('when tab is changed', () => { + beforeEach(() => { + window.mrTabs.eventHub.$emit('MergeRequestTabChange'); + + jest.runAllTimers(); + }); + + it('expands discussion', () => { + expect(expandDiscussion).toHaveBeenCalledWith( + expect.anything(), + { + discussionId: expected, + }, + undefined, + ); + }); + + it('scrolls to discussion', () => { + expect(utils.scrollToElement).toHaveBeenCalledWith( + findDiscussion('div.discussion', expected), + ); + }); + }); + }); + }); + }); +}); diff --git a/spec/javascripts/notes/components/discussion_counter_spec.js b/spec/javascripts/notes/components/discussion_counter_spec.js deleted file mode 100644 index 9c7aed43a3b..00000000000 --- a/spec/javascripts/notes/components/discussion_counter_spec.js +++ /dev/null @@ -1,90 +0,0 @@ -import Vue from 'vue'; -import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; -import createStore from '~/notes/stores'; -import DiscussionCounter from '~/notes/components/discussion_counter.vue'; -import { noteableDataMock, discussionMock, notesDataMock } from '../mock_data'; - -describe('DiscussionCounter component', () => { - let store; - let vm; - const notes = { currentDiscussionId: null }; - - beforeEach(() => { - window.mrTabs = {}; - - const Component = Vue.extend(DiscussionCounter); - - store = createStore(); - store.dispatch('setNoteableData', noteableDataMock); - store.dispatch('setNotesData', notesDataMock); - - vm = createComponentWithStore(Component, store); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('methods', () => { - describe('jumpToNextDiscussion', () => { - it('expands unresolved discussion', () => { - window.mrTabs.currentAction = 'show'; - - spyOn(vm, 'expandDiscussion').and.stub(); - const discussions = [ - { - ...discussionMock, - id: discussionMock.id, - notes: [{ ...discussionMock.notes[0], resolvable: true, resolved: true }], - resolved: true, - }, - { - ...discussionMock, - id: discussionMock.id + 1, - notes: [{ ...discussionMock.notes[0], resolvable: true, resolved: false }], - resolved: false, - }, - ]; - const firstDiscussionId = discussionMock.id + 1; - store.replaceState({ - ...store.state, - discussions, - notes, - }); - vm.jumpToNextDiscussion(); - - expect(vm.expandDiscussion).toHaveBeenCalledWith({ discussionId: firstDiscussionId }); - }); - - it('jumps to next unresolved discussion from diff tab if all diff discussions are resolved', () => { - window.mrTabs.currentAction = 'diff'; - spyOn(vm, 'switchToDiscussionsTabAndJumpTo').and.stub(); - - const unresolvedId = discussionMock.id + 1; - const discussions = [ - { - ...discussionMock, - id: discussionMock.id, - diff_discussion: true, - notes: [{ ...discussionMock.notes[0], resolvable: true, resolved: true }], - resolved: true, - }, - { - ...discussionMock, - id: unresolvedId, - notes: [{ ...discussionMock.notes[0], resolvable: true, resolved: false }], - resolved: false, - }, - ]; - store.replaceState({ - ...store.state, - discussions, - notes, - }); - vm.jumpToNextDiscussion(); - - expect(vm.switchToDiscussionsTabAndJumpTo).toHaveBeenCalledWith(unresolvedId); - }); - }); - }); -}); diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index 1d652f703b8..7d98f8a0c3e 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -271,6 +271,7 @@ MergeRequest::Metrics: - diff_size - modified_paths_size - commits_count +- first_approved_at Ci::Pipeline: - id - project_id diff --git a/spec/lib/gitlab/job_waiter_spec.rb b/spec/lib/gitlab/job_waiter_spec.rb index efa7fd4b975..da6a6a9149b 100644 --- a/spec/lib/gitlab/job_waiter_spec.rb +++ b/spec/lib/gitlab/job_waiter_spec.rb @@ -37,5 +37,40 @@ describe Gitlab::JobWaiter do expect(result).to contain_exactly('a') end + + context 'when a label is provided' do + let(:waiter) { described_class.new(2, worker_label: 'Foo') } + let(:started_total) { double(:started_total) } + let(:timeouts_total) { double(:timeouts_total) } + + before do + allow(Gitlab::Metrics).to receive(:counter) + .with(described_class::STARTED_METRIC, anything) + .and_return(started_total) + + allow(Gitlab::Metrics).to receive(:counter) + .with(described_class::TIMEOUTS_METRIC, anything) + .and_return(timeouts_total) + end + + it 'increments just job_waiter_started_total when all jobs complete' do + expect(started_total).to receive(:increment).with(worker: 'Foo') + expect(timeouts_total).not_to receive(:increment) + + described_class.notify(waiter.key, 'a') + described_class.notify(waiter.key, 'b') + + result = nil + expect { Timeout.timeout(1) { result = waiter.wait(2) } }.not_to raise_error + end + + it 'increments job_waiter_started_total and job_waiter_timeouts_total when it times out' do + expect(started_total).to receive(:increment).with(worker: 'Foo') + expect(timeouts_total).to receive(:increment).with(worker: 'Foo') + + result = nil + expect { Timeout.timeout(2) { result = waiter.wait(1) } }.not_to raise_error + end + end end end diff --git a/spec/lib/gitlab/tracing_spec.rb b/spec/lib/gitlab/tracing_spec.rb deleted file mode 100644 index e913bb600ec..00000000000 --- a/spec/lib/gitlab/tracing_spec.rb +++ /dev/null @@ -1,69 +0,0 @@ -# frozen_string_literal: true - -require 'fast_spec_helper' -require 'rspec-parameterized' - -describe Gitlab::Tracing do - using RSpec::Parameterized::TableSyntax - - describe '.enabled?' do - where(:connection_string, :enabled_state) do - nil | false - "" | false - "opentracing://jaeger" | true - end - - with_them do - it 'returns the correct state for .enabled?' do - expect(described_class).to receive(:connection_string).and_return(connection_string) - - expect(described_class.enabled?).to eq(enabled_state) - end - end - end - - describe '.tracing_url_enabled?' do - where(:enabled?, :tracing_url_template, :tracing_url_enabled_state) do - false | nil | false - false | "" | false - false | "http://localhost" | false - true | nil | false - true | "" | false - true | "http://localhost" | true - end - - with_them do - it 'returns the correct state for .tracing_url_enabled?' do - expect(described_class).to receive(:enabled?).and_return(enabled?) - allow(described_class).to receive(:tracing_url_template).and_return(tracing_url_template) - - expect(described_class.tracing_url_enabled?).to eq(tracing_url_enabled_state) - end - end - end - - describe '.tracing_url' do - where(:tracing_url_enabled?, :tracing_url_template, :correlation_id, :process_name, :tracing_url) do - false | "https://localhost" | "123" | "web" | nil - true | "https://localhost" | "123" | "web" | "https://localhost" - true | "https://localhost?service={{ service }}" | "123" | "web" | "https://localhost?service=web" - true | "https://localhost?c={{ correlation_id }}" | "123" | "web" | "https://localhost?c=123" - true | "https://localhost?c={{ correlation_id }}&s={{ service }}" | "123" | "web" | "https://localhost?c=123&s=web" - true | "https://localhost?c={{ correlation_id }}" | nil | "web" | "https://localhost?c=" - true | "https://localhost?c={{ correlation_id }}&s=%22{{ service }}%22" | "123" | "web" | "https://localhost?c=123&s=%22web%22" - true | "https://localhost?c={{correlation_id}}&s={{service}}" | "123" | "web" | "https://localhost?c=123&s=web" - true | "https://localhost?c={{correlation_id }}&s={{ service}}" | "123" | "web" | "https://localhost?c=123&s=web" - end - - with_them do - it 'returns the correct state for .tracing_url' do - expect(described_class).to receive(:tracing_url_enabled?).and_return(tracing_url_enabled?) - allow(described_class).to receive(:tracing_url_template).and_return(tracing_url_template) - allow(Labkit::Correlation::CorrelationId).to receive(:current_id).and_return(correlation_id) - allow(Gitlab).to receive(:process_name).and_return(process_name) - - expect(described_class.tracing_url).to eq(tracing_url) - end - end - end -end diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 4bfb5771bb8..37219365ecf 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -2509,6 +2509,64 @@ describe Ci::Build do end end + describe 'CHANGED_PAGES variables' do + let(:route_map_yaml) do + <<~ROUTEMAP + - source: 'bar/branch-test.txt' + public: '/bar/branches' + ROUTEMAP + end + + before do + allow_any_instance_of(Project) + .to receive(:route_map_for).with(/.+/) + .and_return(Gitlab::RouteMap.new(route_map_yaml)) + end + + context 'with a deployment environment and a merge request' do + let(:merge_request) { create(:merge_request, source_project: project, target_project: project) } + let(:environment) { create(:environment, project: merge_request.project, name: "foo-#{project.default_branch}") } + let(:build) { create(:ci_build, pipeline: pipeline, environment: environment.name) } + + it 'populates CI_MERGE_REQUEST_CHANGED_PAGES_* variables' do + expect(subject).to include( + { key: 'CI_MERGE_REQUEST_CHANGED_PAGE_PATHS', value: '/bar/branches', public: true, masked: false }, + { key: 'CI_MERGE_REQUEST_CHANGED_PAGE_URLS', value: File.join(environment.external_url, '/bar/branches'), public: true, masked: false } + ) + end + + context 'with a deployment environment and no merge request' do + let(:environment) { create(:environment, project: project, name: "foo-#{project.default_branch}") } + let(:build) { create(:ci_build, pipeline: pipeline, environment: environment.name) } + + it 'does not append CHANGED_PAGES variables' do + ci_variables = subject.select { |var| var[:key] =~ /MERGE_REQUEST_CHANGED_PAGES/ } + + expect(ci_variables).to be_empty + end + end + + context 'with no deployment environment and a present merge request' do + let(:merge_request) { create(:merge_request, :with_detached_merge_request_pipeline, source_project: project, target_project: project) } + let(:build) { create(:ci_build, pipeline: merge_request.all_pipelines.take) } + + it 'does not append CHANGED_PAGES variables' do + ci_variables = subject.select { |var| var[:key] =~ /MERGE_REQUEST_CHANGED_PAGES/ } + + expect(ci_variables).to be_empty + end + end + + context 'with no deployment environment and no merge request' do + it 'does not append CHANGED_PAGES variables' do + ci_variables = subject.select { |var| var[:key] =~ /MERGE_REQUEST_CHANGED_PAGES/ } + + expect(ci_variables).to be_empty + end + end + end + end + context 'when build has user' do let(:user_variables) do [ diff --git a/spec/models/environment_status_spec.rb b/spec/models/environment_status_spec.rb index 0f2c6928820..10283b54796 100644 --- a/spec/models/environment_status_spec.rb +++ b/spec/models/environment_status_spec.rb @@ -51,8 +51,10 @@ describe EnvironmentStatus do # - source: /files\/(.+)/ # public: '\1' describe '#changes' do + subject { environment_status.changes } + it 'contains only added and modified public pages' do - expect(environment_status.changes).to contain_exactly( + expect(subject).to contain_exactly( { path: 'ruby-style-guide.html', external_url: "#{environment.external_url}/ruby-style-guide.html" @@ -64,6 +66,18 @@ describe EnvironmentStatus do end end + describe '#changed_paths' do + subject { environment_status.changed_urls } + + it { is_expected.to contain_exactly("#{environment.external_url}/ruby-style-guide.html", "#{environment.external_url}/html/page.html") } + end + + describe '#changed_urls' do + subject { environment_status.changed_paths } + + it { is_expected.to contain_exactly('ruby-style-guide.html', 'html/page.html') } + end + describe '.for_merge_request' do let(:admin) { create(:admin) } let!(:pipeline) { create(:ci_pipeline, sha: sha, merge_requests_as_head_pipeline: [merge_request]) } diff --git a/spec/models/pages_domain_spec.rb b/spec/models/pages_domain_spec.rb index 7b24ca5eb23..33459767302 100644 --- a/spec/models/pages_domain_spec.rb +++ b/spec/models/pages_domain_spec.rb @@ -643,4 +643,12 @@ describe PagesDomain do end end end + + describe '.find_by_domain_case_insensitive' do + it 'lookup is case-insensitive' do + pages_domain = create(:pages_domain, domain: "Pages.IO") + + expect(PagesDomain.find_by_domain_case_insensitive('pages.io')).to eq(pages_domain) + end + end end diff --git a/spec/models/project_services/chat_notification_service_spec.rb b/spec/models/project_services/chat_notification_service_spec.rb index 45ea4cd74ed..64c7a9b230d 100644 --- a/spec/models/project_services/chat_notification_service_spec.rb +++ b/spec/models/project_services/chat_notification_service_spec.rb @@ -74,5 +74,28 @@ describe ChatNotificationService do chat_service.execute(data) end end + + shared_examples 'with channel specified' do |channel, expected_channels| + before do + allow(chat_service).to receive(:push_channel).and_return(channel) + end + + it 'notifies all channels' do + expect(chat_service).to receive(:notify).with(any_args, hash_including(channel: expected_channels)).and_return(true) + expect(chat_service.execute(data)).to be(true) + end + end + + context 'with single channel specified' do + it_behaves_like 'with channel specified', 'slack-integration', ['slack-integration'] + end + + context 'with multiple channel names specified' do + it_behaves_like 'with channel specified', 'slack-integration,#slack-test', ['slack-integration', '#slack-test'] + end + + context 'with multiple channel names with spaces specified' do + it_behaves_like 'with channel specified', 'slack-integration, #slack-test, @UDLP91W0A', ['slack-integration', '#slack-test', '@UDLP91W0A'] + end end end diff --git a/spec/requests/api/internal/pages_spec.rb b/spec/requests/api/internal/pages_spec.rb index 99cb2bfe221..44f7115f6a8 100644 --- a/spec/requests/api/internal/pages_spec.rb +++ b/spec/requests/api/internal/pages_spec.rb @@ -141,21 +141,29 @@ describe API::Internal::Pages do context 'custom domain' do let(:namespace) { create(:namespace, name: 'gitlab-org') } let(:project) { create(:project, namespace: namespace, name: 'gitlab-ce') } - let!(:pages_domain) { create(:pages_domain, domain: 'pages.gitlab.io', project: project) } + let!(:pages_domain) { create(:pages_domain, domain: 'pages.io', project: project) } context 'when there are no pages deployed for the related project' do it 'responds with 204 No Content' do - query_host('pages.gitlab.io') + query_host('pages.io') expect(response).to have_gitlab_http_status(:no_content) end end context 'when there are pages deployed for the related project' do + it 'domain lookup is case insensitive' do + deploy_pages(project) + + query_host('Pages.IO') + + expect(response).to have_gitlab_http_status(:ok) + end + it 'responds with the correct domain configuration' do deploy_pages(project) - query_host('pages.gitlab.io') + query_host('pages.io') expect(response).to have_gitlab_http_status(:ok) expect(response).to match_response_schema('internal/pages/virtual_domain') diff --git a/spec/support/shared_examples/models/slack_mattermost_notifications_shared_examples.rb b/spec/support/shared_examples/models/slack_mattermost_notifications_shared_examples.rb index 2b68e7bfa82..24ff57c8517 100644 --- a/spec/support/shared_examples/models/slack_mattermost_notifications_shared_examples.rb +++ b/spec/support/shared_examples/models/slack_mattermost_notifications_shared_examples.rb @@ -151,22 +151,14 @@ RSpec.shared_examples 'slack or mattermost notifications' do |service_name| it 'uses the username as an option for slack when configured' do allow(chat_service).to receive(:username).and_return(username) - expect(Slack::Notifier).to receive(:new) - .with(webhook_url, username: username, http_client: SlackService::Notifier::HTTPClient) - .and_return( - double(:slack_service).as_null_object - ) + expect(Slack::Messenger).to execute_with_options(username: username) chat_service.execute(data) end it 'uses the channel as an option when it is configured' do allow(chat_service).to receive(:channel).and_return(channel) - expect(Slack::Notifier).to receive(:new) - .with(webhook_url, channel: channel, http_client: SlackService::Notifier::HTTPClient) - .and_return( - double(:slack_service).as_null_object - ) + expect(Slack::Messenger).to execute_with_options(channel: [channel]) chat_service.execute(data) end @@ -174,11 +166,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do |service_name| it "uses the right channel for push event" do chat_service.update(push_channel: "random") - expect(Slack::Notifier).to receive(:new) - .with(webhook_url, channel: "random", http_client: SlackService::Notifier::HTTPClient) - .and_return( - double(:slack_service).as_null_object - ) + expect(Slack::Messenger).to execute_with_options(channel: ['random']) chat_service.execute(data) end @@ -186,11 +174,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do |service_name| it "uses the right channel for merge request event" do chat_service.update(merge_request_channel: "random") - expect(Slack::Notifier).to receive(:new) - .with(webhook_url, channel: "random", http_client: SlackService::Notifier::HTTPClient) - .and_return( - double(:slack_service).as_null_object - ) + expect(Slack::Messenger).to execute_with_options(channel: ['random']) chat_service.execute(@merge_sample_data) end @@ -198,11 +182,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do |service_name| it "uses the right channel for issue event" do chat_service.update(issue_channel: "random") - expect(Slack::Notifier).to receive(:new) - .with(webhook_url, channel: "random", http_client: SlackService::Notifier::HTTPClient) - .and_return( - double(:slack_service).as_null_object - ) + expect(Slack::Messenger).to execute_with_options(channel: ['random']) chat_service.execute(@issues_sample_data) end @@ -213,7 +193,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do |service_name| it "uses confidential issue channel" do chat_service.update(confidential_issue_channel: 'confidential') - expect(Slack::Notifier).to execute_with_options(channel: 'confidential') + expect(Slack::Messenger).to execute_with_options(channel: ['confidential']) chat_service.execute(@issues_sample_data) end @@ -221,7 +201,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do |service_name| it 'falls back to issue channel' do chat_service.update(issue_channel: 'fallback_channel') - expect(Slack::Notifier).to execute_with_options(channel: 'fallback_channel') + expect(Slack::Messenger).to execute_with_options(channel: ['fallback_channel']) chat_service.execute(@issues_sample_data) end @@ -230,11 +210,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do |service_name| it "uses the right channel for wiki event" do chat_service.update(wiki_page_channel: "random") - expect(Slack::Notifier).to receive(:new) - .with(webhook_url, channel: "random", http_client: SlackService::Notifier::HTTPClient) - .and_return( - double(:slack_service).as_null_object - ) + expect(Slack::Messenger).to execute_with_options(channel: ['random']) chat_service.execute(@wiki_page_sample_data) end @@ -249,11 +225,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do |service_name| note_data = Gitlab::DataBuilder::Note.build(issue_note, user) - expect(Slack::Notifier).to receive(:new) - .with(webhook_url, channel: "random", http_client: SlackService::Notifier::HTTPClient) - .and_return( - double(:slack_service).as_null_object - ) + expect(Slack::Messenger).to execute_with_options(channel: ['random']) chat_service.execute(note_data) end @@ -268,7 +240,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do |service_name| note_data = Gitlab::DataBuilder::Note.build(issue_note, user) - expect(Slack::Notifier).to execute_with_options(channel: 'confidential') + expect(Slack::Messenger).to execute_with_options(channel: ['confidential']) chat_service.execute(note_data) end @@ -278,7 +250,7 @@ RSpec.shared_examples 'slack or mattermost notifications' do |service_name| note_data = Gitlab::DataBuilder::Note.build(issue_note, user) - expect(Slack::Notifier).to execute_with_options(channel: 'fallback_channel') + expect(Slack::Messenger).to execute_with_options(channel: ['fallback_channel']) chat_service.execute(note_data) end |