diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-03-15 18:09:07 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-03-15 18:09:07 +0300 |
commit | f5c3f32975addd56fe8659f1c346d0e56f0b23d9 (patch) | |
tree | 9d6594793e656c52341a98d22d882d96d240433f /spec | |
parent | c8b7a349bc50cff1e8ef18204042978476527b0b (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec')
23 files changed, 650 insertions, 102 deletions
diff --git a/spec/experiments/application_experiment_spec.rb b/spec/experiments/application_experiment_spec.rb index 0a46155b56d..2481ee5a806 100644 --- a/spec/experiments/application_experiment_spec.rb +++ b/spec/experiments/application_experiment_spec.rb @@ -58,6 +58,15 @@ RSpec.describe ApplicationExperiment, :experiment do end describe "publishing results" do + it "doesn't track or push data to the client if we shouldn't track", :snowplow do + allow(subject).to receive(:should_track?).and_return(false) + expect(Gon).not_to receive(:push) + + subject.publish(:action) + + expect_no_snowplow_event + end + it "tracks the assignment" do expect(subject).to receive(:track).with(:assignment) diff --git a/spec/features/admin/dashboard_spec.rb b/spec/features/admin/dashboard_spec.rb index c040811ada1..618fae3e46b 100644 --- a/spec/features/admin/dashboard_spec.rb +++ b/spec/features/admin/dashboard_spec.rb @@ -30,7 +30,6 @@ RSpec.describe 'admin visits dashboard' do describe 'Users statistic' do let_it_be(:users_statistics) { create(:users_statistics) } - let_it_be(:users_count_label) { Gitlab.ee? ? 'Billable users 71' : 'Active users 71' } it 'shows correct amounts of users', :aggregate_failures do visit admin_dashboard_stats_path @@ -42,9 +41,16 @@ RSpec.describe 'admin visits dashboard' do expect(page).to have_content('Users with highest role Maintainer 6') expect(page).to have_content('Users with highest role Owner 5') expect(page).to have_content('Bots 2') + + if Gitlab.ee? + expect(page).to have_content('Billable users 69') + else + expect(page).not_to have_content('Billable users 69') + end + expect(page).to have_content('Blocked users 7') expect(page).to have_content('Total users 78') - expect(page).to have_content(users_count_label) + expect(page).to have_content('Active users 71') end end end diff --git a/spec/frontend/issuable/components/csv_import_export_buttons_spec.js b/spec/frontend/issuable/components/csv_import_export_buttons_spec.js index e96409b61b9..e32bf35b13a 100644 --- a/spec/frontend/issuable/components/csv_import_export_buttons_spec.js +++ b/spec/frontend/issuable/components/csv_import_export_buttons_spec.js @@ -94,11 +94,37 @@ describe('CsvImportExportButtons', () => { expect(findImportCsvButton().exists()).toBe(true); }); - it('import button has a tooltip', () => { - const tooltip = getBinding(findImportDropdown().element, 'gl-tooltip'); + describe('when showLabel=false', () => { + beforeEach(() => { + wrapper = createComponent({ showImportButton: true, showLabel: false }); + }); - expect(tooltip).toBeDefined(); - expect(tooltip.value).toBe('Import issues'); + it('does not have a button text', () => { + expect(findImportCsvButton().props('text')).toBe(null); + }); + + it('import button has a tooltip', () => { + const tooltip = getBinding(findImportDropdown().element, 'gl-tooltip'); + + expect(tooltip).toBeDefined(); + expect(tooltip.value).toBe('Import issues'); + }); + }); + + describe('when showLabel=true', () => { + beforeEach(() => { + wrapper = createComponent({ showImportButton: true, showLabel: true }); + }); + + it('displays a button text', () => { + expect(findImportCsvButton().props('text')).toBe('Import issues'); + }); + + it('import button has no tooltip', () => { + const tooltip = getBinding(findImportDropdown().element, 'gl-tooltip'); + + expect(tooltip.value).toBe(null); + }); }); it('renders the import modal', () => { diff --git a/spec/frontend/issuable_show/components/issuable_body_spec.js b/spec/frontend/issuable_show/components/issuable_body_spec.js index bf166bea1e5..6fa298ca3f2 100644 --- a/spec/frontend/issuable_show/components/issuable_body_spec.js +++ b/spec/frontend/issuable_show/components/issuable_body_spec.js @@ -6,11 +6,13 @@ import IssuableBody from '~/issuable_show/components/issuable_body.vue'; import IssuableDescription from '~/issuable_show/components/issuable_description.vue'; import IssuableEditForm from '~/issuable_show/components/issuable_edit_form.vue'; import IssuableTitle from '~/issuable_show/components/issuable_title.vue'; +import TaskList from '~/task_list'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import { mockIssuableShowProps, mockIssuable } from '../mock_data'; jest.mock('~/autosave'); +jest.mock('~/flash'); const issuableBodyProps = { ...mockIssuableShowProps, @@ -80,6 +82,75 @@ describe('IssuableBody', () => { }); }); + describe('watchers', () => { + describe('editFormVisible', () => { + it('calls initTaskList in nextTick', async () => { + jest.spyOn(wrapper.vm, 'initTaskList'); + wrapper.setProps({ + editFormVisible: true, + }); + + await wrapper.vm.$nextTick(); + + wrapper.setProps({ + editFormVisible: false, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.initTaskList).toHaveBeenCalled(); + }); + }); + }); + + describe('mounted', () => { + it('initializes TaskList instance when enabledEdit and enableTaskList props are true', () => { + expect(wrapper.vm.taskList instanceof TaskList).toBe(true); + expect(wrapper.vm.taskList).toMatchObject({ + dataType: 'issue', + fieldName: 'description', + lockVersion: issuableBodyProps.taskListLockVersion, + selector: '.js-detail-page-description', + onSuccess: expect.any(Function), + onError: expect.any(Function), + }); + }); + + it('does not initialize TaskList instance when either enabledEdit or enableTaskList prop is false', () => { + const wrapperNoTaskList = createComponent({ + ...issuableBodyProps, + enableTaskList: false, + }); + + expect(wrapperNoTaskList.vm.taskList).not.toBeDefined(); + + wrapperNoTaskList.destroy(); + }); + }); + + describe('methods', () => { + describe('handleTaskListUpdateSuccess', () => { + it('emits `task-list-update-success` event on component', () => { + const updatedIssuable = { + foo: 'bar', + }; + + wrapper.vm.handleTaskListUpdateSuccess(updatedIssuable); + + expect(wrapper.emitted('task-list-update-success')).toBeTruthy(); + expect(wrapper.emitted('task-list-update-success')[0]).toEqual([updatedIssuable]); + }); + }); + + describe('handleTaskListUpdateFailure', () => { + it('emits `task-list-update-failure` event on component', () => { + wrapper.vm.handleTaskListUpdateFailure(); + + expect(wrapper.emitted('task-list-update-failure')).toBeTruthy(); + }); + }); + }); + describe('template', () => { it('renders issuable-title component', () => { const titleEl = wrapper.find(IssuableTitle); diff --git a/spec/frontend/issuable_show/components/issuable_description_spec.js b/spec/frontend/issuable_show/components/issuable_description_spec.js index 29ecce1002d..1058e5decfd 100644 --- a/spec/frontend/issuable_show/components/issuable_description_spec.js +++ b/spec/frontend/issuable_show/components/issuable_description_spec.js @@ -5,9 +5,14 @@ import IssuableDescription from '~/issuable_show/components/issuable_description import { mockIssuable } from '../mock_data'; -const createComponent = (issuable = mockIssuable) => +const createComponent = ({ + issuable = mockIssuable, + enableTaskList = true, + canEdit = true, + taskListUpdatePath = `${mockIssuable.webUrl}.json`, +} = {}) => shallowMount(IssuableDescription, { - propsData: { issuable }, + propsData: { issuable, enableTaskList, canEdit, taskListUpdatePath }, }); describe('IssuableDescription', () => { @@ -38,4 +43,27 @@ describe('IssuableDescription', () => { }); }); }); + + describe('templates', () => { + it('renders container element with class `js-task-list-container` when canEdit and enableTaskList props are true', () => { + expect(wrapper.classes()).toContain('js-task-list-container'); + }); + + it('renders container element without class `js-task-list-container` when canEdit and enableTaskList props are true', () => { + const wrapperNoTaskList = createComponent({ + enableTaskList: false, + }); + + expect(wrapperNoTaskList.classes()).not.toContain('js-task-list-container'); + + wrapperNoTaskList.destroy(); + }); + + it('renders hidden textarea element when issuable.description is present and enableTaskList prop is true', () => { + const textareaEl = wrapper.find('textarea.gl-display-none.js-task-list-field'); + + expect(textareaEl.exists()).toBe(true); + expect(textareaEl.attributes('data-update-url')).toBe(`${mockIssuable.webUrl}.json`); + }); + }); }); diff --git a/spec/frontend/issuable_show/components/issuable_header_spec.js b/spec/frontend/issuable_show/components/issuable_header_spec.js index 2164caa40a8..b85f2dd1999 100644 --- a/spec/frontend/issuable_show/components/issuable_header_spec.js +++ b/spec/frontend/issuable_show/components/issuable_header_spec.js @@ -119,6 +119,27 @@ describe('IssuableHeader', () => { expect(avatarEl.find(GlAvatarLabeled).find(GlIcon).exists()).toBe(false); }); + it('renders tast status text when `taskCompletionStatus` prop is defined', () => { + let taskStatusEl = wrapper.findByTestId('task-status'); + + expect(taskStatusEl.exists()).toBe(true); + expect(taskStatusEl.text()).toContain('0 of 5 tasks completed'); + + const wrapperSingleTask = createComponent({ + ...issuableHeaderProps, + taskCompletionStatus: { + completedCount: 0, + count: 1, + }, + }); + + taskStatusEl = wrapperSingleTask.findByTestId('task-status'); + + expect(taskStatusEl.text()).toContain('0 of 1 task completed'); + + wrapperSingleTask.destroy(); + }); + it('renders sidebar toggle button', () => { const toggleButtonEl = wrapper.findByTestId('sidebar-toggle'); diff --git a/spec/frontend/issuable_show/components/issuable_show_root_spec.js b/spec/frontend/issuable_show/components/issuable_show_root_spec.js index 3e3778492d2..b4c125f4910 100644 --- a/spec/frontend/issuable_show/components/issuable_show_root_spec.js +++ b/spec/frontend/issuable_show/components/issuable_show_root_spec.js @@ -54,6 +54,7 @@ describe('IssuableShowRoot', () => { editFormVisible, descriptionPreviewPath, descriptionHelpPath, + taskCompletionStatus, } = mockIssuableShowProps; const { blocked, confidential, createdAt, author } = mockIssuable; @@ -72,6 +73,7 @@ describe('IssuableShowRoot', () => { confidential, createdAt, author, + taskCompletionStatus, }); expect(issuableHeader.find('.issuable-status-box').text()).toContain('Open'); expect(issuableHeader.find('.detail-page-header-actions button.js-close').exists()).toBe( @@ -111,6 +113,26 @@ describe('IssuableShowRoot', () => { expect(wrapper.emitted('edit-issuable')).toBeTruthy(); }); + it('component emits `task-list-update-success` event bubbled via issuable-body', () => { + const issuableBody = wrapper.find(IssuableBody); + const eventParam = { + foo: 'bar', + }; + + issuableBody.vm.$emit('task-list-update-success', eventParam); + + expect(wrapper.emitted('task-list-update-success')).toBeTruthy(); + expect(wrapper.emitted('task-list-update-success')[0]).toEqual([eventParam]); + }); + + it('component emits `task-list-update-failure` event bubbled via issuable-body', () => { + const issuableBody = wrapper.find(IssuableBody); + + issuableBody.vm.$emit('task-list-update-failure'); + + expect(wrapper.emitted('task-list-update-failure')).toBeTruthy(); + }); + it('component emits `sidebar-toggle` event bubbled via issuable-sidebar', () => { const issuableSidebar = wrapper.find(IssuableSidebar); diff --git a/spec/frontend/issuable_show/mock_data.js b/spec/frontend/issuable_show/mock_data.js index af854f420bc..9ecff705617 100644 --- a/spec/frontend/issuable_show/mock_data.js +++ b/spec/frontend/issuable_show/mock_data.js @@ -12,6 +12,7 @@ export const mockIssuable = { blocked: false, confidential: false, updatedBy: issuable.author, + type: 'ISSUE', currentUserTodos: { nodes: [ { @@ -26,11 +27,18 @@ export const mockIssuableShowProps = { issuable: mockIssuable, descriptionHelpPath: '/help/user/markdown', descriptionPreviewPath: '/gitlab-org/gitlab-shell/preview_markdown', + taskListUpdatePath: `${mockIssuable.webUrl}.json`, + taskListLockVersion: 1, editFormVisible: false, enableAutocomplete: true, enableAutosave: true, + enableTaskList: true, enableEdit: true, showFieldTitle: false, statusBadgeClass: 'status-box-open', statusIcon: 'issue-open-m', + taskCompletionStatus: { + completedCount: 0, + count: 5, + }, }; diff --git a/spec/frontend/lib/utils/datetime_utility_spec.js b/spec/frontend/lib/utils/datetime_utility_spec.js index cfb88820c7d..2df0cb00f9a 100644 --- a/spec/frontend/lib/utils/datetime_utility_spec.js +++ b/spec/frontend/lib/utils/datetime_utility_spec.js @@ -1136,3 +1136,32 @@ describe('getStartOfDay', () => { }, ); }); + +describe('getStartOfWeek', () => { + beforeEach(() => { + timezoneMock.register('US/Eastern'); + }); + + afterEach(() => { + timezoneMock.unregister(); + }); + + it.each` + inputAsString | options | expectedAsString + ${'2021-01-29T18:08:23.014Z'} | ${undefined} | ${'2021-01-25T05:00:00.000Z'} + ${'2021-01-29T13:08:23.014-05:00'} | ${undefined} | ${'2021-01-25T05:00:00.000Z'} + ${'2021-01-30T03:08:23.014+09:00'} | ${undefined} | ${'2021-01-25T05:00:00.000Z'} + ${'2021-01-28T18:08:23.014-10:00'} | ${undefined} | ${'2021-01-25T05:00:00.000Z'} + ${'2021-01-28T18:08:23.014-10:00'} | ${{}} | ${'2021-01-25T05:00:00.000Z'} + ${'2021-01-28T18:08:23.014-10:00'} | ${{ utc: false }} | ${'2021-01-25T05:00:00.000Z'} + ${'2021-01-28T18:08:23.014-10:00'} | ${{ utc: true }} | ${'2021-01-26T00:00:00.000Z'} + `( + 'when the provided date is $inputAsString and the options parameter is $options, returns $expectedAsString', + ({ inputAsString, options, expectedAsString }) => { + const inputDate = new Date(inputAsString); + const actual = datetimeUtility.getStartOfWeek(inputDate, options); + + expect(actual.toISOString()).toEqual(expectedAsString); + }, + ); +}); diff --git a/spec/frontend/sidebar/components/reference/sidebar_reference_widget_spec.js b/spec/frontend/sidebar/components/reference/sidebar_reference_widget_spec.js new file mode 100644 index 00000000000..1dbb7702a15 --- /dev/null +++ b/spec/frontend/sidebar/components/reference/sidebar_reference_widget_spec.js @@ -0,0 +1,93 @@ +import { GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { IssuableType } from '~/issue_show/constants'; +import SidebarReferenceWidget from '~/sidebar/components/reference/sidebar_reference_widget.vue'; +import issueReferenceQuery from '~/sidebar/queries/issue_reference.query.graphql'; +import mergeRequestReferenceQuery from '~/sidebar/queries/merge_request_reference.query.graphql'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { issueReferenceResponse } from '../../mock_data'; + +describe('Sidebar Reference Widget', () => { + let wrapper; + let fakeApollo; + const referenceText = 'reference'; + + const createComponent = ({ + issuableType, + referenceQuery = issueReferenceQuery, + referenceQueryHandler = jest.fn().mockResolvedValue(issueReferenceResponse(referenceText)), + } = {}) => { + Vue.use(VueApollo); + + fakeApollo = createMockApollo([[referenceQuery, referenceQueryHandler]]); + + wrapper = shallowMount(SidebarReferenceWidget, { + apolloProvider: fakeApollo, + provide: { + fullPath: 'group/project', + iid: '1', + }, + propsData: { + issuableType, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe.each([ + [IssuableType.Issue, issueReferenceQuery], + [IssuableType.MergeRequest, mergeRequestReferenceQuery], + ])('when issuableType is %s', (issuableType, referenceQuery) => { + it('displays the reference text', async () => { + createComponent({ + issuableType, + referenceQuery, + }); + + await waitForPromises(); + + expect(wrapper.text()).toContain(referenceText); + }); + + it('displays loading icon while fetching and hides clipboard icon', async () => { + createComponent({ + issuableType, + referenceQuery, + }); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + expect(wrapper.find(ClipboardButton).exists()).toBe(false); + }); + + it('calls createFlash with correct parameters', async () => { + const mockError = new Error('mayday'); + + createComponent({ + issuableType, + referenceQuery, + referenceQueryHandler: jest.fn().mockRejectedValue(mockError), + }); + + await waitForPromises(); + + const [ + [ + { + message, + error: { networkError }, + }, + ], + ] = wrapper.emitted('fetch-error'); + expect(message).toBe('An error occurred while fetching reference'); + expect(networkError).toEqual(mockError); + }); + }); +}); diff --git a/spec/frontend/sidebar/mock_data.js b/spec/frontend/sidebar/mock_data.js index 6268499b4e8..e751f1239c8 100644 --- a/spec/frontend/sidebar/mock_data.js +++ b/spec/frontend/sidebar/mock_data.js @@ -233,4 +233,16 @@ export const issueConfidentialityResponse = (confidential = false) => ({ }, }); +export const issueReferenceResponse = (reference) => ({ + data: { + workspace: { + __typename: 'Project', + issuable: { + __typename: 'Issue', + id: 'gid://gitlab/Issue/4', + reference, + }, + }, + }, +}); export default mockData; diff --git a/spec/lib/gitlab/ci/config/entry/bridge_spec.rb b/spec/lib/gitlab/ci/config/entry/bridge_spec.rb index b3b7901074a..179578fe0a8 100644 --- a/spec/lib/gitlab/ci/config/entry/bridge_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/bridge_spec.rb @@ -244,6 +244,52 @@ RSpec.describe Gitlab::Ci::Config::Entry::Bridge do end end end + + context 'when bridge config contains parallel' do + let(:config) { { trigger: 'some/project', parallel: parallel_config } } + + context 'when parallel config is a number' do + let(:parallel_config) { 2 } + + describe '#valid?' do + it { is_expected.not_to be_valid } + end + + describe '#errors' do + it 'returns an error message' do + expect(subject.errors) + .to include(/cannot use "parallel: <number>"/) + end + end + end + + context 'when parallel config is a matrix' do + let(:parallel_config) do + { matrix: [{ PROVIDER: 'aws', STACK: %w[monitoring app1] }, + { PROVIDER: 'gcp', STACK: %w[data] }] } + end + + describe '#valid?' do + it { is_expected.to be_valid } + end + + describe '#value' do + it 'is returns a bridge job configuration' do + expect(subject.value).to eq( + name: :my_bridge, + trigger: { project: 'some/project' }, + ignore: false, + stage: 'test', + only: { refs: %w[branches tags] }, + parallel: { matrix: [{ 'PROVIDER' => ['aws'], 'STACK' => %w(monitoring app1) }, + { 'PROVIDER' => ['gcp'], 'STACK' => %w(data) }] }, + variables: {}, + scheduling_type: :stage + ) + end + end + end + end end describe '#manual_action?' do diff --git a/spec/lib/gitlab/ci/config/entry/product/parallel_spec.rb b/spec/lib/gitlab/ci/config/entry/product/parallel_spec.rb index bc09e20d748..937642f07e7 100644 --- a/spec/lib/gitlab/ci/config/entry/product/parallel_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/product/parallel_spec.rb @@ -4,21 +4,23 @@ require 'fast_spec_helper' require_dependency 'active_model' RSpec.describe ::Gitlab::Ci::Config::Entry::Product::Parallel do - subject(:parallel) { described_class.new(config) } + let(:metadata) { {} } - context 'with invalid config' do - shared_examples 'invalid config' do |error_message| - describe '#valid?' do - it { is_expected.not_to be_valid } - end + subject(:parallel) { described_class.new(config, **metadata) } - describe '#errors' do - it 'returns error about invalid type' do - expect(parallel.errors).to match(a_collection_including(error_message)) - end + shared_examples 'invalid config' do |error_message| + describe '#valid?' do + it { is_expected.not_to be_valid } + end + + describe '#errors' do + it 'returns error about invalid type' do + expect(parallel.errors).to match(a_collection_including(error_message)) end end + end + context 'with invalid config' do context 'when it is not a numeric value' do let(:config) { true } @@ -63,6 +65,12 @@ RSpec.describe ::Gitlab::Ci::Config::Entry::Product::Parallel do expect(parallel.value).to match(number: config) end end + + context 'when :numeric is not allowed' do + let(:metadata) { { allowed_strategies: [:matrix] } } + + it_behaves_like 'invalid config', /cannot use "parallel: <number>"/ + end end end @@ -89,6 +97,12 @@ RSpec.describe ::Gitlab::Ci::Config::Entry::Product::Parallel do ]) end end + + context 'when :matrix is not allowed' do + let(:metadata) { { allowed_strategies: [:numeric] } } + + it_behaves_like 'invalid config', /cannot use "parallel: matrix"/ + end end end end diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb index aceb2299cc0..9178707a3d0 100644 --- a/spec/lib/gitlab/database/migration_helpers_spec.rb +++ b/spec/lib/gitlab/database/migration_helpers_spec.rb @@ -180,6 +180,32 @@ RSpec.describe Gitlab::Database::MigrationHelpers do end end + context 'when with_lock_retries re-runs the block' do + it 'only creates constraint for unique definitions' do + expected_sql = <<~SQL + ALTER TABLE "#{table_name}"\nADD CONSTRAINT "check_cda6f69506" CHECK (char_length("name") <= 255) + SQL + + expect(model).to receive(:create_table).twice.and_call_original + + expect(model).to receive(:execute).with(expected_sql).and_raise(ActiveRecord::LockWaitTimeout) + expect(model).to receive(:execute).with(expected_sql).and_call_original + + model.create_table_with_constraints table_name do |t| + t.timestamps_with_timezone + t.integer :some_id, null: false + t.boolean :active, null: false, default: true + t.text :name + + t.text_limit :name, 255 + end + + expect_table_columns_to_match(column_attributes, table_name) + + expect_check_constraint(table_name, 'check_cda6f69506', 'char_length(name) <= 255') + end + end + context 'when constraints are given invalid names' do let(:expected_max_length) { described_class::MAX_IDENTIFIER_NAME_LENGTH } let(:expected_error_message) { "The maximum allowed constraint name is #{expected_max_length} characters" } diff --git a/spec/mailers/emails/service_desk_spec.rb b/spec/mailers/emails/service_desk_spec.rb index 7d04b373be6..cb74194020d 100644 --- a/spec/mailers/emails/service_desk_spec.rb +++ b/spec/mailers/emails/service_desk_spec.rb @@ -13,8 +13,13 @@ RSpec.describe Emails::ServiceDesk do let_it_be(:user) { create(:user) } let_it_be(:project) { create(:project) } let_it_be(:issue) { create(:issue, project: project) } + let_it_be(:email) { 'someone@gitlab.com' } let(:template) { double(content: template_content) } + before_all do + issue.issue_email_participants.create!(email: email) + end + before do stub_const('ServiceEmailClass', Class.new(ApplicationMailer)) @@ -72,6 +77,10 @@ RSpec.describe Emails::ServiceDesk do let(:template_content) { 'custom text' } let(:issue) { create(:issue, project: project)} + before do + issue.issue_email_participants.create!(email: email) + end + context 'when a template is in the repository' do let(:project) { create(:project, :custom_repo, files: { ".gitlab/service_desk_templates/#{template_key}.md" => template_content }) } @@ -151,7 +160,7 @@ RSpec.describe Emails::ServiceDesk do let_it_be(:note) { create(:note_on_issue, noteable: issue, project: project) } let_it_be(:default_text) { note.note } - subject { ServiceEmailClass.service_desk_new_note_email(issue.id, note.id) } + subject { ServiceEmailClass.service_desk_new_note_email(issue.id, note.id, email) } it_behaves_like 'read template from repository', 'new_note' diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index 399f6ff5576..79358d3e40c 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -1218,6 +1218,7 @@ RSpec.describe Notify do context 'for service desk issues' do before do issue.update!(external_author: 'service.desk@example.com') + issue.issue_email_participants.create!(email: 'service.desk@example.com') end def expect_sender(username) @@ -1266,7 +1267,7 @@ RSpec.describe Notify do describe 'new note email' do let_it_be(:first_note) { create(:discussion_note_on_issue, note: 'Hello world') } - subject { described_class.service_desk_new_note_email(issue.id, first_note.id) } + subject { described_class.service_desk_new_note_email(issue.id, first_note.id, 'service.desk@example.com') } it_behaves_like 'an unsubscribeable thread' diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index 965da59a0ed..a3e245f4def 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -1259,11 +1259,22 @@ RSpec.describe Issue do end end + describe '#email_participants_emails' do + let_it_be(:issue) { create(:issue) } + + it 'returns a list of emails' do + participant1 = issue.issue_email_participants.create(email: 'a@gitlab.com') + participant2 = issue.issue_email_participants.create(email: 'b@gitlab.com') + + expect(issue.email_participants_emails).to contain_exactly(participant1.email, participant2.email) + end + end + describe '#email_participants_downcase' do it 'returns a list of emails with all uppercase letters replaced with their lowercase counterparts' do participant = create(:issue_email_participant, email: 'SomEoNe@ExamPLe.com') - expect(participant.issue.email_participants_downcase).to match([participant.email.downcase]) + expect(participant.issue.email_participants_emails_downcase).to match([participant.email.downcase]) end end end diff --git a/spec/services/ci/create_pipeline_service/parallel_spec.rb b/spec/services/ci/create_pipeline_service/parallel_spec.rb new file mode 100644 index 00000000000..5e34a67d376 --- /dev/null +++ b/spec/services/ci/create_pipeline_service/parallel_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe Ci::CreatePipelineService do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:user) { project.owner } + + let(:service) { described_class.new(project, user, { ref: 'master' }) } + let(:pipeline) { service.execute(:push) } + + before do + stub_ci_pipeline_yaml_file(config) + end + + context 'job:parallel' do + context 'numeric' do + let(:config) do + <<-EOY + job: + script: "echo job" + parallel: 3 + EOY + end + + it 'creates the pipeline' do + expect(pipeline).to be_created_successfully + end + + it 'creates 3 jobs' do + expect(pipeline.processables.pluck(:name)).to contain_exactly( + 'job 1/3', 'job 2/3', 'job 3/3' + ) + end + end + + context 'matrix' do + let(:config) do + <<-EOY + job: + script: "echo job" + parallel: + matrix: + - PROVIDER: ovh + STACK: [monitoring, app] + - PROVIDER: [gcp, vultr] + STACK: [data] + EOY + end + + it 'creates the pipeline' do + expect(pipeline).to be_created_successfully + end + + it 'creates 4 builds with the corresponding matrix variables' do + expect(pipeline.processables.pluck(:name)).to contain_exactly( + 'job: [gcp, data]', 'job: [ovh, app]', 'job: [ovh, monitoring]', 'job: [vultr, data]' + ) + + job1 = find_job('job: [gcp, data]') + job2 = find_job('job: [ovh, app]') + job3 = find_job('job: [ovh, monitoring]') + job4 = find_job('job: [vultr, data]') + + expect(job1.scoped_variables.to_hash).to include('PROVIDER' => 'gcp', 'STACK' => 'data') + expect(job2.scoped_variables.to_hash).to include('PROVIDER' => 'ovh', 'STACK' => 'app') + expect(job3.scoped_variables.to_hash).to include('PROVIDER' => 'ovh', 'STACK' => 'monitoring') + expect(job4.scoped_variables.to_hash).to include('PROVIDER' => 'vultr', 'STACK' => 'data') + end + + context 'when a bridge is using parallel:matrix' do + let(:config) do + <<-EOY + job: + stage: test + script: "echo job" + + deploy: + stage: deploy + trigger: + include: child.yml + parallel: + matrix: + - PROVIDER: ovh + STACK: [monitoring, app] + - PROVIDER: [gcp, vultr] + STACK: [data] + EOY + end + + it 'creates the pipeline' do + expect(pipeline).to be_created_successfully + end + + it 'creates 1 build and 4 bridges with the corresponding matrix variables' do + expect(pipeline.processables.pluck(:name)).to contain_exactly( + 'job', 'deploy: [gcp, data]', 'deploy: [ovh, app]', 'deploy: [ovh, monitoring]', 'deploy: [vultr, data]' + ) + + bridge1 = find_job('deploy: [gcp, data]') + bridge2 = find_job('deploy: [ovh, app]') + bridge3 = find_job('deploy: [ovh, monitoring]') + bridge4 = find_job('deploy: [vultr, data]') + + expect(bridge1.scoped_variables.to_hash).to include('PROVIDER' => 'gcp', 'STACK' => 'data') + expect(bridge2.scoped_variables.to_hash).to include('PROVIDER' => 'ovh', 'STACK' => 'app') + expect(bridge3.scoped_variables.to_hash).to include('PROVIDER' => 'ovh', 'STACK' => 'monitoring') + expect(bridge4.scoped_variables.to_hash).to include('PROVIDER' => 'vultr', 'STACK' => 'data') + end + end + end + end + + private + + def find_job(name) + pipeline.processables.find { |job| job.name == name } + end +end diff --git a/spec/services/ci/pipeline_processing/shared_processing_service.rb b/spec/services/ci/pipeline_processing/shared_processing_service.rb index 215f33a42a3..13c924a3089 100644 --- a/spec/services/ci/pipeline_processing/shared_processing_service.rb +++ b/spec/services/ci/pipeline_processing/shared_processing_service.rb @@ -1,21 +1,13 @@ # frozen_string_literal: true RSpec.shared_examples 'Pipeline Processing Service' do - let(:user) { create(:user) } - let(:project) { create(:project) } + let(:project) { create(:project, :repository) } + let(:user) { project.owner } let(:pipeline) do create(:ci_empty_pipeline, ref: 'master', project: project) end - before do - stub_ci_pipeline_to_return_yaml_file - - stub_not_protect_default_branch - - project.add_developer(user) - end - context 'when simple pipeline is defined' do before do create_build('linux', stage_idx: 0) @@ -866,10 +858,74 @@ RSpec.shared_examples 'Pipeline Processing Service' do end end + context 'when a bridge job has parallel:matrix config', :sidekiq_inline do + let(:parent_config) do + <<-EOY + test: + stage: test + script: echo test + + deploy: + stage: deploy + trigger: + include: .child.yml + parallel: + matrix: + - PROVIDER: ovh + STACK: [monitoring, app] + EOY + end + + let(:child_config) do + <<-EOY + test: + stage: test + script: echo test + EOY + end + + let(:pipeline) do + Ci::CreatePipelineService.new(project, user, { ref: 'master' }).execute(:push) + end + + before do + allow_next_instance_of(Repository) do |repository| + allow(repository) + .to receive(:blob_data_at) + .with(an_instance_of(String), '.gitlab-ci.yml') + .and_return(parent_config) + + allow(repository) + .to receive(:blob_data_at) + .with(an_instance_of(String), '.child.yml') + .and_return(child_config) + end + end + + it 'creates pipeline with bridges, then passes the matrix variables to downstream jobs' do + expect(all_builds_names).to contain_exactly('test', 'deploy: [ovh, monitoring]', 'deploy: [ovh, app]') + expect(all_builds_statuses).to contain_exactly('pending', 'created', 'created') + + succeed_pending + + # bridge jobs directly transition to success + expect(all_builds_statuses).to contain_exactly('success', 'success', 'success') + + bridge1 = all_builds.find_by(name: 'deploy: [ovh, monitoring]') + bridge2 = all_builds.find_by(name: 'deploy: [ovh, app]') + + downstream_job1 = bridge1.downstream_pipeline.processables.first + downstream_job2 = bridge2.downstream_pipeline.processables.first + + expect(downstream_job1.scoped_variables.to_hash).to include('PROVIDER' => 'ovh', 'STACK' => 'monitoring') + expect(downstream_job2.scoped_variables.to_hash).to include('PROVIDER' => 'ovh', 'STACK' => 'app') + end + end + private def all_builds - pipeline.builds.order(:stage_idx, :id) + pipeline.processables.order(:stage_idx, :id) end def builds diff --git a/spec/services/ci/pipeline_processing/shared_processing_service_tests_with_yaml.rb b/spec/services/ci/pipeline_processing/shared_processing_service_tests_with_yaml.rb index 2936d6fae4d..a9f9db8c689 100644 --- a/spec/services/ci/pipeline_processing/shared_processing_service_tests_with_yaml.rb +++ b/spec/services/ci/pipeline_processing/shared_processing_service_tests_with_yaml.rb @@ -1,21 +1,19 @@ # frozen_string_literal: true RSpec.shared_context 'Pipeline Processing Service Tests With Yaml' do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:user) { project.owner } + where(:test_file_path) do Dir.glob(Rails.root.join('spec/services/ci/pipeline_processing/test_cases/*.yml')) end with_them do let(:test_file) { YAML.load_file(test_file_path) } - - let(:user) { create(:user) } - let(:project) { create(:project, :repository) } let(:pipeline) { Ci::CreatePipelineService.new(project, user, ref: 'master').execute(:pipeline) } before do stub_ci_pipeline_yaml_file(YAML.dump(test_file['config'])) - stub_not_protect_default_branch - project.add_developer(user) end it 'follows transitions' do diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index 03dc3be194c..f3cd2776ce7 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -315,17 +315,17 @@ RSpec.describe NotificationService, :mailer do describe 'Notes' do context 'issue note' do let_it_be(:project) { create(:project, :private) } - let_it_be(:issue) { create(:issue, project: project, assignees: [assignee]) } + let_it_be_with_reload(:issue) { create(:issue, project: project, assignees: [assignee]) } let_it_be(:mentioned_issue) { create(:issue, assignees: issue.assignees) } let_it_be_with_reload(:author) { create(:user) } let(:note) { create(:note_on_issue, author: author, noteable: issue, project_id: issue.project_id, note: '@mention referenced, @unsubscribed_mentioned and @outsider also') } subject { notification.new_note(note) } - context 'on service desk issue' do + context 'issue_email_participants' do before do allow(Notify).to receive(:service_desk_new_note_email) - .with(Integer, Integer).and_return(mailer) + .with(Integer, Integer, String).and_return(mailer) allow(::Gitlab::IncomingEmail).to receive(:enabled?) { true } allow(::Gitlab::IncomingEmail).to receive(:supports_wildcard?) { true } @@ -336,7 +336,7 @@ RSpec.describe NotificationService, :mailer do def should_email! expect(Notify).to receive(:service_desk_new_note_email) - .with(issue.id, note.id) + .with(issue.id, note.id, issue.external_author) end def should_not_email! @@ -365,33 +365,19 @@ RSpec.describe NotificationService, :mailer do let(:project) { issue.project } let(:note) { create(:note, noteable: issue, project: project) } - context 'a non-service-desk issue' do + context 'do not exist' do it_should_not_email! end - context 'a service-desk issue' do + context 'do exist' do + let!(:issue_email_participant) { issue.issue_email_participants.create!(email: 'service.desk@example.com') } + before do issue.update!(external_author: 'service.desk@example.com') project.update!(service_desk_enabled: true) end it_should_email! - - context 'where the project has disabled the feature' do - before do - project.update!(service_desk_enabled: false) - end - - it_should_not_email! - end - - context 'when the support bot has unsubscribed' do - before do - issue.unsubscribe(User.support_bot, project) - end - - it_should_not_email! - end end end diff --git a/spec/support/gitlab_experiment.rb b/spec/support/gitlab_experiment.rb index 4015db329fc..bd0c88f8049 100644 --- a/spec/support/gitlab_experiment.rb +++ b/spec/support/gitlab_experiment.rb @@ -6,6 +6,7 @@ require_relative 'stub_snowplow' # This is a temporary fix until we have a larger discussion around the # challenges raised in https://gitlab.com/gitlab-org/gitlab/-/issues/300104 +require Rails.root.join('app', 'experiments', 'application_experiment') class ApplicationExperiment # rubocop:disable Gitlab/NamespacedClass def initialize(...) super(...) diff --git a/spec/views/projects/issues/import_csv/_button.html.haml_spec.rb b/spec/views/projects/issues/import_csv/_button.html.haml_spec.rb deleted file mode 100644 index 8bc0a00d71c..00000000000 --- a/spec/views/projects/issues/import_csv/_button.html.haml_spec.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe 'projects/issues/import_csv/_button' do - include Devise::Test::ControllerHelpers - - context 'when the user does not have edit permissions' do - before do - render - end - - it 'shows a dropdown button to import CSV' do - expect(rendered).to have_text('Import CSV') - end - - it 'does not show a button to import from Jira' do - expect(rendered).not_to have_text('Import from Jira') - end - end - - context 'when the user has edit permissions' do - let(:project) { create(:project) } - let(:current_user) { create(:user, maintainer_projects: [project]) } - - before do - allow(view).to receive(:project_import_jira_path).and_return('import/jira') - allow(view).to receive(:current_user).and_return(current_user) - - assign(:project, project) - - render - end - - it 'shows a dropdown button to import CSV' do - expect(rendered).to have_text('Import CSV') - end - - it 'shows a button to import from Jira' do - expect(rendered).to have_text('Import from Jira') - end - end -end |