diff options
Diffstat (limited to 'spec')
13 files changed, 700 insertions, 195 deletions
diff --git a/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js b/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js index 5ce29bd6c5d..3e699b93fd3 100644 --- a/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js +++ b/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js @@ -1,72 +1,101 @@ -import { GlForm, GlSprintf, GlLoadingIcon } from '@gitlab/ui'; -import { mount, shallowMount } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlForm, GlDropdownItem, GlSprintf, GlLoadingIcon } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; -import { nextTick } from 'vue'; import CreditCardValidationRequiredAlert from 'ee_component/billings/components/cc_validation_required_alert.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; import httpStatusCodes from '~/lib/utils/http_status'; import { redirectTo } from '~/lib/utils/url_utility'; import PipelineNewForm from '~/pipeline_new/components/pipeline_new_form.vue'; +import ciConfigVariablesQuery from '~/pipeline_new/graphql/queries/ci_config_variables.graphql'; +import { resolvers } from '~/pipeline_new/graphql/resolvers'; import RefsDropdown from '~/pipeline_new/components/refs_dropdown.vue'; import { + mockCreditCardValidationRequiredError, + mockCiConfigVariablesResponse, + mockCiConfigVariablesResponseWithoutDesc, + mockEmptyCiConfigVariablesResponse, + mockError, mockQueryParams, mockPostParams, mockProjectId, - mockError, mockRefs, - mockCreditCardValidationRequiredError, + mockYamlVariables, } from '../mock_data'; +Vue.use(VueApollo); + jest.mock('~/lib/utils/url_utility', () => ({ redirectTo: jest.fn(), })); const projectRefsEndpoint = '/root/project/refs'; const pipelinesPath = '/root/project/-/pipelines'; -const configVariablesPath = '/root/project/-/pipelines/config_variables'; +const projectPath = '/root/project/-/pipelines/config_variables'; const newPipelinePostResponse = { id: 1 }; const defaultBranch = 'main'; describe('Pipeline New Form', () => { let wrapper; let mock; + let mockApollo; + let mockCiConfigVariables; let dummySubmitEvent; const findForm = () => wrapper.findComponent(GlForm); const findRefsDropdown = () => wrapper.findComponent(RefsDropdown); - const findSubmitButton = () => wrapper.find('[data-testid="run_pipeline_button"]'); - const findVariableRows = () => wrapper.findAll('[data-testid="ci-variable-row"]'); - const findRemoveIcons = () => wrapper.findAll('[data-testid="remove-ci-variable-row"]'); - const findDropdowns = () => wrapper.findAll('[data-testid="pipeline-form-ci-variable-type"]'); - const findKeyInputs = () => wrapper.findAll('[data-testid="pipeline-form-ci-variable-key"]'); - const findValueInputs = () => wrapper.findAll('[data-testid="pipeline-form-ci-variable-value"]'); - const findErrorAlert = () => wrapper.find('[data-testid="run-pipeline-error-alert"]'); - const findWarningAlert = () => wrapper.find('[data-testid="run-pipeline-warning-alert"]'); + const findSubmitButton = () => wrapper.findByTestId('run_pipeline_button'); + const findVariableRows = () => wrapper.findAllByTestId('ci-variable-row'); + const findRemoveIcons = () => wrapper.findAllByTestId('remove-ci-variable-row'); + const findVariableTypes = () => wrapper.findAllByTestId('pipeline-form-ci-variable-type'); + const findKeyInputs = () => wrapper.findAllByTestId('pipeline-form-ci-variable-key'); + const findValueInputs = () => wrapper.findAllByTestId('pipeline-form-ci-variable-value'); + const findValueDropdowns = () => + wrapper.findAllByTestId('pipeline-form-ci-variable-value-dropdown'); + const findValueDropdownItems = (dropdown) => dropdown.findAllComponents(GlDropdownItem); + const findErrorAlert = () => wrapper.findByTestId('run-pipeline-error-alert'); + const findWarningAlert = () => wrapper.findByTestId('run-pipeline-warning-alert'); const findWarningAlertSummary = () => findWarningAlert().findComponent(GlSprintf); - const findWarnings = () => wrapper.findAll('[data-testid="run-pipeline-warning"]'); + const findWarnings = () => wrapper.findAllByTestId('run-pipeline-warning'); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findCCAlert = () => wrapper.findComponent(CreditCardValidationRequiredAlert); const getFormPostParams = () => JSON.parse(mock.history.post[0].data); - const selectBranch = (branch) => { + const selectBranch = async (branch) => { // Select a branch in the dropdown findRefsDropdown().vm.$emit('input', { shortName: branch, fullName: `refs/heads/${branch}`, }); + + await waitForPromises(); + }; + + const changeKeyInputValue = async (keyInputIndex, value) => { + const input = findKeyInputs().at(keyInputIndex); + input.element.value = value; + input.trigger('change'); + + await nextTick(); }; - const createComponent = (props = {}, method = shallowMount) => { + const createComponentWithApollo = ({ method = shallowMountExtended, props = {} } = {}) => { + const handlers = [[ciConfigVariablesQuery, mockCiConfigVariables]]; + mockApollo = createMockApollo(handlers, resolvers); + wrapper = method(PipelineNewForm, { + apolloProvider: mockApollo, provide: { projectRefsEndpoint, }, propsData: { projectId: mockProjectId, pipelinesPath, - configVariablesPath, + projectPath, defaultBranch, refParam: defaultBranch, settingsLink: '', @@ -78,7 +107,7 @@ describe('Pipeline New Form', () => { beforeEach(() => { mock = new MockAdapter(axios); - mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, {}); + mockCiConfigVariables = jest.fn(); mock.onGet(projectRefsEndpoint).reply(httpStatusCodes.OK, mockRefs); dummySubmitEvent = { @@ -87,24 +116,20 @@ describe('Pipeline New Form', () => { }); afterEach(() => { - wrapper.destroy(); - wrapper = null; - mock.restore(); + wrapper.destroy(); }); describe('Form', () => { beforeEach(async () => { - createComponent(mockQueryParams, mount); - - mock.onPost(pipelinesPath).reply(httpStatusCodes.OK, newPipelinePostResponse); - + mockCiConfigVariables.mockResolvedValue(mockEmptyCiConfigVariablesResponse); + createComponentWithApollo({ props: mockQueryParams, method: mountExtended }); await waitForPromises(); }); it('displays the correct values for the provided query params', async () => { - expect(findDropdowns().at(0).props('text')).toBe('Variable'); - expect(findDropdowns().at(1).props('text')).toBe('File'); + expect(findVariableTypes().at(0).props('text')).toBe('Variable'); + expect(findVariableTypes().at(1).props('text')).toBe('File'); expect(findRefsDropdown().props('value')).toEqual({ shortName: 'tag-1' }); expect(findVariableRows()).toHaveLength(3); }); @@ -117,7 +142,7 @@ describe('Pipeline New Form', () => { it('displays an empty variable for the user to fill out', async () => { expect(findKeyInputs().at(2).element.value).toBe(''); expect(findValueInputs().at(2).element.value).toBe(''); - expect(findDropdowns().at(2).props('text')).toBe('Variable'); + expect(findVariableTypes().at(2).props('text')).toBe('Variable'); }); it('does not display remove icon for last row', () => { @@ -147,13 +172,12 @@ describe('Pipeline New Form', () => { describe('Pipeline creation', () => { beforeEach(async () => { + mockCiConfigVariables.mockResolvedValue(mockEmptyCiConfigVariablesResponse); mock.onPost(pipelinesPath).reply(httpStatusCodes.OK, newPipelinePostResponse); - - await waitForPromises(); }); it('does not submit the native HTML form', async () => { - createComponent(); + createComponentWithApollo(); findForm().vm.$emit('submit', dummySubmitEvent); @@ -161,7 +185,7 @@ describe('Pipeline New Form', () => { }); it('disables the submit button immediately after submitting', async () => { - createComponent(); + createComponentWithApollo(); expect(findSubmitButton().props('disabled')).toBe(false); @@ -172,7 +196,7 @@ describe('Pipeline New Form', () => { }); it('creates pipeline with full ref and variables', async () => { - createComponent(); + createComponentWithApollo(); findForm().vm.$emit('submit', dummySubmitEvent); await waitForPromises(); @@ -182,7 +206,7 @@ describe('Pipeline New Form', () => { }); it('creates a pipeline with short ref and variables from the query params', async () => { - createComponent(mockQueryParams); + createComponentWithApollo({ props: mockQueryParams }); await waitForPromises(); @@ -197,64 +221,51 @@ describe('Pipeline New Form', () => { describe('When the ref has been changed', () => { beforeEach(async () => { - createComponent({}, mount); + mockCiConfigVariables.mockResolvedValue(mockEmptyCiConfigVariablesResponse); + createComponentWithApollo({ method: mountExtended }); await waitForPromises(); }); - it('variables persist between ref changes', async () => { - selectBranch('main'); - - await waitForPromises(); - const mainInput = findKeyInputs().at(0); - mainInput.element.value = 'build_var'; - mainInput.trigger('change'); + it('variables persist between ref changes', async () => { + await selectBranch('main'); + await changeKeyInputValue(0, 'build_var'); - await nextTick(); + await selectBranch('branch-1'); + await changeKeyInputValue(0, 'deploy_var'); - selectBranch('branch-1'); + await selectBranch('main'); - await waitForPromises(); + expect(findKeyInputs().at(0).element.value).toBe('build_var'); + expect(findVariableRows().length).toBe(2); - const branchOneInput = findKeyInputs().at(0); - branchOneInput.element.value = 'deploy_var'; - branchOneInput.trigger('change'); + await selectBranch('branch-1'); - await nextTick(); + expect(findKeyInputs().at(0).element.value).toBe('deploy_var'); + expect(findVariableRows().length).toBe(2); + }); - selectBranch('main'); + it('skips query call when form variables are already cached', async () => { + await selectBranch('main'); + await changeKeyInputValue(0, 'build_var'); - await waitForPromises(); + expect(mockCiConfigVariables).toHaveBeenCalledTimes(1); - expect(findKeyInputs().at(0).element.value).toBe('build_var'); - expect(findVariableRows().length).toBe(2); + await selectBranch('branch-1'); - selectBranch('branch-1'); + expect(mockCiConfigVariables).toHaveBeenCalledTimes(2); - await waitForPromises(); + // no additional call since `main` form values have been cached + await selectBranch('main'); - expect(findKeyInputs().at(0).element.value).toBe('deploy_var'); - expect(findVariableRows().length).toBe(2); + expect(mockCiConfigVariables).toHaveBeenCalledTimes(2); }); }); describe('when yml defines a variable', () => { - const mockYmlKey = 'yml_var'; - const mockYmlValue = 'yml_var_val'; - const mockYmlMultiLineValue = `A value - with multiple - lines`; - const mockYmlDesc = 'A var from yml.'; - it('loading icon is shown when content is requested and hidden when received', async () => { - createComponent(mockQueryParams, mount); - - mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, { - [mockYmlKey]: { - value: mockYmlValue, - description: mockYmlDesc, - }, - }); + mockCiConfigVariables.mockResolvedValue(mockEmptyCiConfigVariablesResponse); + createComponentWithApollo({ props: mockQueryParams, method: mountExtended }); expect(findLoadingIcon().exists()).toBe(true); @@ -263,51 +274,62 @@ describe('Pipeline New Form', () => { expect(findLoadingIcon().exists()).toBe(false); }); - it('multi-line strings are added to the value field without removing line breaks', async () => { - createComponent(mockQueryParams, mount); + describe('with different predefined values', () => { + beforeEach(async () => { + mockCiConfigVariables.mockResolvedValue(mockCiConfigVariablesResponse); + createComponentWithApollo({ method: mountExtended }); + await waitForPromises(); + }); + + it('multi-line strings are added to the value field without removing line breaks', () => { + expect(findValueInputs().at(1).element.value).toBe(mockYamlVariables[1].value); + }); - mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, { - [mockYmlKey]: { - value: mockYmlMultiLineValue, - description: mockYmlDesc, - }, + it('multiple predefined values are rendered as a dropdown', () => { + const dropdown = findValueDropdowns().at(0); + const dropdownItems = findValueDropdownItems(dropdown); + const { valueOptions } = mockYamlVariables[2]; + + expect(dropdownItems.at(0).text()).toBe(valueOptions[0]); + expect(dropdownItems.at(1).text()).toBe(valueOptions[1]); + expect(dropdownItems.at(2).text()).toBe(valueOptions[2]); }); - await waitForPromises(); + it('variables with multiple predefined values sets the first option as the default', () => { + const dropdown = findValueDropdowns().at(0); + const { valueOptions } = mockYamlVariables[2]; - expect(findValueInputs().at(0).element.value).toBe(mockYmlMultiLineValue); + expect(dropdown.props('text')).toBe(valueOptions[0]); + }); }); describe('with description', () => { beforeEach(async () => { - createComponent(mockQueryParams, mount); - - mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, { - [mockYmlKey]: { - value: mockYmlValue, - description: mockYmlDesc, - }, - }); - + mockCiConfigVariables.mockResolvedValue(mockCiConfigVariablesResponse); + createComponentWithApollo({ props: mockQueryParams, method: mountExtended }); await waitForPromises(); }); it('displays all the variables', async () => { - expect(findVariableRows()).toHaveLength(4); + expect(findVariableRows()).toHaveLength(6); }); it('displays a variable from yml', () => { - expect(findKeyInputs().at(0).element.value).toBe(mockYmlKey); - expect(findValueInputs().at(0).element.value).toBe(mockYmlValue); + expect(findKeyInputs().at(0).element.value).toBe(mockYamlVariables[0].key); + expect(findValueInputs().at(0).element.value).toBe(mockYamlVariables[0].value); }); it('displays a variable from provided query params', () => { - expect(findKeyInputs().at(1).element.value).toBe('test_var'); - expect(findValueInputs().at(1).element.value).toBe('test_var_val'); + expect(findKeyInputs().at(3).element.value).toBe( + Object.keys(mockQueryParams.variableParams)[0], + ); + expect(findValueInputs().at(3).element.value).toBe( + Object.values(mockQueryParams.fileParams)[0], + ); }); it('adds a description to the first variable from yml', () => { - expect(findVariableRows().at(0).text()).toContain(mockYmlDesc); + expect(findVariableRows().at(0).text()).toContain(mockYamlVariables[0].description); }); it('removes the description when a variable key changes', async () => { @@ -316,39 +338,27 @@ describe('Pipeline New Form', () => { await nextTick(); - expect(findVariableRows().at(0).text()).not.toContain(mockYmlDesc); + expect(findVariableRows().at(0).text()).not.toContain(mockYamlVariables[0].description); }); }); describe('without description', () => { beforeEach(async () => { - createComponent(mockQueryParams, mount); - - mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, { - [mockYmlKey]: { - value: mockYmlValue, - description: null, - }, - yml_var2: { - value: 'yml_var2_val', - }, - yml_var3: { - description: '', - }, - }); - + mockCiConfigVariables.mockResolvedValue(mockCiConfigVariablesResponseWithoutDesc); + createComponentWithApollo({ method: mountExtended }); await waitForPromises(); }); - it('displays all the variables', async () => { - expect(findVariableRows()).toHaveLength(3); + it('displays variables with description only', async () => { + expect(findVariableRows()).toHaveLength(2); // extra empty variable is added at the end }); }); }); describe('Form errors and warnings', () => { beforeEach(() => { - createComponent(); + mockCiConfigVariables.mockResolvedValue(mockEmptyCiConfigVariablesResponse); + createComponentWithApollo(); }); describe('when the refs cannot be loaded', () => { diff --git a/spec/frontend/pipeline_new/mock_data.js b/spec/frontend/pipeline_new/mock_data.js index e99684ff417..e95a65171fc 100644 --- a/spec/frontend/pipeline_new/mock_data.js +++ b/spec/frontend/pipeline_new/mock_data.js @@ -65,3 +65,62 @@ export const mockVariables = [ }, { uniqueId: 'var-refs/heads/main4', variable_type: 'env_var', key: '', value: '' }, ]; + +export const mockYamlVariables = [ + { + description: 'This is a variable with a value.', + key: 'VAR_WITH_VALUE', + value: 'test_value', + valueOptions: null, + }, + { + description: 'This is a variable with a multi-line value.', + key: 'VAR_WITH_MULTILINE', + value: `this is + a multiline value`, + valueOptions: null, + }, + { + description: 'This is a variable with predefined values.', + key: 'VAR_WITH_OPTIONS', + value: 'development', + valueOptions: ['development', 'staging', 'production'], + }, +]; + +export const mockYamlVariablesWithoutDesc = [ + { + description: 'This is a variable with a value.', + key: 'VAR_WITH_VALUE', + value: 'test_value', + valueOptions: null, + }, + { + description: null, + key: 'VAR_WITH_MULTILINE', + value: `this is + a multiline value`, + valueOptions: null, + }, + { + description: null, + key: 'VAR_WITH_OPTIONS', + value: 'development', + valueOptions: ['development', 'staging', 'production'], + }, +]; + +export const mockCiConfigVariablesQueryResponse = (ciConfigVariables) => ({ + data: { + project: { + id: 1, + ciConfigVariables, + }, + }, +}); + +export const mockCiConfigVariablesResponse = mockCiConfigVariablesQueryResponse(mockYamlVariables); +export const mockEmptyCiConfigVariablesResponse = mockCiConfigVariablesQueryResponse([]); +export const mockCiConfigVariablesResponseWithoutDesc = mockCiConfigVariablesQueryResponse( + mockYamlVariablesWithoutDesc, +); diff --git a/spec/frontend/pipeline_schedules/components/pipeline_schedules_spec.js b/spec/frontend/pipeline_schedules/components/pipeline_schedules_spec.js index d0292c65bcd..cce8f480928 100644 --- a/spec/frontend/pipeline_schedules/components/pipeline_schedules_spec.js +++ b/spec/frontend/pipeline_schedules/components/pipeline_schedules_spec.js @@ -1,13 +1,18 @@ -import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; +import { GlAlert, GlLoadingIcon, GlModal } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; +import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import PipelineSchedules from '~/pipeline_schedules/components/pipeline_schedules.vue'; import PipelineSchedulesTable from '~/pipeline_schedules/components/table/pipeline_schedules_table.vue'; +import deletePipelineScheduleMutation from '~/pipeline_schedules/graphql/mutations/delete_pipeline_schedule.mutation.graphql'; import getPipelineSchedulesQuery from '~/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql'; -import { mockGetPipelineSchedulesGraphQLResponse, mockPipelineScheduleNodes } from '../mock_data'; +import { + mockGetPipelineSchedulesGraphQLResponse, + mockPipelineScheduleNodes, + deleteMutationResponse, +} from '../mock_data'; Vue.use(VueApollo); @@ -17,24 +22,28 @@ describe('Pipeline schedules app', () => { const successHandler = jest.fn().mockResolvedValue(mockGetPipelineSchedulesGraphQLResponse); const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error')); - const createMockApolloProvider = (handler) => { - const requestHandlers = [[getPipelineSchedulesQuery, handler]]; + const deleteMutationHandlerSuccess = jest.fn().mockResolvedValue(deleteMutationResponse); + const deleteMutationHandlerFailed = jest.fn().mockRejectedValue(new Error('GraphQL error')); + const createMockApolloProvider = ( + requestHandlers = [[getPipelineSchedulesQuery, successHandler]], + ) => { return createMockApollo(requestHandlers); }; - const createComponent = (handler = successHandler) => { + const createComponent = (requestHandlers) => { wrapper = shallowMount(PipelineSchedules, { provide: { fullPath: 'gitlab-org/gitlab', }, - apolloProvider: createMockApolloProvider(handler), + apolloProvider: createMockApolloProvider(requestHandlers), }); }; const findTable = () => wrapper.findComponent(PipelineSchedulesTable); const findAlert = () => wrapper.findComponent(GlAlert); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findModal = () => wrapper.findComponent(GlModal); afterEach(() => { wrapper.destroy(); @@ -69,11 +78,84 @@ describe('Pipeline schedules app', () => { expect(findLoadingIcon().exists()).toBe(false); }); - it('shows error alert', async () => { - createComponent(failedHandler); + it('shows query error alert', async () => { + createComponent([[getPipelineSchedulesQuery, failedHandler]]); await waitForPromises(); - expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe('There was a problem fetching pipeline schedules.'); + }); + + it('shows delete mutation error alert', async () => { + createComponent([ + [getPipelineSchedulesQuery, successHandler], + [deletePipelineScheduleMutation, deleteMutationHandlerFailed], + ]); + + await waitForPromises(); + + findModal().vm.$emit('primary'); + + await waitForPromises(); + + expect(findAlert().text()).toBe('There was a problem deleting the pipeline schedule.'); + }); + + it('deletes pipeline schedule and refetches query', async () => { + createComponent([ + [getPipelineSchedulesQuery, successHandler], + [deletePipelineScheduleMutation, deleteMutationHandlerSuccess], + ]); + + jest.spyOn(wrapper.vm.$apollo.queries.schedules, 'refetch'); + + await waitForPromises(); + + const scheduleId = mockPipelineScheduleNodes[0].id; + + findTable().vm.$emit('showDeleteModal', scheduleId); + + expect(wrapper.vm.$apollo.queries.schedules.refetch).not.toHaveBeenCalled(); + + findModal().vm.$emit('primary'); + + await waitForPromises(); + + expect(deleteMutationHandlerSuccess).toHaveBeenCalledWith({ + id: scheduleId, + }); + expect(wrapper.vm.$apollo.queries.schedules.refetch).toHaveBeenCalled(); + }); + + it('modal should be visible after event', async () => { + createComponent(); + + await waitForPromises(); + + expect(findModal().props('visible')).toBe(false); + + findTable().vm.$emit('showDeleteModal', mockPipelineScheduleNodes[0].id); + + await nextTick(); + + expect(findModal().props('visible')).toBe(true); + }); + + it('modal should be hidden', async () => { + createComponent(); + + await waitForPromises(); + + findTable().vm.$emit('showDeleteModal', mockPipelineScheduleNodes[0].id); + + await nextTick(); + + expect(findModal().props('visible')).toBe(true); + + findModal().vm.$emit('hide'); + + await nextTick(); + + expect(findModal().props('visible')).toBe(false); }); }); diff --git a/spec/frontend/pipeline_schedules/components/table/cells/pipeline_schedule_actions_spec.js b/spec/frontend/pipeline_schedules/components/table/cells/pipeline_schedule_actions_spec.js index 8f51269f8ab..ecc1bdeb679 100644 --- a/spec/frontend/pipeline_schedules/components/table/cells/pipeline_schedule_actions_spec.js +++ b/spec/frontend/pipeline_schedules/components/table/cells/pipeline_schedule_actions_spec.js @@ -1,5 +1,5 @@ import { GlButton } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import PipelineScheduleActions from '~/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue'; import { mockPipelineScheduleNodes, mockPipelineScheduleAsGuestNodes } from '../../../mock_data'; @@ -11,7 +11,7 @@ describe('Pipeline schedule actions', () => { }; const createComponent = (props = defaultProps) => { - wrapper = shallowMount(PipelineScheduleActions, { + wrapper = shallowMountExtended(PipelineScheduleActions, { propsData: { ...props, }, @@ -19,6 +19,7 @@ describe('Pipeline schedule actions', () => { }; const findAllButtons = () => wrapper.findAllComponents(GlButton); + const findDeleteBtn = () => wrapper.findByTestId('delete-pipeline-schedule-btn'); afterEach(() => { wrapper.destroy(); @@ -35,4 +36,14 @@ describe('Pipeline schedule actions', () => { expect(findAllButtons()).toHaveLength(0); }); + + it('delete button emits showDeleteModal event and schedule id', () => { + createComponent(); + + findDeleteBtn().vm.$emit('click'); + + expect(wrapper.emitted()).toEqual({ + showDeleteModal: [[mockPipelineScheduleNodes[0].id]], + }); + }); }); diff --git a/spec/frontend/pipeline_schedules/mock_data.js b/spec/frontend/pipeline_schedules/mock_data.js index b551b4c529d..0a60998d8fb 100644 --- a/spec/frontend/pipeline_schedules/mock_data.js +++ b/spec/frontend/pipeline_schedules/mock_data.js @@ -1,3 +1,4 @@ +// Fixture located at spec/frontend/fixtures/pipeline_schedules.rb import mockGetPipelineSchedulesGraphQLResponse from 'test_fixtures/graphql/pipeline_schedules/get_pipeline_schedules.query.graphql.json'; import mockGetPipelineSchedulesAsGuestGraphQLResponse from 'test_fixtures/graphql/pipeline_schedules/get_pipeline_schedules.query.graphql.as_guest.json'; @@ -21,4 +22,14 @@ export const mockPipelineScheduleNodes = nodes; export const mockPipelineScheduleAsGuestNodes = guestNodes; +export const deleteMutationResponse = { + data: { + pipelineScheduleDelete: { + clientMutationId: null, + errors: [], + __typename: 'PipelineScheduleDeletePayload', + }, + }, +}; + export { mockGetPipelineSchedulesGraphQLResponse }; diff --git a/spec/helpers/markup_helper_spec.rb b/spec/helpers/markup_helper_spec.rb index 5b16a8fbb40..a2e34471324 100644 --- a/spec/helpers/markup_helper_spec.rb +++ b/spec/helpers/markup_helper_spec.rb @@ -562,20 +562,6 @@ FooBar shared_examples_for 'common markdown examples' do let(:project_base) { build(:project, :repository) } - it 'displays inline code' do - object = create_object('Text with `inline code`') - expected = 'Text with <code>inline code</code>' - - expect(first_line_in_markdown(object, attribute, 100, project: project)).to match(expected) - end - - it 'truncates the text with multiple paragraphs' do - object = create_object("Paragraph 1\n\nParagraph 2") - expected = 'Paragraph 1...' - - expect(first_line_in_markdown(object, attribute, 100, project: project)).to match(expected) - end - it 'displays the first line of a code block' do object = create_object("```\nCode block\nwith two lines\n```") expected = %r{<pre.+><code><span class="line">Code block\.\.\.</span>\n</code></pre>} @@ -591,18 +577,6 @@ FooBar expect(first_line_in_markdown(object, attribute, 150, project: project)).to match(expected) end - it 'preserves a link href when link text is truncated' do - text = 'The quick brown fox jumped over the lazy dog' # 44 chars - link_url = 'http://example.com/foo/bar/baz' # 30 chars - input = "#{text}#{text}#{text} #{link_url}" # 163 chars - expected_link_text = 'http://example...</a>' - - object = create_object(input) - - expect(first_line_in_markdown(object, attribute, 150, project: project)).to match(link_url) - expect(first_line_in_markdown(object, attribute, 150, project: project)).to match(expected_link_text) - end - it 'preserves code color scheme' do object = create_object("```ruby\ndef test\n 'hello world'\nend\n```") expected = "\n<pre class=\"code highlight js-syntax-highlight language-ruby\">" \ @@ -669,40 +643,6 @@ FooBar expect(result).to include(html) end - it 'truncates Markdown properly' do - object = create_object("@#{user.username}, can you look at this?\nHello world\n") - actual = first_line_in_markdown(object, attribute, 100, project: project) - - doc = Nokogiri::HTML.parse(actual) - - # Make sure we didn't create invalid markup - expect(doc.errors).to be_empty - - # Leading user link - expect(doc.css('a').length).to eq(1) - expect(doc.css('a')[0].attr('href')).to eq user_path(user) - expect(doc.css('a')[0].text).to eq "@#{user.username}" - - expect(doc.content).to eq "@#{user.username}, can you look at this?..." - end - - it 'truncates Markdown with emoji properly' do - object = create_object("foo :wink:\nbar :grinning:") - actual = first_line_in_markdown(object, attribute, 100, project: project) - - doc = Nokogiri::HTML.parse(actual) - - # Make sure we didn't create invalid markup - # But also account for the 2 errors caused by the unknown `gl-emoji` elements - expect(doc.errors.length).to eq(2) - - expect(doc.css('gl-emoji').length).to eq(2) - expect(doc.css('gl-emoji')[0].attr('data-name')).to eq 'wink' - expect(doc.css('gl-emoji')[1].attr('data-name')).to eq 'grinning' - - expect(doc.content).to eq "foo 😉\nbar 😀" - end - it 'does not post-process truncated text', :request_store do object = create_object("hello \n\n [Test](README.md)") diff --git a/spec/lib/banzai/filter/truncate_visible_filter_spec.rb b/spec/lib/banzai/filter/truncate_visible_filter_spec.rb new file mode 100644 index 00000000000..8daaed05264 --- /dev/null +++ b/spec/lib/banzai/filter/truncate_visible_filter_spec.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Banzai::Filter::TruncateVisibleFilter do + include FilterSpecHelper + + let_it_be(:project) { build(:project, :repository) } + let_it_be(:max_chars) { 100 } + let_it_be(:user) do + user = create(:user, username: 'gfm') + project.add_maintainer(user) + user + end + + # Since we're truncating nodes of an html document, actually use the + # full pipeline to generate full documents. + def convert_markdown(text, context = {}) + Banzai::Pipeline::FullPipeline.to_html(text, { project: project }.merge(context)) + end + + shared_examples_for 'truncates text' do + specify do + html = convert_markdown(markdown) + doc = filter(html, { truncate_visible_max_chars: max_chars }) + + expect(doc.to_html).to match(expected) + end + end + + describe 'displays inline code' do + let(:markdown) { 'Text with `inline code`' } + let(:expected) { 'Text with <code>inline code</code>' } + + it_behaves_like 'truncates text' + end + + describe 'truncates the text with multiple paragraphs' do + let(:markdown) { "Paragraph 1\n\nParagraph 2" } + let(:expected) { 'Paragraph 1...' } + + it_behaves_like 'truncates text' + end + + describe 'truncates the first line of a code block' do + let(:markdown) { "```\nCode block\nwith two lines\n```" } + let(:expected) { "Code block...</span>\n</code>" } + + it_behaves_like 'truncates text' + end + + describe 'preserves code color scheme' do + let(:max_chars) { 150 } + let(:markdown) { "```ruby\ndef test\n 'hello world'\nend\n```" } + let(:expected) do + '<code><span id="LC1" class="line" lang="ruby">' \ + '<span class="k">def</span> <span class="nf">test</span>...</span>' + end + + it_behaves_like 'truncates text' + end + + describe 'truncates a single long line of text' do + let(:max_chars) { 150 } + let(:text) { 'The quick brown fox jumped over the lazy dog twice' } # 50 chars + let(:markdown) { text * 4 } + let(:expected) { (text * 2).sub(/.{3}/, '...') } + + it_behaves_like 'truncates text' + end + + it 'preserves a link href when link text is truncated' do + max_chars = 150 + text = 'The quick brown fox jumped over the lazy dog' # 44 chars + link_url = 'http://example.com/foo/bar/baz' # 30 chars + markdown = "#{text}#{text}#{text} #{link_url}" # 163 chars + expected_link_text = 'http://example...</a>' + + html = convert_markdown(markdown) + doc = filter(html, { truncate_visible_max_chars: max_chars }) + + expect(doc.to_html).to match(link_url) + expect(doc.to_html).to match(expected_link_text) + end + + it 'truncates HTML properly' do + markdown = "@#{user.username}, can you look at this?\nHello world\n" + + html = convert_markdown(markdown) + doc = filter(html, { truncate_visible_max_chars: max_chars }) + + # Make sure we didn't create invalid markup + expect(doc.errors).to be_empty + + # Leading user link + expect(doc.css('a').length).to eq(1) + expect(doc.css('a')[0].attr('href')).to eq urls.user_path(user) + expect(doc.css('a')[0].text).to eq "@#{user.username}" + expect(doc.content).to eq "@#{user.username}, can you look at this?..." + end + + it 'truncates HTML with emoji properly' do + markdown = "foo :wink:\nbar :grinning:" + # actual = first_line_in_markdown(object, attribute, 100, project: project) + + html = convert_markdown(markdown) + doc = filter(html, { truncate_visible_max_chars: max_chars }) + + # Make sure we didn't create invalid markup + # But also account for the 2 errors caused by the unknown `gl-emoji` elements + expect(doc.errors.length).to eq(2) + + expect(doc.css('gl-emoji').length).to eq(2) + expect(doc.css('gl-emoji')[0].attr('data-name')).to eq 'wink' + expect(doc.css('gl-emoji')[1].attr('data-name')).to eq 'grinning' + + expect(doc.content).to eq "foo 😉\nbar 😀" + end + + it 'does not truncate if truncate_visible_max_chars not specified' do + markdown = "@#{user.username}, can you look at this?\nHello world" + + html = convert_markdown(markdown) + doc = filter(html) + + expect(doc.content).to eq markdown + end +end diff --git a/spec/migrations/adjust_task_note_rename_background_migration_values_spec.rb b/spec/migrations/adjust_task_note_rename_background_migration_values_spec.rb new file mode 100644 index 00000000000..422d0655e36 --- /dev/null +++ b/spec/migrations/adjust_task_note_rename_background_migration_values_spec.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe AdjustTaskNoteRenameBackgroundMigrationValues, :migration do + let(:finished_status) { 3 } + let(:failed_status) { described_class::MIGRATION_FAILED_STATUS } + let(:active_status) { described_class::MIGRATION_ACTIVE_STATUS } + + shared_examples 'task note migration with failing batches' do + it 'updates batch sizes and resets failed batches' do + migration = create_background_migration(status: initial_status) + batches = [] + + batches << create_failed_batched_job(migration) + batches << create_failed_batched_job(migration) + + migrate! + + expect(described_class::JOB_CLASS_NAME).to have_scheduled_batched_migration( + table_name: :system_note_metadata, + column_name: :id, + interval: 2.minutes, + batch_size: described_class::NEW_BATCH_SIZE, + max_batch_size: 20_000, + sub_batch_size: described_class::NEW_SUB_BATCH_SIZE + ) + expect(migration.reload.status).to eq(active_status) + + updated_batches = batches.map { |b| b.reload.attributes.slice('attempts', 'sub_batch_size') } + expect(updated_batches).to all(eq("attempts" => 0, "sub_batch_size" => 10)) + end + end + + describe '#up' do + context 'when migration was already finished' do + it 'does not update batch sizes' do + create_background_migration(status: finished_status) + + migrate! + + expect(described_class::JOB_CLASS_NAME).to have_scheduled_batched_migration( + table_name: :system_note_metadata, + column_name: :id, + interval: 2.minutes, + batch_size: described_class::OLD_BATCH_SIZE, + max_batch_size: 20_000, + sub_batch_size: described_class::OLD_SUB_BATCH_SIZE + ) + end + end + + context 'when the migration had failing batches' do + context 'when migration had a failed status' do + it_behaves_like 'task note migration with failing batches' do + let(:initial_status) { failed_status } + end + + it 'updates started_at timestamp' do + migration = create_background_migration(status: failed_status) + now = Time.zone.now + + travel_to now do + migrate! + migration.reload + end + + expect(migration.started_at).to be_like_time(now) + end + end + + context 'when migration had an active status' do + it_behaves_like 'task note migration with failing batches' do + let(:initial_status) { active_status } + end + + it 'does not update started_at timestamp' do + migration = create_background_migration(status: active_status) + original_time = migration.started_at + + migrate! + migration.reload + + expect(migration.started_at).to be_like_time(original_time) + end + end + end + end + + describe '#down' do + it 'reverts to old batch sizes' do + create_background_migration(status: finished_status) + + migrate! + schema_migrate_down! + + expect(described_class::JOB_CLASS_NAME).to have_scheduled_batched_migration( + table_name: :system_note_metadata, + column_name: :id, + interval: 2.minutes, + batch_size: described_class::OLD_BATCH_SIZE, + max_batch_size: 20_000, + sub_batch_size: described_class::OLD_SUB_BATCH_SIZE + ) + end + end + + def create_failed_batched_job(migration) + table(:batched_background_migration_jobs).create!( + batched_background_migration_id: migration.id, + status: described_class::JOB_FAILED_STATUS, + min_value: 1, + max_value: 10, + attempts: 3, + batch_size: described_class::OLD_BATCH_SIZE, + sub_batch_size: described_class::OLD_SUB_BATCH_SIZE + ) + end + + def create_background_migration(status:) + migrations_table = table(:batched_background_migrations) + # make sure we only have on migration with that job class name in the specs + migrations_table.where(job_class_name: described_class::JOB_CLASS_NAME).delete_all + + migrations_table.create!( + job_class_name: described_class::JOB_CLASS_NAME, + status: status, + max_value: 10, + max_batch_size: 20_000, + batch_size: described_class::OLD_BATCH_SIZE, + sub_batch_size: described_class::OLD_SUB_BATCH_SIZE, + interval: 2.minutes, + table_name: :system_note_metadata, + column_name: :id, + total_tuple_count: 100_000, + pause_ms: 100, + gitlab_schema: :gitlab_main, + job_arguments: [], + started_at: 2.days.ago + ) + end +end diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb index 414a201aa34..04df8ecc882 100644 --- a/spec/models/member_spec.rb +++ b/spec/models/member_spec.rb @@ -178,8 +178,8 @@ RSpec.describe Member do end context 'member role is associated' do - let_it_be(:member_role) do - create(:member_role, members: [member]) + let!(:member_role) do + create(:member_role, members: [member], base_access_level: Gitlab::Access::DEVELOPER) end context 'member role matches access level' do @@ -201,7 +201,9 @@ RSpec.describe Member do member.access_level = Gitlab::Access::MAINTAINER expect(member).not_to be_valid - expect(member.errors.full_messages).to include( "Access level cannot be changed since member is associated with a custom role") + expect(member.errors.full_messages).to include( + "Access level cannot be changed since member is associated with a custom role" + ) end end end diff --git a/spec/models/members/member_role_spec.rb b/spec/models/members/member_role_spec.rb index e8993491918..e2691e2e78c 100644 --- a/spec/models/members/member_role_spec.rb +++ b/spec/models/members/member_role_spec.rb @@ -11,7 +11,39 @@ RSpec.describe MemberRole do describe 'validation' do subject { described_class.new } - it { is_expected.to validate_presence_of(:namespace_id) } + it { is_expected.to validate_presence_of(:namespace) } it { is_expected.to validate_presence_of(:base_access_level) } + + context 'for namespace' do + subject { build(:member_role) } + + let_it_be(:root_group) { create(:group) } + + context 'when namespace is a subgroup' do + it 'is invalid' do + subgroup = create(:group, parent: root_group) + subject.namespace = subgroup + + expect(subject).to be_invalid + end + end + + context 'when namespace is a root group' do + it 'is valid' do + subject.namespace = root_group + + expect(subject).to be_valid + end + end + + context 'when namespace is not present' do + it 'is invalid with a different error message' do + subject.namespace = nil + + expect(subject).to be_invalid + expect(subject.errors.full_messages).to eq(["Namespace can't be blank"]) + end + end + end end end diff --git a/spec/policies/project_policy_spec.rb b/spec/policies/project_policy_spec.rb index 847379af91a..49709d47645 100644 --- a/spec/policies/project_policy_spec.rb +++ b/spec/policies/project_policy_spec.rb @@ -2777,6 +2777,50 @@ RSpec.describe ProjectPolicy do end end + describe 'role_enables_download_code' do + using RSpec::Parameterized::TableSyntax + + context 'default roles' do + let(:current_user) { public_send(role) } + + context 'public project' do + let(:project) { public_project } + + where(:role, :allowed) do + :owner | true + :maintainer | true + :developer | true + :reporter | true + :guest | true + + with_them do + it do + expect(subject.can?(:download_code)).to be(allowed) + end + end + end + end + + context 'private project' do + let(:project) { private_project } + + where(:role, :allowed) do + :owner | true + :maintainer | true + :developer | true + :reporter | true + :guest | false + end + + with_them do + it do + expect(subject.can?(:download_code)).to be(allowed) + end + end + end + end + end + private def project_subject(project_type) diff --git a/spec/requests/api/graphql/ci/ci_cd_setting_spec.rb b/spec/requests/api/graphql/ci/ci_cd_setting_spec.rb index c19defa37e8..2dc7b9764fe 100644 --- a/spec/requests/api/graphql/ci/ci_cd_setting_spec.rb +++ b/spec/requests/api/graphql/ci/ci_cd_setting_spec.rb @@ -48,6 +48,8 @@ RSpec.describe 'Getting Ci Cd Setting' do expect(settings_data['mergeTrainsEnabled']).to eql project.ci_cd_settings.merge_trains_enabled? expect(settings_data['keepLatestArtifact']).to eql project.keep_latest_artifacts_available? expect(settings_data['jobTokenScopeEnabled']).to eql project.ci_cd_settings.job_token_scope_enabled? + expect(settings_data['inboundJobTokenScopeEnabled']).to eql( + project.ci_cd_settings.inbound_job_token_scope_enabled?) end end end diff --git a/spec/requests/api/graphql/mutations/ci/project_ci_cd_settings_update_spec.rb b/spec/requests/api/graphql/mutations/ci/project_ci_cd_settings_update_spec.rb index 394d9ff53d1..6cca618726b 100644 --- a/spec/requests/api/graphql/mutations/ci/project_ci_cd_settings_update_spec.rb +++ b/spec/requests/api/graphql/mutations/ci/project_ci_cd_settings_update_spec.rb @@ -6,15 +6,19 @@ RSpec.describe 'ProjectCiCdSettingsUpdate' do include GraphqlHelpers let_it_be(:project) do - create(:project, keep_latest_artifact: true, ci_job_token_scope_enabled: true) - .tap(&:save!) + create(:project, + keep_latest_artifact: true, + ci_job_token_scope_enabled: true, + ci_inbound_job_token_scope_enabled: true + ).tap(&:save!) end let(:variables) do { full_path: project.full_path, keep_latest_artifact: false, - job_token_scope_enabled: false + job_token_scope_enabled: false, + inbound_job_token_scope_enabled: false } end @@ -76,6 +80,43 @@ RSpec.describe 'ProjectCiCdSettingsUpdate' do expect(project.ci_job_token_scope_enabled).to eq(true) end + describe 'inbound_job_token_scope_enabled' do + it 'updates inbound_job_token_scope_enabled' do + post_graphql_mutation(mutation, current_user: user) + + project.reload + + expect(response).to have_gitlab_http_status(:success) + expect(project.ci_inbound_job_token_scope_enabled).to eq(false) + end + + it 'does not update inbound_job_token_scope_enabled if not specified' do + variables.except!(:inbound_job_token_scope_enabled) + + post_graphql_mutation(mutation, current_user: user) + + project.reload + + expect(response).to have_gitlab_http_status(:success) + expect(project.ci_inbound_job_token_scope_enabled).to eq(true) + end + + context 'when ci_inbound_job_token_scope disabled' do + before do + stub_feature_flags(ci_inbound_job_token_scope: false) + end + + it 'does not update inbound_job_token_scope_enabled' do + post_graphql_mutation(mutation, current_user: user) + + project.reload + + expect(response).to have_gitlab_http_status(:success) + expect(project.ci_inbound_job_token_scope_enabled).to eq(true) + end + end + end + context 'when bad arguments are provided' do let(:variables) { { full_path: '', keep_latest_artifact: false } } |