diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-08-09 18:11:41 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-08-09 18:11:41 +0300 |
commit | d56569ff3e73ae1dbcf93d2530925c4ecb8fd185 (patch) | |
tree | f89e6dd59d8d807201a9dd3ca46b5eee0ea5f438 /spec | |
parent | 1faea1c6a0464e44dca4477fb31846938c2ad871 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec')
12 files changed, 523 insertions, 152 deletions
diff --git a/spec/frontend/custom_emoji/components/delete_item_spec.js b/spec/frontend/custom_emoji/components/delete_item_spec.js new file mode 100644 index 00000000000..06c4ca8d54b --- /dev/null +++ b/spec/frontend/custom_emoji/components/delete_item_spec.js @@ -0,0 +1,89 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import * as Sentry from '@sentry/browser'; +import { GlModal } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/alert'; +import DeleteItem from '~/custom_emoji/components/delete_item.vue'; +import deleteCustomEmojiMutation from '~/custom_emoji/queries/delete_custom_emoji.mutation.graphql'; +import { CUSTOM_EMOJI } from '../mock_data'; + +jest.mock('~/alert'); +jest.mock('@sentry/browser'); + +let wrapper; +let deleteMutationSpy; + +Vue.use(VueApollo); + +function createSuccessSpy() { + deleteMutationSpy = jest.fn().mockResolvedValue({ + data: { destroyCustomEmoji: { customEmoji: { id: CUSTOM_EMOJI[0].id } } }, + }); +} + +function createErrorSpy() { + deleteMutationSpy = jest.fn().mockRejectedValue(); +} + +function createMockApolloProvider() { + const requestHandlers = [[deleteCustomEmojiMutation, deleteMutationSpy]]; + + return createMockApollo(requestHandlers); +} + +function createComponent() { + const apolloProvider = createMockApolloProvider(); + + wrapper = mountExtended(DeleteItem, { + apolloProvider, + propsData: { + emoji: CUSTOM_EMOJI[0], + }, + }); +} + +const findDeleteButton = () => wrapper.findByTestId('delete-button'); +const findModal = () => wrapper.findComponent(GlModal); + +describe('Custom emoji delete item component', () => { + it('opens modal when clicking button', async () => { + createSuccessSpy(); + createComponent(); + + await findDeleteButton().trigger('click'); + + expect(document.querySelector('.gl-modal')).not.toBe(null); + }); + + it('calls GraphQL mutation on modals primary action', () => { + createSuccessSpy(); + createComponent(); + + findModal().vm.$emit('primary'); + + expect(deleteMutationSpy).toHaveBeenCalledWith({ id: CUSTOM_EMOJI[0].id }); + }); + + it('creates alert when mutation fails', async () => { + createErrorSpy(); + createComponent(); + + findModal().vm.$emit('primary'); + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith('Failed to delete custom emoji. Please try again.'); + }); + + it('calls sentry when mutation fails', async () => { + createErrorSpy(); + createComponent(); + + findModal().vm.$emit('primary'); + await waitForPromises(); + + expect(Sentry.captureException).toHaveBeenCalled(); + }); +}); diff --git a/spec/frontend/custom_emoji/components/list_spec.js b/spec/frontend/custom_emoji/components/list_spec.js index 2bffe367435..b5729d59464 100644 --- a/spec/frontend/custom_emoji/components/list_spec.js +++ b/spec/frontend/custom_emoji/components/list_spec.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import List from '~/custom_emoji/components/list.vue'; +import DeleteItem from '~/custom_emoji/components/delete_item.vue'; import { CUSTOM_EMOJI } from '../mock_data'; jest.mock('~/lib/utils/datetime/date_format_utility', () => ({ @@ -58,4 +59,21 @@ describe('Custom emoji settings list component', () => { expect(wrapper.emitted('input')[0]).toEqual([emits]); }); }); + + describe('delete button', () => { + it.each` + deleteCustomEmoji | rendersText | renders + ${true} | ${'renders'} | ${true} + ${false} | ${'does not render'} | ${false} + `( + '$rendersText delete button when deleteCustomEmoji is $deleteCustomEmoji', + ({ deleteCustomEmoji, renders }) => { + createComponent({ + customEmojis: [{ ...CUSTOM_EMOJI[0], userPermissions: { deleteCustomEmoji } }], + }); + + expect(wrapper.findComponent(DeleteItem).exists()).toBe(renders); + }, + ); + }); }); diff --git a/spec/frontend/custom_emoji/mock_data.js b/spec/frontend/custom_emoji/mock_data.js index 9936274d71c..f2b32bf1cfb 100644 --- a/spec/frontend/custom_emoji/mock_data.js +++ b/spec/frontend/custom_emoji/mock_data.js @@ -4,6 +4,9 @@ export const CUSTOM_EMOJI = [ name: 'confused_husky', url: 'https://gitlab.com/custom_emoji/custom_emoji/-/raw/main/img/confused_husky.gif', createdAt: 'created-at', + userPermissions: { + deleteCustomEmoji: false, + }, }, ]; diff --git a/spec/frontend/work_items/components/shared/work_item_link_child_contents_spec.js b/spec/frontend/work_items/components/shared/work_item_link_child_contents_spec.js new file mode 100644 index 00000000000..9a20e2ec98f --- /dev/null +++ b/spec/frontend/work_items/components/shared/work_item_link_child_contents_spec.js @@ -0,0 +1,179 @@ +import { GlLabel, GlIcon } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +import WorkItemLinkChildMetadata from 'ee_else_ce/work_items/components/shared/work_item_link_child_metadata.vue'; + +import { createAlert } from '~/alert'; +import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue'; + +import WorkItemLinkChildContents from '~/work_items/components/shared/work_item_link_child_contents.vue'; +import WorkItemLinksMenu from '~/work_items/components/shared/work_item_links_menu.vue'; +import { TASK_TYPE_NAME, WORK_ITEM_TYPE_VALUE_OBJECTIVE } from '~/work_items/constants'; + +import { + workItemTask, + workItemObjectiveWithChild, + workItemObjectiveNoMetadata, + confidentialWorkItemTask, + closedWorkItemTask, + workItemObjectiveMetadataWidgets, +} from '../../mock_data'; + +jest.mock('~/alert'); + +describe('WorkItemLinkChildContents', () => { + Vue.use(VueApollo); + + const WORK_ITEM_ID = 'gid://gitlab/WorkItem/2'; + let wrapper; + const { LABELS } = workItemObjectiveMetadataWidgets; + const mockLabels = LABELS.labels.nodes; + const mockFullPath = 'gitlab-org/gitlab-test'; + + const findStatusIconComponent = () => + wrapper.findByTestId('item-status-icon').findComponent(GlIcon); + const findConfidentialIconComponent = () => wrapper.findByTestId('confidential-icon'); + const findTitleEl = () => wrapper.findByTestId('item-title'); + const findStatusTooltipComponent = () => wrapper.findComponent(RichTimestampTooltip); + const findMetadataComponent = () => wrapper.findComponent(WorkItemLinkChildMetadata); + const findAllLabels = () => wrapper.findAllComponents(GlLabel); + const findRegularLabel = () => findAllLabels().at(0); + const findScopedLabel = () => findAllLabels().at(1); + const findLinksMenuComponent = () => wrapper.findComponent(WorkItemLinksMenu); + + const createComponent = ({ + canUpdate = true, + parentWorkItemId = WORK_ITEM_ID, + childItem = workItemTask, + workItemType = TASK_TYPE_NAME, + } = {}) => { + wrapper = shallowMountExtended(WorkItemLinkChildContents, { + propsData: { + canUpdate, + parentWorkItemId, + childItem, + workItemType, + fullPath: mockFullPath, + childPath: '/gitlab-org/gitlab-test/-/work_items/4', + }, + }); + }; + + beforeEach(() => { + createAlert.mockClear(); + }); + + it.each` + status | childItem | statusIconName | statusIconColorClass | rawTimestamp | tooltipContents + ${'open'} | ${workItemTask} | ${'issue-open-m'} | ${'gl-text-green-500'} | ${workItemTask.createdAt} | ${'Created'} + ${'closed'} | ${closedWorkItemTask} | ${'issue-close'} | ${'gl-text-blue-500'} | ${closedWorkItemTask.closedAt} | ${'Closed'} + `( + 'renders item status icon and tooltip when item status is `$status`', + ({ childItem, statusIconName, statusIconColorClass, rawTimestamp, tooltipContents }) => { + createComponent({ childItem }); + + expect(findStatusIconComponent().props('name')).toBe(statusIconName); + expect(findStatusIconComponent().classes()).toContain(statusIconColorClass); + expect(findStatusTooltipComponent().props('rawTimestamp')).toBe(rawTimestamp); + expect(findStatusTooltipComponent().props('timestampTypeText')).toContain(tooltipContents); + }, + ); + + it('renders confidential icon when item is confidential', () => { + createComponent({ childItem: confidentialWorkItemTask }); + + expect(findConfidentialIconComponent().props('name')).toBe('eye-slash'); + expect(findConfidentialIconComponent().attributes('title')).toBe('Confidential'); + }); + + describe('item title', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders item title', () => { + expect(findTitleEl().attributes('href')).toBe('/gitlab-org/gitlab-test/-/work_items/4'); + expect(findTitleEl().text()).toBe(workItemTask.title); + }); + + it.each` + action | event | emittedEvent + ${'on mouseover'} | ${'mouseover'} | ${'mouseover'} + ${'on mouseout'} | ${'mouseout'} | ${'mouseout'} + `('$action item title emit `$emittedEvent` event', ({ event, emittedEvent }) => { + findTitleEl().vm.$emit(event); + + expect(wrapper.emitted(emittedEvent)).toEqual([[]]); + }); + + it('emits click event with correct parameters on clicking title', () => { + const eventObj = { + preventDefault: jest.fn(), + }; + findTitleEl().vm.$emit('click', eventObj); + + expect(wrapper.emitted('click')).toEqual([[eventObj]]); + }); + }); + + describe('item metadata', () => { + beforeEach(() => { + createComponent({ + childItem: workItemObjectiveWithChild, + workItemType: WORK_ITEM_TYPE_VALUE_OBJECTIVE, + }); + }); + + it('renders item metadata component when item has metadata present', () => { + expect(findMetadataComponent().props()).toMatchObject({ + metadataWidgets: workItemObjectiveMetadataWidgets, + }); + }); + + it('does not render item metadata component when item has no metadata present', () => { + createComponent({ + childItem: workItemObjectiveNoMetadata, + workItemType: WORK_ITEM_TYPE_VALUE_OBJECTIVE, + }); + + expect(findMetadataComponent().exists()).toBe(false); + }); + + it('renders labels', () => { + const mockLabel = mockLabels[0]; + + expect(findAllLabels()).toHaveLength(mockLabels.length); + expect(findRegularLabel().props()).toMatchObject({ + title: mockLabel.title, + backgroundColor: mockLabel.color, + description: mockLabel.description, + scoped: false, + }); + expect(findScopedLabel().props('scoped')).toBe(true); // Second label is scoped + }); + }); + + describe('item menu', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders work-item-links-menu', () => { + expect(findLinksMenuComponent().exists()).toBe(true); + }); + + it('does not render work-item-links-menu when canUpdate is false', () => { + createComponent({ canUpdate: false }); + + expect(findLinksMenuComponent().exists()).toBe(false); + }); + + it('removeChild event on menu triggers `click-remove-child` event', () => { + findLinksMenuComponent().vm.$emit('removeChild'); + + expect(wrapper.emitted('removeChild')).toEqual([[workItemTask]]); + }); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_link_child_metadata_spec.js b/spec/frontend/work_items/components/shared/work_item_link_child_metadata_spec.js index 07efb1c5ac8..25ef0e69a40 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_link_child_metadata_spec.js +++ b/spec/frontend/work_items/components/shared/work_item_link_child_metadata_spec.js @@ -3,7 +3,7 @@ import { GlAvatarsInline } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ItemMilestone from '~/issuable/components/issue_milestone.vue'; -import WorkItemLinkChildMetadata from '~/work_items/components/work_item_links/work_item_link_child_metadata.vue'; +import WorkItemLinkChildMetadata from '~/work_items/components/shared/work_item_link_child_metadata.vue'; import { workItemObjectiveMetadataWidgets } from '../../mock_data'; diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_menu_spec.js b/spec/frontend/work_items/components/shared/work_item_links_menu_spec.js index f02a9fbd021..721db6c3315 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_links_menu_spec.js +++ b/spec/frontend/work_items/components/shared/work_item_links_menu_spec.js @@ -1,7 +1,7 @@ import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import WorkItemLinksMenu from '~/work_items/components/work_item_links/work_item_links_menu.vue'; +import WorkItemLinksMenu from '~/work_items/components/shared/work_item_links_menu.vue'; describe('WorkItemLinksMenu', () => { let wrapper; diff --git a/spec/frontend/work_items/components/work_item_links/work_item_link_child_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_link_child_spec.js index 71d1a0e253f..803ff950cbe 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_link_child_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_link_child_spec.js @@ -1,20 +1,16 @@ -import { GlLabel, GlIcon } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import WorkItemLinkChildMetadata from 'ee_else_ce/work_items/components/work_item_links/work_item_link_child_metadata.vue'; - import { createAlert } from '~/alert'; -import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue'; import getWorkItemTreeQuery from '~/work_items/graphql/work_item_tree.query.graphql'; import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import WorkItemLinkChild from '~/work_items/components/work_item_links/work_item_link_child.vue'; -import WorkItemLinksMenu from '~/work_items/components/work_item_links/work_item_links_menu.vue'; import WorkItemTreeChildren from '~/work_items/components/work_item_links/work_item_tree_children.vue'; +import WorkItemLinkChildContents from '~/work_items/components/shared/work_item_link_child_contents.vue'; import { WIDGET_TYPE_HIERARCHY, TASK_TYPE_NAME, @@ -24,12 +20,8 @@ import { import { workItemTask, workItemObjectiveWithChild, - workItemObjectiveNoMetadata, - confidentialWorkItemTask, - closedWorkItemTask, workItemHierarchyTreeResponse, workItemHierarchyTreeFailureResponse, - workItemObjectiveMetadataWidgets, changeIndirectWorkItemParentMutationResponse, workItemUpdateFailureResponse, } from '../../mock_data'; @@ -41,8 +33,6 @@ describe('WorkItemLinkChild', () => { let wrapper; let getWorkItemTreeQueryHandler; let mutationChangeParentHandler; - const { LABELS } = workItemObjectiveMetadataWidgets; - const mockLabels = LABELS.labels.nodes; const $toast = { show: jest.fn(), @@ -51,6 +41,8 @@ describe('WorkItemLinkChild', () => { Vue.use(VueApollo); + const findWorkItemLinkChildContents = () => wrapper.findComponent(WorkItemLinkChildContents); + const createComponent = ({ canUpdate = true, issuableGid = WORK_ITEM_ID, @@ -89,87 +81,7 @@ describe('WorkItemLinkChild', () => { createAlert.mockClear(); }); - it.each` - status | childItem | statusIconName | statusIconColorClass | rawTimestamp | tooltipContents - ${'open'} | ${workItemTask} | ${'issue-open-m'} | ${'gl-text-green-500'} | ${workItemTask.createdAt} | ${'Created'} - ${'closed'} | ${closedWorkItemTask} | ${'issue-close'} | ${'gl-text-blue-500'} | ${closedWorkItemTask.closedAt} | ${'Closed'} - `( - 'renders item status icon and tooltip when item status is `$status`', - ({ childItem, statusIconName, statusIconColorClass, rawTimestamp, tooltipContents }) => { - createComponent({ childItem }); - - const statusIcon = wrapper.findByTestId('item-status-icon').findComponent(GlIcon); - const statusTooltip = wrapper.findComponent(RichTimestampTooltip); - - expect(statusIcon.props('name')).toBe(statusIconName); - expect(statusIcon.classes()).toContain(statusIconColorClass); - expect(statusTooltip.props('rawTimestamp')).toBe(rawTimestamp); - expect(statusTooltip.props('timestampTypeText')).toContain(tooltipContents); - }, - ); - - it('renders confidential icon when item is confidential', () => { - createComponent({ childItem: confidentialWorkItemTask }); - - const confidentialIcon = wrapper.findByTestId('confidential-icon'); - - expect(confidentialIcon.props('name')).toBe('eye-slash'); - expect(confidentialIcon.attributes('title')).toBe('Confidential'); - }); - - describe('item title', () => { - let titleEl; - - beforeEach(() => { - createComponent(); - - titleEl = wrapper.findByTestId('item-title'); - }); - - it('renders item title', () => { - expect(titleEl.attributes('href')).toBe('/gitlab-org/gitlab-test/-/work_items/4'); - expect(titleEl.text()).toBe(workItemTask.title); - }); - - describe('renders item title correctly for relative instance', () => { - beforeEach(() => { - window.gon = { relative_url_root: '/test' }; - createComponent(); - titleEl = wrapper.findByTestId('item-title'); - }); - - it('renders item title with correct href', () => { - expect(titleEl.attributes('href')).toBe('/test/gitlab-org/gitlab-test/-/work_items/4'); - }); - - it('renders item title with correct text', () => { - expect(titleEl.text()).toBe(workItemTask.title); - }); - }); - - it.each` - action | event | emittedEvent - ${'doing mouseover on'} | ${'mouseover'} | ${'mouseover'} - ${'doing mouseout on'} | ${'mouseout'} | ${'mouseout'} - `('$action item title emit `$emittedEvent` event', ({ event, emittedEvent }) => { - titleEl.vm.$emit(event); - - expect(wrapper.emitted(emittedEvent)).toEqual([[]]); - }); - - it('emits click event with correct parameters on clicking title', () => { - const eventObj = { - preventDefault: jest.fn(), - }; - titleEl.vm.$emit('click', eventObj); - - expect(wrapper.emitted('click')).toEqual([[eventObj]]); - }); - }); - - describe('item metadata', () => { - const findMetadataComponent = () => wrapper.findComponent(WorkItemLinkChildMetadata); - + describe('renders WorkItemLinkChildContents', () => { beforeEach(() => { createComponent({ childItem: workItemObjectiveWithChild, @@ -177,67 +89,31 @@ describe('WorkItemLinkChild', () => { }); }); - it('renders item metadata component when item has metadata present', () => { - const metadataEl = findMetadataComponent(); - expect(metadataEl.exists()).toBe(true); - expect(metadataEl.props()).toMatchObject({ - metadataWidgets: workItemObjectiveMetadataWidgets, - }); - }); - - it('does not render item metadata component when item has no metadata present', () => { - createComponent({ - childItem: workItemObjectiveNoMetadata, - workItemType: WORK_ITEM_TYPE_VALUE_OBJECTIVE, + it('with default props', () => { + expect(findWorkItemLinkChildContents().props()).toEqual({ + childItem: workItemObjectiveWithChild, + canUpdate: true, + parentWorkItemId: 'gid://gitlab/WorkItem/2', + workItemType: 'Objective', + childPath: '/gitlab-org/gitlab-test/-/work_items/12', }); - - expect(findMetadataComponent().exists()).toBe(false); }); - it('renders labels', () => { - const labels = wrapper.findAllComponents(GlLabel); - const mockLabel = mockLabels[0]; - - expect(labels).toHaveLength(mockLabels.length); - expect(labels.at(0).props()).toMatchObject({ - title: mockLabel.title, - backgroundColor: mockLabel.color, - description: mockLabel.description, - scoped: false, + describe('with relative instance', () => { + beforeEach(() => { + window.gon = { relative_url_root: '/test' }; + createComponent({ + childItem: workItemObjectiveWithChild, + workItemType: WORK_ITEM_TYPE_VALUE_OBJECTIVE, + }); }); - expect(labels.at(1).props('scoped')).toBe(true); // Second label is scoped - }); - }); - - describe('item menu', () => { - let itemMenuEl; - - beforeEach(() => { - createComponent(); - - itemMenuEl = wrapper.findComponent(WorkItemLinksMenu); - }); - it('renders work-item-links-menu', () => { - expect(itemMenuEl.exists()).toBe(true); - - expect(itemMenuEl.attributes()).toMatchObject({ - 'work-item-id': workItemTask.id, - 'parent-work-item-id': WORK_ITEM_ID, + it('adds the relative url to child path value', () => { + expect(findWorkItemLinkChildContents().props('childPath')).toBe( + '/test/gitlab-org/gitlab-test/-/work_items/12', + ); }); }); - - it('does not render work-item-links-menu when canUpdate is false', () => { - createComponent({ canUpdate: false }); - - expect(wrapper.findComponent(WorkItemLinksMenu).exists()).toBe(false); - }); - - it('removeChild event on menu triggers `click-remove-child` event', () => { - itemMenuEl.vm.$emit('removeChild'); - - expect(wrapper.emitted('removeChild')).toEqual([[workItemTask]]); - }); }); describe('nested children', () => { @@ -252,7 +128,6 @@ describe('WorkItemLinkChild', () => { const findFirstItem = () => getChildrenNodes()[0]; beforeEach(() => { - getWorkItemTreeQueryHandler.mockClear(); createComponent({ childItem: workItemObjectiveWithChild, workItemType: WORK_ITEM_TYPE_VALUE_OBJECTIVE, diff --git a/spec/helpers/time_helper_spec.rb b/spec/helpers/time_helper_spec.rb index 3e406f5e74e..02e28b2ba05 100644 --- a/spec/helpers/time_helper_spec.rb +++ b/spec/helpers/time_helper_spec.rb @@ -11,7 +11,7 @@ RSpec.describe TimeHelper do 100.32 => "1 minute and 40 seconds", 120 => "2 minutes", 121 => "2 minutes and 1 second", - 3721 => "62 minutes and 1 second", + 3721 => "1 hour, 2 minutes, and 1 second", 0 => "0 seconds" } diff --git a/spec/lib/gitlab/blame_spec.rb b/spec/lib/gitlab/blame_spec.rb index f636ce283ae..bfe2b7d1360 100644 --- a/spec/lib/gitlab/blame_spec.rb +++ b/spec/lib/gitlab/blame_spec.rb @@ -33,12 +33,18 @@ RSpec.describe Gitlab::Blame do expect(subject.count).to eq(18) expect(subject[0][:commit].sha).to eq('913c66a37b4a45b9769037c55c2d238bd0942d2e') expect(subject[0][:lines]).to eq(["require 'fileutils'", "require 'open3'", ""]) + expect(subject[0][:span]).to eq(3) + expect(subject[0][:lineno]).to eq(1) expect(subject[1][:commit].sha).to eq('874797c3a73b60d2187ed6e2fcabd289ff75171e') expect(subject[1][:lines]).to eq(["module Popen", " extend self"]) + expect(subject[1][:span]).to eq(2) + expect(subject[1][:lineno]).to eq(4) expect(subject[-1][:commit].sha).to eq('913c66a37b4a45b9769037c55c2d238bd0942d2e') expect(subject[-1][:lines]).to eq([" end", "end"]) + expect(subject[-1][:span]).to eq(2) + expect(subject[-1][:lineno]).to eq(36) end context 'with a range 1..5' do diff --git a/spec/lib/gitlab/git/blame_spec.rb b/spec/lib/gitlab/git/blame_spec.rb index 676ea2663d2..d21ac36bf34 100644 --- a/spec/lib/gitlab/git/blame_spec.rb +++ b/spec/lib/gitlab/git/blame_spec.rb @@ -13,13 +13,17 @@ RSpec.describe Gitlab::Git::Blame do let(:result) do [].tap do |data| - blame.each do |commit, line, previous_path| - data << { commit: commit, line: line, previous_path: previous_path } + blame.each do |commit, line, previous_path, span| + data << { commit: commit, line: line, previous_path: previous_path, span: span } end end end describe 'blaming a file' do + it 'has the right commit span' do + expect(result.first[:span]).to eq(95) + end + it 'has the right number of lines' do expect(result.size).to eq(95) expect(result.first[:commit]).to be_kind_of(Gitlab::Git::Commit) diff --git a/spec/lib/gitlab/gitaly_client/operation_service_spec.rb b/spec/lib/gitlab/gitaly_client/operation_service_spec.rb index f8b452b157a..9055b284119 100644 --- a/spec/lib/gitlab/gitaly_client/operation_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/operation_service_spec.rb @@ -731,6 +731,39 @@ RSpec.describe Gitlab::GitalyClient::OperationService, feature_category: :source end end + describe '#user_rebase_to_ref' do + let(:first_parent_ref) { 'refs/heads/my-branch' } + let(:source_sha) { 'cfe32cf61b73a0d5e9f13e774abde7ff789b1660' } + let(:target_ref) { 'refs/merge-requests/x/merge' } + let(:response) { Gitaly::UserRebaseToRefResponse.new(commit_id: 'new-commit-id') } + + let(:payload) do + { source_sha: source_sha, target_ref: target_ref, first_parent_ref: first_parent_ref } + end + + it 'sends a user_rebase_to_ref message' do + freeze_time do + expect_any_instance_of(Gitaly::OperationService::Stub).to receive(:user_rebase_to_ref) do |_, request, options| + expect(options).to be_kind_of(Hash) + expect(request.to_h).to( + eq( + payload.merge( + { + expected_old_oid: "", + repository: repository.gitaly_repository.to_h, + user: Gitlab::Git::User.from_gitlab(user).to_gitaly.to_h, + timestamp: { nanos: 0, seconds: Time.current.to_i } + } + ) + ) + ) + end.and_return(response) + + client.user_rebase_to_ref(user, **payload) + end + end + end + describe '#user_squash' do let(:start_sha) { 'b83d6e391c22777fca1ed3012fce84f633d7fed0' } let(:end_sha) { '54cec5282aa9f21856362fe321c800c236a61615' } diff --git a/spec/services/merge_requests/create_ref_service_spec.rb b/spec/services/merge_requests/create_ref_service_spec.rb new file mode 100644 index 00000000000..1d073cd143e --- /dev/null +++ b/spec/services/merge_requests/create_ref_service_spec.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe MergeRequests::CreateRefService, feature_category: :merge_trains do + using RSpec::Parameterized::TableSyntax + + describe '#execute' do + let_it_be(:project) { create(:project, :empty_repo) } + let_it_be(:user) { project.creator } + let_it_be(:first_parent_ref) { project.default_branch_or_main } + let_it_be(:source_branch) { 'branch' } + let(:target_ref) { "refs/merge-requests/#{merge_request.iid}/train" } + let(:source_sha) { project.commit(source_branch).sha } + let(:squash) { false } + + let(:merge_request) do + create( + :merge_request, + title: 'Merge request ref test', + author: user, + source_project: project, + target_project: project, + source_branch: source_branch, + target_branch: first_parent_ref, + squash: squash + ) + end + + subject(:result) do + described_class.new( + current_user: user, + merge_request: merge_request, + target_ref: target_ref, + source_sha: source_sha, + first_parent_ref: first_parent_ref + ).execute + end + + context 'when there is a user-caused gitaly error' do + let(:source_sha) { '123' } + + it 'returns an error response' do + expect(result[:status]).to eq :error + end + end + + context 'with valid inputs' do + before_all do + # ensure first_parent_ref is created before source_sha + project.repository.create_file( + user, + 'README.md', + '', + message: 'Base parent commit 1', + branch_name: first_parent_ref + ) + project.repository.create_branch(source_branch, first_parent_ref) + + # create two commits source_branch to test squashing + project.repository.create_file( + user, + '.gitlab-ci.yml', + '', + message: 'Feature branch commit 1', + branch_name: source_branch + ) + + project.repository.create_file( + user, + '.gitignore', + '', + message: 'Feature branch commit 2', + branch_name: source_branch + ) + + # create an extra commit not present on source_branch + project.repository.create_file( + user, + 'EXTRA', + '', + message: 'Base parent commit 2', + branch_name: first_parent_ref + ) + end + + it 'writes the merged result into target_ref', :aggregate_failures do + expect(result[:status]).to eq :success + expect(project.repository.commits(target_ref, limit: 10, order: 'topo').map(&:message)).to( + match( + [ + a_string_matching(/Merge branch '#{source_branch}' into '#{first_parent_ref}'/), + 'Feature branch commit 2', + 'Feature branch commit 1', + 'Base parent commit 2', + 'Base parent commit 1' + ] + ) + ) + end + + context 'when squash is requested' do + let(:squash) { true } + + it 'writes the squashed result', :aggregate_failures do + expect(result[:status]).to eq :success + expect(project.repository.commits(target_ref, limit: 10, order: 'topo').map(&:message)).to( + match( + [ + a_string_matching(/Merge branch '#{source_branch}' into '#{first_parent_ref}'/), + "#{merge_request.title}\n", + 'Base parent commit 2', + 'Base parent commit 1' + ] + ) + ) + end + end + + context 'when semi-linear merges are enabled' do + before do + project.merge_method = :rebase_merge + project.save! + end + + it 'writes the semi-linear merged result', :aggregate_failures do + expect(result[:status]).to eq :success + expect(project.repository.commits(target_ref, limit: 10, order: 'topo').map(&:message)).to( + match( + [ + a_string_matching(/Merge branch '#{source_branch}' into '#{first_parent_ref}'/), + 'Feature branch commit 2', + 'Feature branch commit 1', + 'Base parent commit 2', + 'Base parent commit 1' + ] + ) + ) + end + end + + context 'when fast-forward merges are enabled' do + before do + project.merge_method = :ff + project.save! + end + + it 'writes the rebased merged result', :aggregate_failures do + expect(result[:status]).to eq :success + expect(project.repository.commits(target_ref, limit: 10, order: 'topo').map(&:message)).to( + eq( + [ + 'Feature branch commit 2', + 'Feature branch commit 1', + 'Base parent commit 2', + 'Base parent commit 1' + ] + ) + ) + end + end + end + end +end |