diff options
Diffstat (limited to 'spec')
17 files changed, 566 insertions, 335 deletions
diff --git a/spec/frontend/blob/components/blob_embeddable_spec.js b/spec/frontend/blob/components/blob_embeddable_spec.js new file mode 100644 index 00000000000..b2fe71f1401 --- /dev/null +++ b/spec/frontend/blob/components/blob_embeddable_spec.js @@ -0,0 +1,35 @@ +import { shallowMount } from '@vue/test-utils'; +import BlobEmbeddable from '~/blob/components/blob_embeddable.vue'; +import { GlFormInputGroup } from '@gitlab/ui'; + +describe('Blob Embeddable', () => { + let wrapper; + const url = 'https://foo.bar'; + + function createComponent() { + wrapper = shallowMount(BlobEmbeddable, { + propsData: { + url, + }, + }); + } + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders gl-form-input-group component', () => { + expect(wrapper.find(GlFormInputGroup).exists()).toBe(true); + }); + + it('makes up optionValues based on the url prop', () => { + expect(wrapper.vm.optionValues).toEqual([ + { name: 'Embed', value: expect.stringContaining(`${url}.js`) }, + { name: 'Share', value: url }, + ]); + }); +}); diff --git a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap index 2ed93396376..2b2ff074f83 100644 --- a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap +++ b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap @@ -69,9 +69,8 @@ exports[`Dashboard template matches the default snapshot 1`] = ` label-size="sm" > <date-time-picker-stub - end="2020-01-01T18:57:47.000Z" - start="2020-01-01T18:27:47.000Z" - timewindows="[object Object]" + options="[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]" + value="[object Object]" /> </gl-form-group-stub> diff --git a/spec/frontend/monitoring/components/dashboard_template_spec.js b/spec/frontend/monitoring/components/dashboard_template_spec.js index d525f4821f4..38523ab82bc 100644 --- a/spec/frontend/monitoring/components/dashboard_template_spec.js +++ b/spec/frontend/monitoring/components/dashboard_template_spec.js @@ -5,13 +5,7 @@ import Dashboard from '~/monitoring/components/dashboard.vue'; import { createStore } from '~/monitoring/stores'; import { propsData } from '../init_utils'; -jest.mock('~/lib/utils/url_utility', () => ({ - getParameterValues: jest.fn().mockImplementation(param => { - if (param === 'start') return ['2020-01-01T18:27:47.000Z']; - if (param === 'end') return ['2020-01-01T18:57:47.000Z']; - return []; - }), -})); +jest.mock('~/lib/utils/url_utility'); describe('Dashboard template', () => { let wrapper; diff --git a/spec/frontend/monitoring/components/dashboard_time_url_spec.js b/spec/frontend/monitoring/components/dashboard_time_url_spec.js deleted file mode 100644 index 2da377eb79f..00000000000 --- a/spec/frontend/monitoring/components/dashboard_time_url_spec.js +++ /dev/null @@ -1,51 +0,0 @@ -import { mount } from '@vue/test-utils'; -import createFlash from '~/flash'; -import MockAdapter from 'axios-mock-adapter'; -import Dashboard from '~/monitoring/components/dashboard.vue'; -import { createStore } from '~/monitoring/stores'; -import { propsData } from '../init_utils'; -import axios from '~/lib/utils/axios_utils'; - -jest.mock('~/flash'); - -jest.mock('~/lib/utils/url_utility', () => ({ - getParameterValues: jest.fn().mockReturnValue('<script>alert("XSS")</script>'), -})); - -describe('dashboard invalid url parameters', () => { - let store; - let wrapper; - let mock; - - const createMountedWrapper = (props = {}, options = {}) => { - wrapper = mount(Dashboard, { - propsData: { ...propsData, ...props }, - store, - ...options, - }); - }; - - beforeEach(() => { - store = createStore(); - mock = new MockAdapter(axios); - }); - - afterEach(() => { - if (wrapper) { - wrapper.destroy(); - } - mock.restore(); - }); - - it('shows an error message if invalid url parameters are passed', done => { - createMountedWrapper({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] }); - - wrapper.vm - .$nextTick() - .then(() => { - expect(createFlash).toHaveBeenCalled(); - done(); - }) - .catch(done.fail); - }); -}); diff --git a/spec/frontend/monitoring/components/dashboard_time_window_spec.js b/spec/frontend/monitoring/components/dashboard_time_window_spec.js deleted file mode 100644 index e9f2a67983a..00000000000 --- a/spec/frontend/monitoring/components/dashboard_time_window_spec.js +++ /dev/null @@ -1,69 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { GlDropdownItem } from '@gitlab/ui'; -import MockAdapter from 'axios-mock-adapter'; -import axios from '~/lib/utils/axios_utils'; -import statusCodes from '~/lib/utils/http_status'; -import Dashboard from '~/monitoring/components/dashboard.vue'; -import { createStore } from '~/monitoring/stores'; -import { propsData, setupComponentStore } from '../init_utils'; -import { metricsDashboardPayload, mockApiEndpoint } from '../mock_data'; - -jest.mock('~/lib/utils/url_utility', () => ({ - getParameterValues: jest.fn().mockImplementation(param => { - if (param === 'start') return ['2019-10-01T18:27:47.000Z']; - if (param === 'end') return ['2019-10-01T18:57:47.000Z']; - return []; - }), - mergeUrlParams: jest.fn().mockReturnValue('#'), -})); - -describe('dashboard time window', () => { - let store; - let wrapper; - let mock; - - const createComponentWrapperMounted = (props = {}, options = {}) => { - wrapper = mount(Dashboard, { - propsData: { ...propsData, ...props }, - store, - ...options, - }); - }; - - beforeEach(() => { - store = createStore(); - mock = new MockAdapter(axios); - }); - - afterEach(() => { - if (wrapper) { - wrapper.destroy(); - } - mock.restore(); - }); - - it('shows an active quick range option', done => { - mock.onGet(mockApiEndpoint).reply(statusCodes.OK, metricsDashboardPayload); - - createComponentWrapperMounted({ hasMetrics: true }, { stubs: ['graph-group', 'panel-type'] }); - - setupComponentStore(wrapper); - - wrapper.vm - .$nextTick() - .then(() => { - const timeWindowDropdownItems = wrapper - .find({ ref: 'dateTimePicker' }) - .findAll(GlDropdownItem); - - const activeItem = timeWindowDropdownItems.wrappers.filter(itemWrapper => - itemWrapper.find('.active').exists(), - ); - - expect(activeItem.length).toBe(1); - - done(); - }) - .catch(done.fail); - }); -}); diff --git a/spec/frontend/monitoring/components/dashboard_url_time_spec.js b/spec/frontend/monitoring/components/dashboard_url_time_spec.js new file mode 100644 index 00000000000..33fbfac486f --- /dev/null +++ b/spec/frontend/monitoring/components/dashboard_url_time_spec.js @@ -0,0 +1,145 @@ +import { mount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import createFlash from '~/flash'; +import { queryToObject, redirectTo, removeParams, mergeUrlParams } from '~/lib/utils/url_utility'; +import axios from '~/lib/utils/axios_utils'; +import { mockProjectDir } from '../mock_data'; + +import Dashboard from '~/monitoring/components/dashboard.vue'; +import { createStore } from '~/monitoring/stores'; +import { propsData } from '../init_utils'; + +jest.mock('~/flash'); +jest.mock('~/lib/utils/url_utility'); + +describe('dashboard invalid url parameters', () => { + let store; + let wrapper; + let mock; + + const fetchDataMock = jest.fn(); + + const createMountedWrapper = (props = { hasMetrics: true }, options = {}) => { + wrapper = mount(Dashboard, { + propsData: { ...propsData, ...props }, + store, + stubs: ['graph-group', 'panel-type'], + methods: { + fetchData: fetchDataMock, + }, + ...options, + }); + }; + + const findDateTimePicker = () => wrapper.find({ ref: 'dateTimePicker' }); + + beforeEach(() => { + store = createStore(); + mock = new MockAdapter(axios); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + } + mock.restore(); + fetchDataMock.mockReset(); + queryToObject.mockReset(); + }); + + it('passes default url parameters to the time range picker', () => { + queryToObject.mockReturnValue({}); + + createMountedWrapper(); + + return wrapper.vm.$nextTick().then(() => { + expect(findDateTimePicker().props('value')).toMatchObject({ + duration: { seconds: 28800 }, + }); + + expect(fetchDataMock).toHaveBeenCalledTimes(1); + expect(fetchDataMock).toHaveBeenCalledWith({ + start: expect.any(String), + end: expect.any(String), + }); + }); + }); + + it('passes a fixed time range in the URL to the time range picker', () => { + const params = { + start: '2019-01-01T00:00:00.000Z', + end: '2019-01-10T00:00:00.000Z', + }; + + queryToObject.mockReturnValue(params); + + createMountedWrapper(); + + return wrapper.vm.$nextTick().then(() => { + expect(findDateTimePicker().props('value')).toEqual(params); + + expect(fetchDataMock).toHaveBeenCalledTimes(1); + expect(fetchDataMock).toHaveBeenCalledWith(params); + }); + }); + + it('passes a rolling time range in the URL to the time range picker', () => { + queryToObject.mockReturnValue({ + duration_seconds: '120', + }); + + createMountedWrapper(); + + return wrapper.vm.$nextTick().then(() => { + expect(findDateTimePicker().props('value')).toMatchObject({ + duration: { seconds: 60 * 2 }, + }); + + expect(fetchDataMock).toHaveBeenCalledTimes(1); + expect(fetchDataMock).toHaveBeenCalledWith({ + start: expect.any(String), + end: expect.any(String), + }); + }); + }); + + it('shows an error message and loads a default time range if invalid url parameters are passed', () => { + queryToObject.mockReturnValue({ + start: '<script>alert("XSS")</script>', + end: '<script>alert("XSS")</script>', + }); + + createMountedWrapper(); + + return wrapper.vm.$nextTick().then(() => { + expect(createFlash).toHaveBeenCalled(); + + expect(findDateTimePicker().props('value')).toMatchObject({ + duration: { seconds: 28800 }, + }); + + expect(fetchDataMock).toHaveBeenCalledTimes(1); + expect(fetchDataMock).toHaveBeenCalledWith({ + start: expect.any(String), + end: expect.any(String), + }); + }); + }); + + it('redirects to different time range', () => { + const toUrl = `${mockProjectDir}/-/environments/1/metrics`; + removeParams.mockReturnValueOnce(toUrl); + + createMountedWrapper(); + + return wrapper.vm.$nextTick().then(() => { + findDateTimePicker().vm.$emit('input', { + duration: { seconds: 120 }, + }); + + // redirect to plus + new parameters + expect(mergeUrlParams).toHaveBeenCalledWith({ duration_seconds: '120' }, toUrl); + expect(redirectTo).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/spec/frontend/registry/list/components/__snapshots__/project_empty_state_spec.js.snap b/spec/frontend/registry/list/components/__snapshots__/project_empty_state_spec.js.snap index c8482bf08ca..426bc5c0e6c 100644 --- a/spec/frontend/registry/list/components/__snapshots__/project_empty_state_spec.js.snap +++ b/spec/frontend/registry/list/components/__snapshots__/project_empty_state_spec.js.snap @@ -90,11 +90,10 @@ exports[`Registry Project Empty state to match the default snapshot 1`] = ` type="button" > <svg - aria-hidden="true" - class="s16 ic-duplicate" + class="gl-icon s16" > <use - xlink:href="#duplicate" + href="#copy-to-clipboard" /> </svg> </button> @@ -128,11 +127,10 @@ exports[`Registry Project Empty state to match the default snapshot 1`] = ` type="button" > <svg - aria-hidden="true" - class="s16 ic-duplicate" + class="gl-icon s16" > <use - xlink:href="#duplicate" + href="#copy-to-clipboard" /> </svg> </button> @@ -158,11 +156,10 @@ exports[`Registry Project Empty state to match the default snapshot 1`] = ` type="button" > <svg - aria-hidden="true" - class="s16 ic-duplicate" + class="gl-icon s16" > <use - xlink:href="#duplicate" + href="#copy-to-clipboard" /> </svg> </button> diff --git a/spec/frontend/snippets/components/app_spec.js b/spec/frontend/snippets/components/app_spec.js index 6576e5b075f..a683ed9aaba 100644 --- a/spec/frontend/snippets/components/app_spec.js +++ b/spec/frontend/snippets/components/app_spec.js @@ -1,5 +1,7 @@ import SnippetApp from '~/snippets/components/app.vue'; import SnippetHeader from '~/snippets/components/snippet_header.vue'; +import SnippetTitle from '~/snippets/components/snippet_title.vue'; +import SnippetBlob from '~/snippets/components/snippet_blob_view.vue'; import { GlLoadingIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; @@ -35,8 +37,10 @@ describe('Snippet view app', () => { expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); }); - it('renders SnippetHeader component after the query is finished', () => { + it('renders all components after the query is finished', () => { createComponent(); expect(wrapper.find(SnippetHeader).exists()).toBe(true); + expect(wrapper.find(SnippetTitle).exists()).toBe(true); + expect(wrapper.find(SnippetBlob).exists()).toBe(true); }); }); diff --git a/spec/frontend/snippets/components/snippet_blob_view_spec.js b/spec/frontend/snippets/components/snippet_blob_view_spec.js new file mode 100644 index 00000000000..8401c08b1da --- /dev/null +++ b/spec/frontend/snippets/components/snippet_blob_view_spec.js @@ -0,0 +1,54 @@ +import { shallowMount } from '@vue/test-utils'; +import SnippetBlobView from '~/snippets/components/snippet_blob_view.vue'; +import BlobEmbeddable from '~/blob/components/blob_embeddable.vue'; +import { + SNIPPET_VISIBILITY_PRIVATE, + SNIPPET_VISIBILITY_INTERNAL, + SNIPPET_VISIBILITY_PUBLIC, +} from '~/snippets/constants'; + +describe('Blob Embeddable', () => { + let wrapper; + const snippet = { + id: 'gid://foo.bar/snippet', + webUrl: 'https://foo.bar', + visibilityLevel: SNIPPET_VISIBILITY_PUBLIC, + }; + + function createComponent(props = {}) { + wrapper = shallowMount(SnippetBlobView, { + propsData: { + snippet: { + ...snippet, + ...props, + }, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders blob-embeddable component', () => { + createComponent(); + expect(wrapper.find(BlobEmbeddable).exists()).toBe(true); + }); + + it('does not render blob-embeddable for internal snippet', () => { + createComponent({ + visibilityLevel: SNIPPET_VISIBILITY_INTERNAL, + }); + expect(wrapper.find(BlobEmbeddable).exists()).toBe(false); + + createComponent({ + visibilityLevel: SNIPPET_VISIBILITY_PRIVATE, + }); + expect(wrapper.find(BlobEmbeddable).exists()).toBe(false); + + createComponent({ + visibilityLevel: 'foo', + }); + expect(wrapper.find(BlobEmbeddable).exists()).toBe(false); + }); +}); diff --git a/spec/frontend/vue_shared/components/clipboard_button_spec.js b/spec/frontend/vue_shared/components/clipboard_button_spec.js index 37f71867ab9..07ff86828e7 100644 --- a/spec/frontend/vue_shared/components/clipboard_button_spec.js +++ b/spec/frontend/vue_shared/components/clipboard_button_spec.js @@ -1,7 +1,6 @@ import { shallowMount } from '@vue/test-utils'; -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlIcon } from '@gitlab/ui'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import Icon from '~/vue_shared/components/icon.vue'; describe('clipboard button', () => { let wrapper; @@ -29,7 +28,7 @@ describe('clipboard button', () => { it('renders a button for clipboard', () => { expect(wrapper.find(GlButton).exists()).toBe(true); expect(wrapper.attributes('data-clipboard-text')).toBe('copy me'); - expect(wrapper.find(Icon).props('name')).toBe('duplicate'); + expect(wrapper.find(GlIcon).props('name')).toBe('copy-to-clipboard'); }); it('should have a tooltip with default values', () => { diff --git a/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js b/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js index b7b024183e1..3a75ab2d127 100644 --- a/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js +++ b/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_lib_spec.js @@ -54,97 +54,6 @@ describe('date time picker lib', () => { }); }); - describe('getTimeWindow', () => { - [ - { - args: [ - { - start: '2019-10-01T18:27:47.000Z', - end: '2019-10-01T21:27:47.000Z', - }, - dateTimePickerLib.defaultTimeWindows, - ], - expected: 'threeHours', - }, - { - args: [ - { - start: '2019-10-01T28:27:47.000Z', - end: '2019-10-01T21:27:47.000Z', - }, - dateTimePickerLib.defaultTimeWindows, - ], - expected: null, - }, - { - args: [ - { - start: '', - end: '', - }, - dateTimePickerLib.defaultTimeWindows, - ], - expected: null, - }, - { - args: [ - { - start: null, - end: null, - }, - dateTimePickerLib.defaultTimeWindows, - ], - expected: null, - }, - { - args: [{}, dateTimePickerLib.defaultTimeWindows], - expected: null, - }, - ].forEach(({ args, expected }) => { - it(`returns "${expected}" with args=${JSON.stringify(args)}`, () => { - expect(dateTimePickerLib.getTimeWindowKey(...args)).toEqual(expected); - }); - }); - }); - - describe('getTimeRange', () => { - function secondsBetween({ start, end }) { - return (new Date(end) - new Date(start)) / 1000; - } - - function minutesBetween(timeRange) { - return secondsBetween(timeRange) / 60; - } - - function hoursBetween(timeRange) { - return minutesBetween(timeRange) / 60; - } - - it('defaults to an 8 hour (28800s) difference', () => { - const params = dateTimePickerLib.getTimeRange(); - - expect(hoursBetween(params)).toEqual(8); - }); - - it('accepts time window as an argument', () => { - const params = dateTimePickerLib.getTimeRange('thirtyMinutes'); - - expect(minutesBetween(params)).toEqual(30); - }); - - it('returns a value for every defined time window', () => { - const nonDefaultWindows = Object.entries(dateTimePickerLib.defaultTimeWindows).filter( - ([, timeWindow]) => !timeWindow.default, - ); - nonDefaultWindows.forEach(timeWindow => { - const params = dateTimePickerLib.getTimeRange(timeWindow[0]); - - // Ensure we're not returning the default - expect(hoursBetween(params)).not.toEqual(8); - }); - }); - }); - describe('stringToISODate', () => { ['', 'null', undefined, 'abc'].forEach(input => { it(`throws error for invalid input like ${input}`, done => { diff --git a/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_spec.js b/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_spec.js index 98dfbe9cd14..90130917d8f 100644 --- a/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_spec.js +++ b/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_spec.js @@ -1,11 +1,11 @@ import { mount } from '@vue/test-utils'; import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue'; -import { defaultTimeWindows } from '~/vue_shared/components/date_time_picker/date_time_picker_lib'; +import { + defaultTimeRanges, + defaultTimeRange, +} from '~/vue_shared/components/date_time_picker/date_time_picker_lib'; -const timeWindowsCount = Object.entries(defaultTimeWindows).length; -const start = '2019-10-10T07:00:00.000Z'; -const end = '2019-10-13T07:00:00.000Z'; -const selectedTimeWindowText = `3 days`; +const optionsCount = defaultTimeRanges.length; describe('DateTimePicker', () => { let dateTimePicker; @@ -15,19 +15,10 @@ describe('DateTimePicker', () => { const applyButtonElement = () => dateTimePicker.find('button.btn-success').element; const findQuickRangeItems = () => dateTimePicker.findAll('.dropdown-item'); const cancelButtonElement = () => dateTimePicker.find('button.btn-secondary').element; - const fillInputAndBlur = (input, val) => { - dateTimePicker.find(input).setValue(val); - return dateTimePicker.vm.$nextTick().then(() => { - dateTimePicker.find(input).trigger('blur'); - return dateTimePicker.vm.$nextTick(); - }); - }; const createComponent = props => { dateTimePicker = mount(DateTimePicker, { propsData: { - start, - end, ...props, }, }); @@ -40,7 +31,7 @@ describe('DateTimePicker', () => { it('renders dropdown toggle button with selected text', done => { createComponent(); dateTimePicker.vm.$nextTick(() => { - expect(dropdownToggle().text()).toBe(selectedTimeWindowText); + expect(dropdownToggle().text()).toBe(defaultTimeRange.label); done(); }); }); @@ -54,8 +45,10 @@ describe('DateTimePicker', () => { it('renders inputs with h/m/s truncated if its all 0s', done => { createComponent({ - start: '2019-10-10T00:00:00.000Z', - end: '2019-10-14T00:10:00.000Z', + value: { + start: '2019-10-10T00:00:00.000Z', + end: '2019-10-14T00:10:00.000Z', + }, }); dateTimePicker.vm.$nextTick(() => { expect(dateTimePicker.find('#custom-time-from').element.value).toBe('2019-10-10'); @@ -64,22 +57,21 @@ describe('DateTimePicker', () => { }); }); - it(`renders dropdown with ${timeWindowsCount} (default) items in quick range`, done => { + it(`renders dropdown with ${optionsCount} (default) items in quick range`, done => { createComponent(); dropdownToggle().trigger('click'); dateTimePicker.vm.$nextTick(() => { - expect(findQuickRangeItems().length).toBe(timeWindowsCount); + expect(findQuickRangeItems().length).toBe(optionsCount); done(); }); }); - it(`renders dropdown with correct quick range item selected`, done => { + it('renders dropdown with a default quick range item selected', done => { createComponent(); dropdownToggle().trigger('click'); dateTimePicker.vm.$nextTick(() => { - expect(dateTimePicker.find('.dropdown-item.active').text()).toBe(selectedTimeWindowText); - - expect(dateTimePicker.find('.dropdown-item.active svg').isVisible()).toBe(true); + expect(dateTimePicker.find('.dropdown-item.active').exists()).toBe(true); + expect(dateTimePicker.find('.dropdown-item.active').text()).toBe(defaultTimeRange.label); done(); }); }); @@ -92,99 +84,142 @@ describe('DateTimePicker', () => { expect(applyButtonElement().getAttribute('disabled')).toBe('disabled'); }); - it('displays inline error message if custom time range inputs are invalid', done => { - createComponent(); - fillInputAndBlur('#custom-time-from', '2019-10-01abc') - .then(() => fillInputAndBlur('#custom-time-to', '2019-10-10abc')) - .then(() => { - expect(dateTimePicker.findAll('.invalid-feedback').length).toBe(2); - done(); - }) - .catch(done); - }); + describe('user input', () => { + const fillInputAndBlur = (input, val) => { + dateTimePicker.find(input).setValue(val); + return dateTimePicker.vm.$nextTick().then(() => { + dateTimePicker.find(input).trigger('blur'); + return dateTimePicker.vm.$nextTick(); + }); + }; - it('keeps apply button disabled with invalid custom time range inputs', done => { - createComponent(); - fillInputAndBlur('#custom-time-from', '2019-10-01abc') - .then(() => fillInputAndBlur('#custom-time-to', '2019-09-19')) - .then(() => { - expect(applyButtonElement().getAttribute('disabled')).toBe('disabled'); - done(); - }) - .catch(done); - }); + beforeEach(done => { + createComponent(); + dateTimePicker.vm.$nextTick(done); + }); - it('enables apply button with valid custom time range inputs', done => { - createComponent(); - fillInputAndBlur('#custom-time-from', '2019-10-01') - .then(() => fillInputAndBlur('#custom-time-to', '2019-10-19')) - .then(() => { - expect(applyButtonElement().getAttribute('disabled')).toBeNull(); - done(); - }) - .catch(done.fail); - }); + it('displays inline error message if custom time range inputs are invalid', done => { + fillInputAndBlur('#custom-time-from', '2019-10-01abc') + .then(() => fillInputAndBlur('#custom-time-to', '2019-10-10abc')) + .then(() => { + expect(dateTimePicker.findAll('.invalid-feedback').length).toBe(2); + done(); + }) + .catch(done); + }); - it('emits dates in an object when apply is clicked', done => { - createComponent(); - fillInputAndBlur('#custom-time-from', '2019-10-01') - .then(() => fillInputAndBlur('#custom-time-to', '2019-10-19')) - .then(() => { - applyButtonElement().click(); - - expect(dateTimePicker.emitted().apply).toHaveLength(1); - expect(dateTimePicker.emitted().apply[0]).toEqual([ - { - end: '2019-10-19T00:00:00Z', - start: '2019-10-01T00:00:00Z', - }, - ]); - done(); - }) - .catch(done.fail); - }); + it('keeps apply button disabled with invalid custom time range inputs', done => { + fillInputAndBlur('#custom-time-from', '2019-10-01abc') + .then(() => fillInputAndBlur('#custom-time-to', '2019-09-19')) + .then(() => { + expect(applyButtonElement().getAttribute('disabled')).toBe('disabled'); + done(); + }) + .catch(done); + }); - it('hides the popover with cancel button', done => { - createComponent(); - dropdownToggle().trigger('click'); + it('enables apply button with valid custom time range inputs', done => { + fillInputAndBlur('#custom-time-from', '2019-10-01') + .then(() => fillInputAndBlur('#custom-time-to', '2019-10-19')) + .then(() => { + expect(applyButtonElement().getAttribute('disabled')).toBeNull(); + done(); + }) + .catch(done.fail); + }); - dateTimePicker.vm.$nextTick(() => { - cancelButtonElement().click(); + it('emits dates in an object when apply is clicked', done => { + fillInputAndBlur('#custom-time-from', '2019-10-01') + .then(() => fillInputAndBlur('#custom-time-to', '2019-10-19')) + .then(() => { + applyButtonElement().click(); + + expect(dateTimePicker.emitted().input).toHaveLength(1); + expect(dateTimePicker.emitted().input[0]).toEqual([ + { + end: '2019-10-19T00:00:00Z', + start: '2019-10-01T00:00:00Z', + }, + ]); + done(); + }) + .catch(done.fail); + }); + + it('unchecks quick range when text is input is clicked', done => { + const findActiveItems = () => findQuickRangeItems().filter(w => w.is('.active')); + + expect(findActiveItems().length).toBe(1); + + fillInputAndBlur('#custom-time-from', '2019-10-01') + .then(() => { + expect(findActiveItems().length).toBe(0); + + done(); + }) + .catch(done.fail); + }); + + it('emits dates in an object when a is clicked', () => { + findQuickRangeItems() + .at(3) // any item + .trigger('click'); + + expect(dateTimePicker.emitted().input).toHaveLength(1); + expect(dateTimePicker.emitted().input[0][0]).toMatchObject({ + duration: { + seconds: expect.any(Number), + }, + }); + }); + + it('hides the popover with cancel button', done => { + dropdownToggle().trigger('click'); dateTimePicker.vm.$nextTick(() => { - expect(dropdownMenu().classes('show')).toBe(false); - done(); + cancelButtonElement().click(); + + dateTimePicker.vm.$nextTick(() => { + expect(dropdownMenu().classes('show')).toBe(false); + done(); + }); }); }); }); describe('when using non-default time windows', () => { - const otherTimeWindows = { - oneMinute: { + const MOCK_NOW = Date.UTC(2020, 0, 23, 20); + + const otherTimeRanges = [ + { label: '1 minute', - seconds: 60, + duration: { seconds: 60 }, }, - twoMinutes: { + { label: '2 minutes', - seconds: 60 * 2, + duration: { seconds: 60 * 2 }, default: true, }, - fiveMinutes: { + { label: '5 minutes', - seconds: 60 * 5, + duration: { seconds: 60 * 5 }, }, - }; + ]; + + beforeEach(() => { + jest.spyOn(Date, 'now').mockImplementation(() => MOCK_NOW); + }); it('renders dropdown with a label in the quick range', done => { createComponent({ - // 2 minutes range - start: '2020-01-21T15:00:00.000Z', - end: '2020-01-21T15:02:00.000Z', - timeWindows: otherTimeWindows, + value: { + duration: { seconds: 60 * 5 }, + }, + options: otherTimeRanges, }); dropdownToggle().trigger('click'); dateTimePicker.vm.$nextTick(() => { - expect(dropdownToggle().text()).toBe('2 minutes'); + expect(dropdownToggle().text()).toBe('5 minutes'); done(); }); @@ -192,16 +227,16 @@ describe('DateTimePicker', () => { it('renders dropdown with quick range items', done => { createComponent({ - // 2 minutes range - start: '2020-01-21T15:00:00.000Z', - end: '2020-01-21T15:02:00.000Z', - timeWindows: otherTimeWindows, + value: { + duration: { seconds: 60 * 2 }, + }, + options: otherTimeRanges, }); dropdownToggle().trigger('click'); dateTimePicker.vm.$nextTick(() => { const items = findQuickRangeItems(); - expect(items.length).toBe(Object.keys(otherTimeWindows).length); + expect(items.length).toBe(Object.keys(otherTimeRanges).length); expect(items.at(0).text()).toBe('1 minute'); expect(items.at(0).is('.active')).toBe(false); @@ -217,14 +252,13 @@ describe('DateTimePicker', () => { it('renders dropdown with a label not in the quick range', done => { createComponent({ - // 10 minutes range - start: '2020-01-21T15:00:00.000Z', - end: '2020-01-21T15:10:00.000Z', - timeWindows: otherTimeWindows, + value: { + duration: { seconds: 60 * 4 }, + }, }); dropdownToggle().trigger('click'); dateTimePicker.vm.$nextTick(() => { - expect(dropdownToggle().text()).toBe('2020-01-21 15:00:00 to 2020-01-21 15:10:00'); + expect(dropdownToggle().text()).toBe('2020-01-23 19:56:00 to 2020-01-23 20:00:00'); done(); }); diff --git a/spec/lib/gitlab/background_migration/update_existing_subgroup_to_match_visibility_level_of_parent_spec.rb b/spec/lib/gitlab/background_migration/update_existing_subgroup_to_match_visibility_level_of_parent_spec.rb new file mode 100644 index 00000000000..37280110b91 --- /dev/null +++ b/spec/lib/gitlab/background_migration/update_existing_subgroup_to_match_visibility_level_of_parent_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::BackgroundMigration::UpdateExistingSubgroupToMatchVisibilityLevelOfParent, :migration, schema: 2020_01_10_121314 do + include MigrationHelpers::NamespacesHelpers + + context 'private visibility level' do + it 'updates the project visibility' do + parent = create_namespace('parent', Gitlab::VisibilityLevel::PRIVATE) + child = create_namespace('child', Gitlab::VisibilityLevel::PUBLIC, parent_id: parent.id) + + expect { subject.perform([parent.id], Gitlab::VisibilityLevel::PRIVATE) }.to change { child.reload.visibility_level }.to(Gitlab::VisibilityLevel::PRIVATE) + end + + it 'updates sub-sub groups' do + parent = create_namespace('parent', Gitlab::VisibilityLevel::PRIVATE) + middle_group = create_namespace('middle', Gitlab::VisibilityLevel::PRIVATE, parent_id: parent.id) + child = create_namespace('child', Gitlab::VisibilityLevel::PUBLIC, parent_id: middle_group.id) + + subject.perform([parent.id, middle_group.id], Gitlab::VisibilityLevel::PRIVATE) + + expect(child.reload.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE) + end + + it 'updates all sub groups' do + parent = create_namespace('parent', Gitlab::VisibilityLevel::PRIVATE) + middle_group = create_namespace('middle', Gitlab::VisibilityLevel::PUBLIC, parent_id: parent.id) + child = create_namespace('child', Gitlab::VisibilityLevel::PUBLIC, parent_id: middle_group.id) + + subject.perform([parent.id], Gitlab::VisibilityLevel::PRIVATE) + + expect(child.reload.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE) + expect(middle_group.reload.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE) + end + end + + context 'internal visibility level' do + it 'updates the project visibility' do + parent = create_namespace('parent', Gitlab::VisibilityLevel::INTERNAL) + child = create_namespace('child', Gitlab::VisibilityLevel::PUBLIC, parent_id: parent.id) + + expect { subject.perform([parent.id], Gitlab::VisibilityLevel::INTERNAL) }.to change { child.reload.visibility_level }.to(Gitlab::VisibilityLevel::INTERNAL) + end + end +end diff --git a/spec/lib/gitlab/database/with_lock_retries_spec.rb b/spec/lib/gitlab/database/with_lock_retries_spec.rb index c3be6510584..b6321f2eab1 100644 --- a/spec/lib/gitlab/database/with_lock_retries_spec.rb +++ b/spec/lib/gitlab/database/with_lock_retries_spec.rb @@ -128,4 +128,23 @@ describe Gitlab::Database::WithLockRetries do end end end + + context 'casting durations correctly' do + let(:timing_configuration) { [[0.015.seconds, 0.025.seconds], [0.015.seconds, 0.025.seconds]] } # 15ms, 25ms + + it 'executes `SET LOCAL lock_timeout` using the configured timeout value in milliseconds' do + expect(ActiveRecord::Base.connection).to receive(:execute).with("SAVEPOINT active_record_1").and_call_original + expect(ActiveRecord::Base.connection).to receive(:execute).with("SET LOCAL lock_timeout TO '15ms'").and_call_original + expect(ActiveRecord::Base.connection).to receive(:execute).with("RELEASE SAVEPOINT active_record_1").and_call_original + + subject.run { } + end + + it 'calls `sleep` after the first iteration fails, using the configured sleep time' do + expect(subject).to receive(:run_block_with_transaction).and_raise(ActiveRecord::LockWaitTimeout).twice + expect(subject).to receive(:sleep).with(0.025) + + subject.run { } + end + end end diff --git a/spec/migrations/schedule_update_existing_subgroup_to_match_visibility_level_of_parent_spec.rb b/spec/migrations/schedule_update_existing_subgroup_to_match_visibility_level_of_parent_spec.rb new file mode 100644 index 00000000000..5ff9ff4641f --- /dev/null +++ b/spec/migrations/schedule_update_existing_subgroup_to_match_visibility_level_of_parent_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20200110121314_schedule_update_existing_subgroup_to_match_visibility_level_of_parent.rb') + +describe ScheduleUpdateExistingSubgroupToMatchVisibilityLevelOfParent, :migration, :sidekiq do + include MigrationHelpers::NamespacesHelpers + let(:migration_class) { described_class::MIGRATION } + let(:migration_name) { migration_class.to_s.demodulize } + + context 'private visibility level' do + it 'correctly schedules background migrations' do + parent = create_namespace('parent', Gitlab::VisibilityLevel::PRIVATE) + create_namespace('child', Gitlab::VisibilityLevel::PUBLIC, parent_id: parent.id) + + Sidekiq::Testing.fake! do + Timecop.freeze do + migrate! + + expect(BackgroundMigrationWorker.jobs.size).to eq(1) + expect(migration_name).to be_scheduled_migration_with_multiple_args([parent.id], Gitlab::VisibilityLevel::PRIVATE) + end + end + end + + it 'correctly schedules background migrations for groups and subgroups' do + parent = create_namespace('parent', Gitlab::VisibilityLevel::PRIVATE) + middle_group = create_namespace('middle_group', Gitlab::VisibilityLevel::PRIVATE, parent_id: parent.id) + create_namespace('middle_empty_group', Gitlab::VisibilityLevel::PRIVATE, parent_id: parent.id) + create_namespace('child', Gitlab::VisibilityLevel::PUBLIC, parent_id: middle_group.id) + + Sidekiq::Testing.fake! do + Timecop.freeze do + migrate! + + expect(BackgroundMigrationWorker.jobs.size).to eq(1) + expect(migration_name).to be_scheduled_migration_with_multiple_args([middle_group.id, parent.id], Gitlab::VisibilityLevel::PRIVATE) + end + end + end + end + + context 'internal visibility level' do + it 'correctly schedules background migrations' do + parent = create_namespace('parent', Gitlab::VisibilityLevel::INTERNAL) + middle_group = create_namespace('child', Gitlab::VisibilityLevel::INTERNAL, parent_id: parent.id) + create_namespace('child', Gitlab::VisibilityLevel::PUBLIC, parent_id: middle_group.id) + + Sidekiq::Testing.fake! do + Timecop.freeze do + migrate! + + expect(BackgroundMigrationWorker.jobs.size).to eq(1) + expect(migration_name).to be_scheduled_migration_with_multiple_args([parent.id, middle_group.id], Gitlab::VisibilityLevel::INTERNAL) + end + end + end + end + + context 'mixed visibility levels' do + it 'correctly schedules background migrations' do + parent1 = create_namespace('parent1', Gitlab::VisibilityLevel::INTERNAL) + create_namespace('child', Gitlab::VisibilityLevel::PUBLIC, parent_id: parent1.id) + parent2 = create_namespace('parent2', Gitlab::VisibilityLevel::PRIVATE) + middle_group = create_namespace('middle_group', Gitlab::VisibilityLevel::INTERNAL, parent_id: parent2.id) + create_namespace('child', Gitlab::VisibilityLevel::PUBLIC, parent_id: middle_group.id) + + Sidekiq::Testing.fake! do + Timecop.freeze do + migrate! + + expect(BackgroundMigrationWorker.jobs.size).to eq(2) + expect(migration_name).to be_scheduled_migration_with_multiple_args([parent1.id, middle_group.id], Gitlab::VisibilityLevel::INTERNAL) + expect(migration_name).to be_scheduled_migration_with_multiple_args([parent2.id], Gitlab::VisibilityLevel::PRIVATE) + end + end + end + end +end diff --git a/spec/support/matchers/background_migrations_matchers.rb b/spec/support/matchers/background_migrations_matchers.rb index c38aa7ad6a6..8735dac8b2a 100644 --- a/spec/support/matchers/background_migrations_matchers.rb +++ b/spec/support/matchers/background_migrations_matchers.rb @@ -26,3 +26,26 @@ RSpec::Matchers.define :be_scheduled_migration do |*expected| "Migration `#{migration}` with args `#{expected.inspect}` not scheduled!" end end + +RSpec::Matchers.define :be_scheduled_migration_with_multiple_args do |*expected| + match do |migration| + BackgroundMigrationWorker.jobs.any? do |job| + args = job['args'].size == 1 ? [BackgroundMigrationWorker.jobs[0]['args'][0], []] : job['args'] + args[0] == migration && compare_args(args, expected) + end + end + + failure_message do |migration| + "Migration `#{migration}` with args `#{expected.inspect}` not scheduled!" + end + + def compare_args(args, expected) + args[1].map.with_index do |arg, i| + arg.is_a?(Array) ? same_arrays?(arg, expected[i]) : arg == expected[i] + end.all? + end + + def same_arrays?(arg, expected) + arg.sort == expected.sort + end +end diff --git a/spec/support/migrations_helpers/namespaces_helper.rb b/spec/support/migrations_helpers/namespaces_helper.rb new file mode 100644 index 00000000000..4ca01c87568 --- /dev/null +++ b/spec/support/migrations_helpers/namespaces_helper.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module MigrationHelpers + module NamespacesHelpers + def create_namespace(name, visibility, options = {}) + table(:namespaces).create({ + name: name, + path: name, + type: 'Group', + visibility_level: visibility + }.merge(options)) + end + end +end |