My paragraph
'; + buildWrapper(); + tiptapEditor.commands.insertContent(content); expect(onDocUpdateListener).toHaveBeenCalledWith( @@ -58,10 +80,27 @@ describe('content_editor/components/editor_state_observer', () => { }); }); + it.each` + event | listener + ${ALERT_EVENT} | ${() => onAlertListener} + ${LOADING_CONTENT_EVENT} | ${() => onLoadingContentListener} + ${LOADING_SUCCESS_EVENT} | ${() => onLoadingSuccessListener} + ${LOADING_ERROR_EVENT} | ${() => onLoadingErrorListener} + `('listens to $event event in the eventBus object', ({ event, listener }) => { + const args = {}; + + buildWrapper(); + + eventHub.$emit(event, args); + expect(listener()).toHaveBeenCalledWith(args); + }); + describe('when component is destroyed', () => { it('removes onTiptapDocUpdate and onTiptapSelectionUpdate hooks', () => { jest.spyOn(tiptapEditor, 'off'); + buildWrapper(); + wrapper.destroy(); each(tiptapToComponentMap, (_, tiptapEvent) => { @@ -71,5 +110,25 @@ describe('content_editor/components/editor_state_observer', () => { ); }); }); + + it.each` + event + ${ALERT_EVENT} + ${LOADING_CONTENT_EVENT} + ${LOADING_SUCCESS_EVENT} + ${LOADING_ERROR_EVENT} + `('removes $event event hook from eventHub', ({ event }) => { + jest.spyOn(eventHub, '$off'); + jest.spyOn(eventHub, '$on'); + + buildWrapper(); + + wrapper.destroy(); + + expect(eventHub.$off).toHaveBeenCalledWith( + event, + eventHub.$on.mock.calls.find(([eventName]) => eventName === event)[1], + ); + }); }); }); diff --git a/spec/frontend/content_editor/components/toolbar_button_spec.js b/spec/frontend/content_editor/components/toolbar_button_spec.js index 60263c46bdd..ce50482302d 100644 --- a/spec/frontend/content_editor/components/toolbar_button_spec.js +++ b/spec/frontend/content_editor/components/toolbar_button_spec.js @@ -2,6 +2,7 @@ import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue'; import ToolbarButton from '~/content_editor/components/toolbar_button.vue'; +import eventHubFactory from '~/helpers/event_hub_factory'; import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils'; describe('content_editor/components/toolbar_button', () => { @@ -25,6 +26,7 @@ describe('content_editor/components/toolbar_button', () => { }, provide: { tiptapEditor, + eventHub: eventHubFactory(), }, propsData: { contentType: CONTENT_TYPE, diff --git a/spec/frontend/content_editor/components/toolbar_link_button_spec.js b/spec/frontend/content_editor/components/toolbar_link_button_spec.js index 0cf488260bd..fc26a9da471 100644 --- a/spec/frontend/content_editor/components/toolbar_link_button_spec.js +++ b/spec/frontend/content_editor/components/toolbar_link_button_spec.js @@ -1,6 +1,7 @@ import { GlDropdown, GlButton, GlFormInputGroup } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import ToolbarLinkButton from '~/content_editor/components/toolbar_link_button.vue'; +import eventHubFactory from '~/helpers/event_hub_factory'; import Link from '~/content_editor/extensions/link'; import { hasSelection } from '~/content_editor/services/utils'; import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils'; @@ -15,6 +16,7 @@ describe('content_editor/components/toolbar_link_button', () => { wrapper = mountExtended(ToolbarLinkButton, { provide: { tiptapEditor: editor, + eventHub: eventHubFactory(), }, }); }; diff --git a/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js b/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js index 65c1c8c8310..608be1bd693 100644 --- a/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js +++ b/spec/frontend/content_editor/components/toolbar_text_style_dropdown_spec.js @@ -4,6 +4,7 @@ import EditorStateObserver from '~/content_editor/components/editor_state_observ import ToolbarTextStyleDropdown from '~/content_editor/components/toolbar_text_style_dropdown.vue'; import { TEXT_STYLE_DROPDOWN_ITEMS } from '~/content_editor/constants'; import Heading from '~/content_editor/extensions/heading'; +import eventHubFactory from '~/helpers/event_hub_factory'; import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils'; describe('content_editor/components/toolbar_text_style_dropdown', () => { @@ -27,6 +28,7 @@ describe('content_editor/components/toolbar_text_style_dropdown', () => { }, provide: { tiptapEditor, + eventHub: eventHubFactory(), }, propsData: { ...propsData, diff --git a/spec/frontend/content_editor/extensions/attachment_spec.js b/spec/frontend/content_editor/extensions/attachment_spec.js index d2d2cd98a78..e095a3d0b6a 100644 --- a/spec/frontend/content_editor/extensions/attachment_spec.js +++ b/spec/frontend/content_editor/extensions/attachment_spec.js @@ -5,6 +5,7 @@ import Image from '~/content_editor/extensions/image'; import Link from '~/content_editor/extensions/link'; import Loading from '~/content_editor/extensions/loading'; import httpStatus from '~/lib/utils/http_status'; +import eventHubFactory from '~/helpers/event_hub_factory'; import { createTestEditor, createDocBuilder } from '../test_utils'; const PROJECT_WIKI_ATTACHMENT_IMAGE_HTML = `@@ -25,6 +26,7 @@ describe('content_editor/extensions/attachment', () => { let link; let renderMarkdown; let mock; + let eventHub; const uploadsPath = '/uploads/'; const imageFile = new File(['foo'], 'test-file.png', { type: 'image/png' }); @@ -50,9 +52,15 @@ describe('content_editor/extensions/attachment', () => { beforeEach(() => { renderMarkdown = jest.fn(); + eventHub = eventHubFactory(); tiptapEditor = createTestEditor({ - extensions: [Loading, Link, Image, Attachment.configure({ renderMarkdown, uploadsPath })], + extensions: [ + Loading, + Link, + Image, + Attachment.configure({ renderMarkdown, uploadsPath, eventHub }), + ], }); ({ @@ -160,7 +168,7 @@ describe('content_editor/extensions/attachment', () => { it('emits an alert event that includes an error message', (done) => { tiptapEditor.commands.uploadAttachment({ file: imageFile }); - tiptapEditor.on('alert', ({ message }) => { + eventHub.$on('alert', ({ message }) => { expect(message).toBe('An error occurred while uploading the image. Please try again.'); done(); }); @@ -236,7 +244,7 @@ describe('content_editor/extensions/attachment', () => { it('emits an alert event that includes an error message', (done) => { tiptapEditor.commands.uploadAttachment({ file: attachmentFile }); - tiptapEditor.on('alert', ({ message }) => { + eventHub.$on('alert', ({ message }) => { expect(message).toBe('An error occurred while uploading the file. Please try again.'); done(); }); diff --git a/spec/frontend/content_editor/services/content_editor_spec.js b/spec/frontend/content_editor/services/content_editor_spec.js index e48687f1548..ac4f71a80cb 100644 --- a/spec/frontend/content_editor/services/content_editor_spec.js +++ b/spec/frontend/content_editor/services/content_editor_spec.js @@ -4,19 +4,21 @@ import { LOADING_ERROR_EVENT, } from '~/content_editor/constants'; import { ContentEditor } from '~/content_editor/services/content_editor'; - +import eventHubFactory from '~/helpers/event_hub_factory'; import { createTestEditor } from '../test_utils'; describe('content_editor/services/content_editor', () => { let contentEditor; let serializer; + let eventHub; beforeEach(() => { const tiptapEditor = createTestEditor(); jest.spyOn(tiptapEditor, 'destroy'); serializer = { deserialize: jest.fn() }; - contentEditor = new ContentEditor({ tiptapEditor, serializer }); + eventHub = eventHubFactory(); + contentEditor = new ContentEditor({ tiptapEditor, serializer, eventHub }); }); describe('.dispose', () => { @@ -34,13 +36,13 @@ describe('content_editor/services/content_editor', () => { serializer.deserialize.mockResolvedValueOnce(''); }); - it('emits loadingContent and loadingSuccess event', () => { + it('emits loadingContent and loadingSuccess event in the eventHub', () => { let loadingContentEmitted = false; - contentEditor.on(LOADING_CONTENT_EVENT, () => { + eventHub.$on(LOADING_CONTENT_EVENT, () => { loadingContentEmitted = true; }); - contentEditor.on(LOADING_SUCCESS_EVENT, () => { + eventHub.$on(LOADING_SUCCESS_EVENT, () => { expect(loadingContentEmitted).toBe(true); }); @@ -56,7 +58,7 @@ describe('content_editor/services/content_editor', () => { }); it('emits loadingError event', async () => { - contentEditor.on(LOADING_ERROR_EVENT, (e) => { + eventHub.$on(LOADING_ERROR_EVENT, (e) => { expect(e).toBe('error'); }); diff --git a/spec/frontend/jobs/components/job_log_controllers_spec.js b/spec/frontend/jobs/components/job_log_controllers_spec.js index 226322a2951..cd3ee734466 100644 --- a/spec/frontend/jobs/components/job_log_controllers_spec.js +++ b/spec/frontend/jobs/components/job_log_controllers_spec.js @@ -8,7 +8,6 @@ describe('Job log controllers', () => { afterEach(() => { if (wrapper?.destroy) { wrapper.destroy(); - wrapper = null; } }); @@ -34,7 +33,6 @@ describe('Job log controllers', () => { const findTruncatedInfo = () => wrapper.find('[data-testid="log-truncated-info"]'); const findRawLink = () => wrapper.find('[data-testid="raw-link"]'); const findRawLinkController = () => wrapper.find('[data-testid="job-raw-link-controller"]'); - const findEraseLink = () => wrapper.find('[data-testid="job-log-erase-link"]'); const findScrollTop = () => wrapper.find('[data-testid="job-controller-scroll-top"]'); const findScrollBottom = () => wrapper.find('[data-testid="job-controller-scroll-bottom"]'); @@ -76,28 +74,6 @@ describe('Job log controllers', () => { expect(findRawLinkController().exists()).toBe(false); }); }); - - describe('when is erasable', () => { - beforeEach(() => { - createWrapper(); - }); - - it('renders erase job link', () => { - expect(findEraseLink().exists()).toBe(true); - }); - }); - - describe('when it is not erasable', () => { - beforeEach(() => { - createWrapper({ - erasePath: null, - }); - }); - - it('does not render erase button', () => { - expect(findEraseLink().exists()).toBe(false); - }); - }); }); describe('scroll buttons', () => { diff --git a/spec/frontend/jobs/components/job_sidebar_retry_button_spec.js b/spec/frontend/jobs/components/job_sidebar_retry_button_spec.js index 6914b8d4fa1..ad72b9be261 100644 --- a/spec/frontend/jobs/components/job_sidebar_retry_button_spec.js +++ b/spec/frontend/jobs/components/job_sidebar_retry_button_spec.js @@ -1,5 +1,4 @@ -import { GlButton, GlLink } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import JobsSidebarRetryButton from '~/jobs/components/job_sidebar_retry_button.vue'; import createStore from '~/jobs/store'; import job from '../mock_data'; @@ -9,12 +8,12 @@ describe('Job Sidebar Retry Button', () => { let wrapper; const forwardDeploymentFailure = 'forward_deployment_failure'; - const findRetryButton = () => wrapper.find(GlButton); - const findRetryLink = () => wrapper.find(GlLink); + const findRetryButton = () => wrapper.findByTestId('retry-job-button'); + const findRetryLink = () => wrapper.findByTestId('retry-job-link'); const createWrapper = ({ props = {} } = {}) => { store = createStore(); - wrapper = shallowMount(JobsSidebarRetryButton, { + wrapper = shallowMountExtended(JobsSidebarRetryButton, { propsData: { href: job.retry_path, modalId: 'modal-id', @@ -27,7 +26,6 @@ describe('Job Sidebar Retry Button', () => { afterEach(() => { if (wrapper) { wrapper.destroy(); - wrapper = null; } }); @@ -44,7 +42,6 @@ describe('Job Sidebar Retry Button', () => { expect(findRetryButton().exists()).toBe(buttonExists); expect(findRetryLink().exists()).toBe(linkExists); - expect(wrapper.text()).toMatch('Retry'); }, ); @@ -55,6 +52,7 @@ describe('Job Sidebar Retry Button', () => { expect(findRetryButton().attributes()).toMatchObject({ category: 'primary', variant: 'confirm', + icon: 'retry', }); }); }); @@ -64,6 +62,7 @@ describe('Job Sidebar Retry Button', () => { expect(findRetryLink().attributes()).toMatchObject({ 'data-method': 'post', href: job.retry_path, + icon: 'retry', }); }); }); diff --git a/spec/frontend/jobs/components/sidebar_spec.js b/spec/frontend/jobs/components/sidebar_spec.js index 6e327725627..39c71986ce4 100644 --- a/spec/frontend/jobs/components/sidebar_spec.js +++ b/spec/frontend/jobs/components/sidebar_spec.js @@ -21,25 +21,54 @@ describe('Sidebar details block', () => { const findNewIssueButton = () => wrapper.findByTestId('job-new-issue'); const findRetryButton = () => wrapper.find(JobRetryButton); const findTerminalLink = () => wrapper.findByTestId('terminal-link'); + const findEraseLink = () => wrapper.findByTestId('job-log-erase-link'); - const createWrapper = ({ props = {} } = {}) => { + const createWrapper = (props) => { store = createStore(); store.state.job = job; wrapper = extendedWrapper( shallowMount(Sidebar, { - ...props, + propsData: { + ...props, + }, + store, }), ); }; afterEach(() => { - if (wrapper) { - wrapper.destroy(); - wrapper = null; - } + wrapper.destroy(); + }); + + describe('when job log is erasable', () => { + const path = '/root/ci-project/-/jobs/1447/erase'; + + beforeEach(() => { + createWrapper({ + erasePath: path, + }); + }); + + it('renders erase job link', () => { + expect(findEraseLink().exists()).toBe(true); + }); + + it('erase job link has correct path', () => { + expect(findEraseLink().attributes('href')).toBe(path); + }); + }); + + describe('when job log is not erasable', () => { + beforeEach(() => { + createWrapper(); + }); + + it('does not render erase button', () => { + expect(findEraseLink().exists()).toBe(false); + }); }); describe('when there is no retry path retry', () => { @@ -86,7 +115,7 @@ describe('Sidebar details block', () => { }); it('should render link to cancel job', () => { - expect(findCancelButton().text()).toMatch('Cancel'); + expect(findCancelButton().props('icon')).toBe('cancel'); expect(findCancelButton().attributes('href')).toBe(job.cancel_path); }); }); diff --git a/spec/frontend/pipeline_wizard/components/widgets/list_spec.js b/spec/frontend/pipeline_wizard/components/widgets/list_spec.js new file mode 100644 index 00000000000..796356634bc --- /dev/null +++ b/spec/frontend/pipeline_wizard/components/widgets/list_spec.js @@ -0,0 +1,212 @@ +import { GlFormGroup, GlFormInputGroup } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import ListWidget from '~/pipeline_wizard/components/widgets/list.vue'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +describe('Pipeline Wizard - List Widget', () => { + const defaultProps = { + label: 'This label', + description: 'some description', + placeholder: 'some placeholder', + pattern: '^[a-z]+$', + invalidFeedback: 'some feedback', + }; + let wrapper; + let addStepBtn; + + const findGlFormGroup = () => wrapper.findComponent(GlFormGroup); + const findGlFormGroupInvalidFeedback = () => findGlFormGroup().find('.invalid-feedback').text(); + const findFirstGlFormInputGroup = () => wrapper.findComponent(GlFormInputGroup); + const findAllGlFormInputGroups = () => wrapper.findAllComponents(GlFormInputGroup); + const findGlFormInputGroupByIndex = (index) => findAllGlFormInputGroups().at(index); + const setValueOnInputField = (value, atIndex = 0) => { + return findGlFormInputGroupByIndex(atIndex).vm.$emit('input', value); + }; + const findAddStepButton = () => wrapper.findByTestId('add-step-button'); + const addStep = () => findAddStepButton().vm.$emit('click'); + + const createComponent = (props = {}, mountFn = shallowMountExtended) => { + wrapper = mountFn(ListWidget, { + propsData: { + ...defaultProps, + ...props, + }, + }); + addStepBtn = findAddStepButton(); + }; + + describe('component setup and interface', () => { + afterEach(() => { + wrapper.destroy(); + }); + + it('prints the label inside the legend', () => { + createComponent(); + + expect(findGlFormGroup().attributes('label')).toBe(defaultProps.label); + }); + + it('prints the description inside the legend', () => { + createComponent(); + + expect(findGlFormGroup().attributes('labeldescription')).toBe(defaultProps.description); + }); + + it('sets the input field type attribute to "text"', async () => { + createComponent(); + + expect(findFirstGlFormInputGroup().attributes('type')).toBe('text'); + }); + + it('passes the placeholder to the first input field', () => { + createComponent(); + + expect(findFirstGlFormInputGroup().attributes('placeholder')).toBe(defaultProps.placeholder); + }); + + it('shows a delete button on all fields if there are more than one', async () => { + createComponent({}, mountExtended); + + await addStep(); + await addStep(); + const inputGroups = findAllGlFormInputGroups().wrappers; + + expect(inputGroups.length).toBe(3); + inputGroups.forEach((inputGroup) => { + const button = inputGroup.find('[data-testid="remove-step-button"]'); + expect(button.find('[data-testid="remove-icon"]').exists()).toBe(true); + expect(button.attributes('aria-label')).toBe('remove step'); + }); + }); + + it('null values do not cause an input event', async () => { + createComponent(); + + await addStep(); + + expect(wrapper.emitted('input')).toBe(undefined); + }); + + it('hides the delete button if there is only one', () => { + createComponent({}, mountExtended); + + const inputGroups = findAllGlFormInputGroups().wrappers; + + expect(inputGroups.length).toBe(1); + expect(wrapper.findByTestId('remove-step-button').exists()).toBe(false); + }); + + it('shows an "add step" button', () => { + createComponent(); + + expect(addStepBtn.attributes('icon')).toBe('plus'); + expect(addStepBtn.text()).toBe('add another step'); + }); + + it('the "add step" button increases the number of input fields', async () => { + createComponent(); + + expect(findAllGlFormInputGroups().wrappers.length).toBe(1); + await addStep(); + expect(findAllGlFormInputGroups().wrappers.length).toBe(2); + }); + + it('does not pass the placeholder on subsequent input fields', async () => { + createComponent(); + + await addStep(); + await addStep(); + const nullOrUndefined = [null, undefined]; + expect(nullOrUndefined).toContain(findAllGlFormInputGroups().at(1).attributes('placeholder')); + expect(nullOrUndefined).toContain(findAllGlFormInputGroups().at(2).attributes('placeholder')); + }); + + it('emits an update event on input', async () => { + createComponent(); + + const localValue = 'somevalue'; + await setValueOnInputField(localValue); + await nextTick(); + + expect(wrapper.emitted('input')).toEqual([[[localValue]]]); + }); + + it('only emits non-null values', async () => { + createComponent(); + + await addStep(); + await addStep(); + await setValueOnInputField('abc', 1); + await nextTick(); + + const events = wrapper.emitted('input'); + + expect(events.length).toBe(1); + expect(events[0]).toEqual([['abc']]); + }); + }); + + describe('form validation', () => { + afterEach(() => { + wrapper.destroy(); + }); + + it('does not show validation state when untouched', async () => { + createComponent({}, mountExtended); + expect(findGlFormGroup().classes()).not.toContain('is-valid'); + expect(findGlFormGroup().classes()).not.toContain('is-invalid'); + }); + + it('shows invalid state on blur', async () => { + createComponent({}, mountExtended); + expect(findGlFormGroup().classes()).not.toContain('is-invalid'); + const input = findFirstGlFormInputGroup().find('input'); + await input.setValue('invalid99'); + await input.trigger('blur'); + expect(input.classes()).toContain('is-invalid'); + expect(findGlFormGroup().classes()).toContain('is-invalid'); + }); + + it('shows invalid state when toggling `validate` prop', async () => { + createComponent({ required: true, validate: false }, mountExtended); + await setValueOnInputField(null); + expect(findGlFormGroup().classes()).not.toContain('is-invalid'); + await wrapper.setProps({ validate: true }); + expect(findGlFormGroup().classes()).toContain('is-invalid'); + }); + + it.each` + scenario | required | values | inputFieldClasses | inputGroupClass | feedback + ${'shows invalid if all inputs are empty'} | ${true} | ${[null, null]} | ${['is-invalid', null]} | ${'is-invalid'} | ${'At least one entry is required'} + ${'is valid if at least one field has a valid entry'} | ${true} | ${[null, 'abc']} | ${[null, 'is-valid']} | ${'is-valid'} | ${expect.anything()} + ${'is invalid if one field has an invalid entry'} | ${true} | ${['abc', '99']} | ${['is-valid', 'is-invalid']} | ${'is-invalid'} | ${defaultProps.invalidFeedback} + ${'is not invalid if its not required but all values are null'} | ${false} | ${[null, null]} | ${[null, null]} | ${'is-valid'} | ${expect.anything()} + ${'is invalid if pattern does not match even if its not required'} | ${false} | ${['99', null]} | ${['is-invalid', null]} | ${'is-invalid'} | ${defaultProps.invalidFeedback} + `('$scenario', async ({ required, values, inputFieldClasses, inputGroupClass, feedback }) => { + createComponent({ required, validate: true }, mountExtended); + + await Promise.all( + values.map(async (value, i) => { + if (i > 0) { + await addStep(); + } + await setValueOnInputField(value, i); + }), + ); + await nextTick(); + + inputFieldClasses.forEach((expected, i) => { + const inputWrapper = findGlFormInputGroupByIndex(i).find('input'); + if (expected === null) { + expect(inputWrapper.classes()).not.toContain('is-valid'); + expect(inputWrapper.classes()).not.toContain('is-invalid'); + } else { + expect(inputWrapper.classes()).toContain(expected); + } + }); + + expect(findGlFormGroup().classes()).toContain(inputGroupClass); + expect(findGlFormGroupInvalidFeedback()).toEqual(feedback); + }); + }); +}); diff --git a/spec/lib/gitlab/email/receiver_spec.rb b/spec/lib/gitlab/email/receiver_spec.rb index b1a04f0592a..9040731d8fd 100644 --- a/spec/lib/gitlab/email/receiver_spec.rb +++ b/spec/lib/gitlab/email/receiver_spec.rb @@ -32,12 +32,21 @@ RSpec.describe Gitlab::Email::Receiver do metadata = receiver.mail_metadata - expect(metadata.keys).to match_array(%i(mail_uid from_address to_address mail_key references delivered_to envelope_to x_envelope_to meta)) + expect(metadata.keys).to match_array(%i(mail_uid from_address to_address mail_key references delivered_to envelope_to x_envelope_to meta received_recipients)) expect(metadata[:meta]).to include(client_id: 'email/jake@example.com', project: project.full_path) expect(metadata[meta_key]).to eq(meta_value) end end + shared_examples 'failed receive' do + it 'adds metric event' do + expect(::Gitlab::Metrics::BackgroundTransaction).to receive(:current).and_return(metric_transaction) + expect(metric_transaction).to receive(:add_event).with('email_receiver_error', { error: expected_error.name }) + + expect { receiver.execute }.to raise_error(expected_error) + end + end + context 'when the email contains a valid email address in a header' do before do stub_incoming_email_setting(enabled: true, address: "incoming+%{key}@appmail.example.com") @@ -74,14 +83,25 @@ RSpec.describe Gitlab::Email::Receiver do it_behaves_like 'successful receive' end - end - shared_examples 'failed receive' do - it 'adds metric event' do - expect(::Gitlab::Metrics::BackgroundTransaction).to receive(:current).and_return(metric_transaction) - expect(metric_transaction).to receive(:add_event).with('email_receiver_error', { error: expected_error.name }) + context 'when all other headers are missing' do + let(:email_raw) { fixture_file('emails/missing_delivered_to_header.eml') } + let(:meta_key) { :received_recipients } + let(:meta_value) { ['incoming+gitlabhq/gitlabhq+auth_token@appmail.example.com', 'incoming+gitlabhq/gitlabhq@example.com'] } - expect { receiver.execute }.to raise_error(expected_error) + context 'when use_received_header_for_incoming_emails is enabled' do + it_behaves_like 'successful receive' + end + + context 'when use_received_header_for_incoming_emails is disabled' do + let(:expected_error) { Gitlab::Email::UnknownIncomingEmail } + + before do + stub_feature_flags(use_received_header_for_incoming_emails: false) + end + + it_behaves_like 'failed receive' + end end end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 565a794b902..72da2c22f29 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -385,23 +385,43 @@ RSpec.describe Group do end end - before do - subject - reload_models(old_parent, new_parent, group) - end - context 'within the same hierarchy' do let!(:root) { create(:group).reload } let!(:old_parent) { create(:group, parent: root) } let!(:new_parent) { create(:group, parent: root) } - it 'updates traversal_ids' do - expect(group.traversal_ids).to eq [root.id, new_parent.id, group.id] + context 'with FOR UPDATE lock' do + before do + stub_feature_flags(for_no_key_update_lock: false) + subject + reload_models(old_parent, new_parent, group) + end + + it 'updates traversal_ids' do + expect(group.traversal_ids).to eq [root.id, new_parent.id, group.id] + end + + it_behaves_like 'hierarchy with traversal_ids' + it_behaves_like 'locked row', 'FOR UPDATE' do + let(:row) { root } + end end - it_behaves_like 'hierarchy with traversal_ids' - it_behaves_like 'locked row' do - let(:row) { root } + context 'with FOR NO KEY UPDATE lock' do + before do + stub_feature_flags(for_no_key_update_lock: true) + subject + reload_models(old_parent, new_parent, group) + end + + it 'updates traversal_ids' do + expect(group.traversal_ids).to eq [root.id, new_parent.id, group.id] + end + + it_behaves_like 'hierarchy with traversal_ids' + it_behaves_like 'locked row', 'FOR NO KEY UPDATE' do + let(:row) { root } + end end end @@ -410,6 +430,11 @@ RSpec.describe Group do let!(:new_parent) { create(:group) } let!(:group) { create(:group, parent: old_parent) } + before do + subject + reload_models(old_parent, new_parent, group) + end + it 'updates traversal_ids' do expect(group.traversal_ids).to eq [new_parent.id, group.id] end @@ -435,6 +460,11 @@ RSpec.describe Group do let!(:old_parent) { nil } let!(:new_parent) { create(:group) } + before do + subject + reload_models(old_parent, new_parent, group) + end + it 'updates traversal_ids' do expect(group.traversal_ids).to eq [new_parent.id, group.id] end @@ -452,6 +482,11 @@ RSpec.describe Group do let!(:old_parent) { create(:group) } let!(:new_parent) { nil } + before do + subject + reload_models(old_parent, new_parent, group) + end + it 'updates traversal_ids' do expect(group.traversal_ids).to eq [group.id] end diff --git a/spec/models/namespace/traversal_hierarchy_spec.rb b/spec/models/namespace/traversal_hierarchy_spec.rb index 51932ab943c..eeea071d326 100644 --- a/spec/models/namespace/traversal_hierarchy_spec.rb +++ b/spec/models/namespace/traversal_hierarchy_spec.rb @@ -68,11 +68,24 @@ RSpec.describe Namespace::TraversalHierarchy, type: :model do end end - it_behaves_like 'locked row' do + it_behaves_like 'locked row', 'FOR UPDATE' do let(:recorded_queries) { ActiveRecord::QueryRecorder.new } let(:row) { root } before do + stub_feature_flags(for_no_key_update_lock: false) + + recorded_queries.record { subject } + end + end + + it_behaves_like 'locked row', 'FOR NO KEY UPDATE' do + let(:recorded_queries) { ActiveRecord::QueryRecorder.new } + let(:row) { root } + + before do + stub_feature_flags(for_no_key_update_lock: true) + recorded_queries.record { subject } end end diff --git a/spec/requests/api/error_tracking/collector_spec.rb b/spec/requests/api/error_tracking/collector_spec.rb index 573da862b57..771bab20b75 100644 --- a/spec/requests/api/error_tracking/collector_spec.rb +++ b/spec/requests/api/error_tracking/collector_spec.rb @@ -171,6 +171,12 @@ RSpec.describe API::ErrorTracking::Collector do it_behaves_like 'successful request' end + context 'when JSON key transaction is empty string' do + let_it_be(:raw_event) { fixture_file('error_tracking/php_empty_transaction.json') } + + it_behaves_like 'successful request' + end + context 'sentry_key as param and empty headers' do let(:url) { "/error_tracking/collector/api/#{project.id}/store?sentry_key=#{sentry_key}" } let(:headers) { {} } diff --git a/spec/services/error_tracking/collect_error_service_spec.rb b/spec/services/error_tracking/collect_error_service_spec.rb index 2b16612dac3..faca3c12a48 100644 --- a/spec/services/error_tracking/collect_error_service_spec.rb +++ b/spec/services/error_tracking/collect_error_service_spec.rb @@ -51,25 +51,30 @@ RSpec.describe ErrorTracking::CollectErrorService do end end - context 'unusual payload' do + context 'with unusual payload' do let(:modified_event) { parsed_event } + let(:event) { described_class.new(project, nil, event: modified_event).execute } - context 'missing transaction' do + context 'when transaction is missing' do it 'builds actor from stacktrace' do modified_event.delete('transaction') - event = described_class.new(project, nil, event: modified_event).execute - expect(event.error.actor).to eq 'find()' end end - context 'timestamp is numeric' do + context 'when transaction is an empty string' do \ + it 'builds actor from stacktrace' do + modified_event['transaction'] = '' + + expect(event.error.actor).to eq 'find()' + end + end + + context 'when timestamp is numeric' do it 'parses timestamp' do modified_event['timestamp'] = '1631015580.50' - event = described_class.new(project, nil, event: modified_event).execute - expect(event.occurred_at).to eq '2021-09-07T11:53:00.5' end end diff --git a/spec/support/shared_examples/row_lock_shared_examples.rb b/spec/support/shared_examples/row_lock_shared_examples.rb index 5e003172215..e7eec88ec42 100644 --- a/spec/support/shared_examples/row_lock_shared_examples.rb +++ b/spec/support/shared_examples/row_lock_shared_examples.rb @@ -4,10 +4,10 @@ # Ensure a transaction also occurred. # Be careful! This form of spec is not foolproof, but better than nothing. -RSpec.shared_examples 'locked row' do +RSpec.shared_examples 'locked row' do |lock_type| it "has locked row" do table_name = row.class.table_name - ids_regex = /SELECT.*FROM.*#{table_name}.*"#{table_name}"."id" = #{row.id}.+FOR UPDATE/m + ids_regex = /SELECT.*FROM.*#{table_name}.*"#{table_name}"."id" = #{row.id}.+#{lock_type}/m expect(recorded_queries.log).to include a_string_matching 'SAVEPOINT' expect(recorded_queries.log).to include a_string_matching ids_regex