({
@@ -18,6 +22,7 @@ jest.mock('~/lib/utils/url_utility', () => ({
describe('RelatedIssuableItem', () => {
let wrapper;
+ let showModalSpy;
const defaultProps = {
idKey: 1,
@@ -40,13 +45,25 @@ describe('RelatedIssuableItem', () => {
const findRemoveButton = () => wrapper.findComponent(GlButton);
const findTitleLink = () => wrapper.findComponent(GlLink);
const findWorkItemDetailModal = () => wrapper.findComponent(WorkItemDetailModal);
+ const findAbuseCategorySelector = () => wrapper.findComponent(AbuseCategorySelector);
function mountComponent({ data = {}, props = {} } = {}) {
+ showModalSpy = jest.fn();
wrapper = shallowMount(RelatedIssuableItem, {
propsData: {
...defaultProps,
...props,
},
+ provide: {
+ reportAbusePath: '/report/abuse/path',
+ },
+ stubs: {
+ WorkItemDetailModal: stubComponent(WorkItemDetailModal, {
+ methods: {
+ show: showModalSpy,
+ },
+ }),
+ },
data() {
return data;
},
@@ -265,4 +282,30 @@ describe('RelatedIssuableItem', () => {
});
});
});
+
+ describe('abuse category selector', () => {
+ beforeEach(() => {
+ mountComponent({ props: { workItemType: 'TASK' } });
+ findTitleLink().vm.$emit('click', { preventDefault: () => {} });
+ });
+
+ it('should not be visible by default', () => {
+ expect(showModalSpy).toHaveBeenCalled();
+ expect(findAbuseCategorySelector().exists()).toBe(false);
+ });
+
+ it('should be visible when the work item modal emits `openReportAbuse` event', async () => {
+ findWorkItemDetailModal().vm.$emit('openReportAbuse', mockWorkItemCommentNote);
+
+ await nextTick();
+
+ expect(findAbuseCategorySelector().exists()).toBe(true);
+
+ findAbuseCategorySelector().vm.$emit('close-drawer');
+
+ await nextTick();
+
+ expect(findAbuseCategorySelector().exists()).toBe(false);
+ });
+ });
});
diff --git a/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js
index b9580b90c12..8807bc311f0 100644
--- a/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js
+++ b/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js
@@ -22,6 +22,41 @@ describe('RelatedIssuesBlock', () => {
const findRelatedIssuesBody = () => wrapper.findByTestId('related-issues-body');
const findIssueCountBadgeAddButton = () => wrapper.findByTestId('related-issues-plus-button');
+ const createComponent = ({
+ mountFn = mountExtended,
+ pathIdSeparator = PathIdSeparator.Issue,
+ issuableType = TYPE_ISSUE,
+ canAdmin = false,
+ helpPath = '',
+ isFetching = false,
+ isFormVisible = false,
+ relatedIssues = [],
+ showCategorizedIssues = false,
+ autoCompleteEpics = true,
+ slots = '',
+ } = {}) => {
+ wrapper = mountFn(RelatedIssuesBlock, {
+ propsData: {
+ pathIdSeparator,
+ issuableType,
+ canAdmin,
+ helpPath,
+ isFetching,
+ isFormVisible,
+ relatedIssues,
+ showCategorizedIssues,
+ autoCompleteEpics,
+ },
+ provide: {
+ reportAbusePath: '/report/abuse/path',
+ },
+ stubs: {
+ GlCard,
+ },
+ slots,
+ });
+ };
+
afterEach(() => {
if (wrapper) {
wrapper.destroy();
@@ -31,12 +66,7 @@ describe('RelatedIssuesBlock', () => {
describe('with defaults', () => {
beforeEach(() => {
- wrapper = mountExtended(RelatedIssuesBlock, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- issuableType: TYPE_ISSUE,
- },
- });
+ createComponent();
});
it.each`
@@ -46,13 +76,11 @@ describe('RelatedIssuesBlock', () => {
`(
'displays "$titleText" in the header and "$addButtonText" aria-label for add button when issuableType is set to "$issuableType"',
({ issuableType, pathIdSeparator, titleText, addButtonText }) => {
- wrapper = mountExtended(RelatedIssuesBlock, {
- propsData: {
- pathIdSeparator,
- issuableType,
- canAdmin: true,
- helpPath: '/help/user/project/issues/related_issues',
- },
+ createComponent({
+ pathIdSeparator,
+ issuableType,
+ canAdmin: true,
+ helpPath: '/help/user/project/issues/related_issues',
});
expect(wrapper.find('.card-title').text()).toContain(titleText);
@@ -73,14 +101,8 @@ describe('RelatedIssuesBlock', () => {
it('displays header text slot data', () => {
const headerText = '
custom header text
';
- wrapper = shallowMountExtended(RelatedIssuesBlock, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- issuableType: 'issue',
- },
- stubs: {
- GlCard,
- },
+ createComponent({
+ mountFn: shallowMountExtended,
slots: { 'header-text': headerText },
});
@@ -92,14 +114,8 @@ describe('RelatedIssuesBlock', () => {
it('displays header actions slot data', () => {
const headerActions = '
';
- wrapper = shallowMountExtended(RelatedIssuesBlock, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- issuableType: 'issue',
- },
- stubs: {
- GlCard,
- },
+ createComponent({
+ mountFn: shallowMountExtended,
slots: { 'header-actions': headerActions },
});
@@ -109,12 +125,8 @@ describe('RelatedIssuesBlock', () => {
describe('with isFetching=true', () => {
beforeEach(() => {
- wrapper = mountExtended(RelatedIssuesBlock, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- isFetching: true,
- issuableType: 'issue',
- },
+ createComponent({
+ isFetching: true,
});
});
@@ -125,13 +137,7 @@ describe('RelatedIssuesBlock', () => {
describe('with canAddRelatedIssues=true', () => {
beforeEach(() => {
- wrapper = mountExtended(RelatedIssuesBlock, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- canAdmin: true,
- issuableType: 'issue',
- },
- });
+ createComponent({ canAdmin: true });
});
it('can add new related issues', () => {
@@ -141,14 +147,7 @@ describe('RelatedIssuesBlock', () => {
describe('with isFormVisible=true', () => {
beforeEach(() => {
- wrapper = mountExtended(RelatedIssuesBlock, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- isFormVisible: true,
- issuableType: 'issue',
- autoCompleteEpics: false,
- },
- });
+ createComponent({ isFormVisible: true, autoCompleteEpics: false });
});
it('shows add related issues form', () => {
@@ -164,19 +163,14 @@ describe('RelatedIssuesBlock', () => {
const issueList = () => wrapper.findAll('.js-related-issues-token-list-item');
const categorizedHeadings = () => wrapper.findAll('h4');
const headingTextAt = (index) => categorizedHeadings().at(index).text();
- const mountComponent = (showCategorizedIssues) => {
- wrapper = mountExtended(RelatedIssuesBlock, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- relatedIssues: [issuable1, issuable2, issuable3],
- issuableType: 'issue',
- showCategorizedIssues,
- },
- });
- };
describe('when showCategorizedIssues=true', () => {
- beforeEach(() => mountComponent(true));
+ beforeEach(() =>
+ createComponent({
+ showCategorizedIssues: true,
+ relatedIssues: [issuable1, issuable2, issuable3],
+ }),
+ );
it('should render issue tokens items', () => {
expect(issueList()).toHaveLength(3);
@@ -203,8 +197,10 @@ describe('RelatedIssuesBlock', () => {
describe('when showCategorizedIssues=false', () => {
it('should render issues as a flat list with no header', () => {
- mountComponent(false);
-
+ createComponent({
+ showCategorizedIssues: false,
+ relatedIssues: [issuable1, issuable2, issuable3],
+ });
expect(issueList()).toHaveLength(3);
expect(categorizedHeadings()).toHaveLength(0);
});
@@ -223,14 +219,8 @@ describe('RelatedIssuesBlock', () => {
},
].forEach(({ issuableType, icon }) => {
it(`issuableType=${issuableType} is passed`, () => {
- wrapper = shallowMountExtended(RelatedIssuesBlock, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- issuableType,
- },
- stubs: {
- GlCard,
- },
+ createComponent({
+ issuableType,
});
const iconComponent = wrapper.findComponent(GlIcon);
@@ -242,15 +232,8 @@ describe('RelatedIssuesBlock', () => {
describe('toggle', () => {
beforeEach(() => {
- wrapper = shallowMountExtended(RelatedIssuesBlock, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- relatedIssues: [issuable1, issuable2, issuable3],
- issuableType: TYPE_ISSUE,
- },
- stubs: {
- GlCard,
- },
+ createComponent({
+ relatedIssues: [issuable1, issuable2, issuable3],
});
});
@@ -280,14 +263,12 @@ describe('RelatedIssuesBlock', () => {
`(
'displays "$emptyText" in the body and "$helpLinkText" aria-label for help link',
({ issuableType, pathIdSeparator, showCategorizedIssues, emptyText, helpLinkText }) => {
- wrapper = mountExtended(RelatedIssuesBlock, {
- propsData: {
- pathIdSeparator,
- issuableType,
- canAdmin: true,
- helpPath: '/help/user/project/issues/related_issues',
- showCategorizedIssues,
- },
+ createComponent({
+ pathIdSeparator,
+ issuableType,
+ canAdmin: true,
+ helpPath: '/help/user/project/issues/related_issues',
+ showCategorizedIssues,
});
expect(wrapper.findByTestId('related-issues-body').text()).toContain(emptyText);
diff --git a/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js
index 9bb71ec3dcb..0a6a0a90d44 100644
--- a/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js
+++ b/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js
@@ -13,6 +13,30 @@ import { PathIdSeparator } from '~/related_issues/constants';
describe('RelatedIssuesList', () => {
let wrapper;
+ const createComponent = ({
+ mountFn = shallowMount,
+ pathIdSeparator = PathIdSeparator.Issue,
+ issuableType = 'issue',
+ listLinkType = 'relates_to',
+ heading = '',
+ isFetching = false,
+ relatedIssues = [],
+ } = {}) => {
+ wrapper = mountFn(RelatedIssuesList, {
+ propsData: {
+ pathIdSeparator,
+ issuableType,
+ listLinkType,
+ heading,
+ isFetching,
+ relatedIssues,
+ },
+ provide: {
+ reportAbusePath: '/report/abuse/path',
+ },
+ });
+ };
+
afterEach(() => {
if (wrapper) {
wrapper.destroy();
@@ -24,14 +48,7 @@ describe('RelatedIssuesList', () => {
const heading = 'Related to';
beforeEach(() => {
- wrapper = shallowMount(RelatedIssuesList, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- issuableType: 'issue',
- listLinkType: 'relates_to',
- heading,
- },
- });
+ createComponent({ heading });
});
it('assigns value of listLinkType prop to data attribute', () => {
@@ -49,13 +66,7 @@ describe('RelatedIssuesList', () => {
describe('with isFetching=true', () => {
beforeEach(() => {
- wrapper = shallowMount(RelatedIssuesList, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- isFetching: true,
- issuableType: 'issue',
- },
- });
+ createComponent({ isFetching: true });
});
it('should show loading icon', () => {
@@ -65,13 +76,7 @@ describe('RelatedIssuesList', () => {
describe('methods', () => {
beforeEach(() => {
- wrapper = shallowMount(RelatedIssuesList, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- relatedIssues: [issuable1, issuable2, issuable3, issuable4, issuable5],
- issuableType: 'issue',
- },
- });
+ createComponent({ relatedIssues: [issuable1, issuable2, issuable3, issuable4, issuable5] });
});
it('updates the order correctly when an item is moved to the top', () => {
@@ -112,23 +117,17 @@ describe('RelatedIssuesList', () => {
});
describe('issuableOrderingId returns correct issuable order id when', () => {
- it('issuableType is epic', () => {
- wrapper = shallowMount(RelatedIssuesList, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- issuableType: 'issue',
- },
+ it('issuableType is issue', () => {
+ createComponent({
+ issuableType: 'issue',
});
expect(wrapper.vm.issuableOrderingId(issuable1)).toBe(issuable1.epicIssueId);
});
- it('issuableType is issue', () => {
- wrapper = shallowMount(RelatedIssuesList, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- issuableType: 'epic',
- },
+ it('issuableType is epic', () => {
+ createComponent({
+ issuableType: 'epic',
});
expect(wrapper.vm.issuableOrderingId(issuable1)).toBe(issuable1.id);
@@ -143,12 +142,9 @@ describe('RelatedIssuesList', () => {
});
it('issuableType is epic', () => {
- wrapper = shallowMount(RelatedIssuesList, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- issuableType: 'epic',
- relatedIssues,
- },
+ createComponent({
+ issuableType: 'epic',
+ relatedIssues,
});
const listItems = wrapper.vm.$el.querySelectorAll('.list-item');
@@ -159,12 +155,9 @@ describe('RelatedIssuesList', () => {
});
it('issuableType is issue', () => {
- wrapper = shallowMount(RelatedIssuesList, {
- propsData: {
- pathIdSeparator: PathIdSeparator.Issue,
- issuableType: 'issue',
- relatedIssues,
- },
+ createComponent({
+ issuableType: 'issue',
+ relatedIssues,
});
const listItems = wrapper.vm.$el.querySelectorAll('.list-item');
@@ -177,13 +170,7 @@ describe('RelatedIssuesList', () => {
describe('related item contents', () => {
beforeAll(() => {
- wrapper = mount(RelatedIssuesList, {
- propsData: {
- issuableType: 'issue',
- pathIdSeparator: PathIdSeparator.Issue,
- relatedIssues: [issuable1],
- },
- });
+ createComponent({ mountFn: mount, relatedIssues: [issuable1] });
});
it('shows due date', () => {
diff --git a/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js
index 1383013aedb..b119c836411 100644
--- a/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js
+++ b/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js
@@ -42,6 +42,9 @@ describe('RelatedIssuesRoot', () => {
...defaultProps,
...props,
},
+ provide: {
+ reportAbusePath: '/report/abuse/path',
+ },
data() {
return data;
},
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js
index f115ec2d6ca..d87aa3194d2 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js
@@ -99,6 +99,7 @@ function createComponent({
portalName: 'fake target',
alignSuggestions: jest.fn(),
suggestionsListClass: () => 'custom-class',
+ termsAsTokens: () => false,
filteredSearchSuggestionListInstance: {
register: jest.fn(),
unregister: jest.fn(),
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js
index a6bb32736db..6bbbfd838a0 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js
@@ -46,6 +46,7 @@ function createComponent(options = {}) {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
suggestionsListClass: () => 'custom-class',
+ termsAsTokens: () => false,
},
stubs,
});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js
index ce134f7d24e..fb8cea09a9b 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js
@@ -71,6 +71,7 @@ describe('CrmContactToken', () => {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
suggestionsListClass: () => 'custom-class',
+ termsAsTokens: () => false,
},
stubs,
listeners,
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js
index f41c5b5d432..20369342220 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js
@@ -70,6 +70,7 @@ describe('CrmOrganizationToken', () => {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
suggestionsListClass: () => 'custom-class',
+ termsAsTokens: () => false,
},
stubs,
listeners,
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js
index 0dddae50c4e..5e675c10038 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js
@@ -52,6 +52,7 @@ function createComponent(options = {}) {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
suggestionsListClass: () => 'custom-class',
+ termsAsTokens: () => false,
},
stubs,
});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js
index 696483df8ef..c55721fe032 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js
@@ -51,6 +51,7 @@ function createComponent(options = {}) {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
suggestionsListClass: () => 'custom-class',
+ termsAsTokens: () => false,
},
stubs,
listeners,
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js
index c758e550ba2..db51b4a05b1 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js
@@ -49,6 +49,7 @@ function createComponent(options = {}) {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
suggestionsListClass: () => 'custom-class',
+ termsAsTokens: () => false,
},
stubs,
});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js
index 5190ab919b1..79fd527cbe3 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/release_token_spec.js
@@ -24,6 +24,7 @@ describe('ReleaseToken', () => {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
suggestionsListClass: () => 'custom-class',
+ termsAsTokens: () => false,
},
});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/user_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/user_token_spec.js
index d0a6519f16d..e4ca7dcb19a 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/user_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/user_token_spec.js
@@ -57,6 +57,7 @@ function createComponent(options = {}) {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
suggestionsListClass: () => 'custom-class',
+ termsAsTokens: () => false,
},
data() {
return { ...data };
diff --git a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js
index cd4ebe334c0..015e08ed760 100644
--- a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js
+++ b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js
@@ -1,6 +1,6 @@
import { GlAlert, GlModal, GlButton, GlSkeletonLoader } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
-import { shallowMount } from '@vue/test-utils';
+import { shallowMount, ErrorWrapper } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
@@ -41,7 +41,12 @@ describe('RunnerInstructionsModal component', () => {
let runnerPlatformsHandler;
const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
- const findAlert = () => wrapper.findComponent(GlAlert);
+ const findAlert = (variant = 'danger') => {
+ const { wrappers } = wrapper
+ .findAllComponents(GlAlert)
+ .filter((w) => w.props('variant') === variant);
+ return wrappers[0] || new ErrorWrapper();
+ };
const findModal = () => wrapper.findComponent(GlModal);
const findPlatformButtonGroup = () => wrapper.findByTestId('platform-buttons');
const findPlatformButtons = () => findPlatformButtonGroup().findAllComponents(GlButton);
@@ -84,6 +89,10 @@ describe('RunnerInstructionsModal component', () => {
expect(findAlert().exists()).toBe(false);
});
+ it('should not show deprecation alert', () => {
+ expect(findAlert('warning').exists()).toBe(false);
+ });
+
it('should contain a number of platforms buttons', () => {
expect(runnerPlatformsHandler).toHaveBeenCalledWith({});
@@ -100,6 +109,21 @@ describe('RunnerInstructionsModal component', () => {
);
});
+ describe.each`
+ glFeatures | deprecationAlertExists
+ ${{}} | ${false}
+ ${{ createRunnerWorkflowForAdmin: true }} | ${true}
+ ${{ createRunnerWorkflowForNamespace: true }} | ${true}
+ `('with features $glFeatures', ({ glFeatures, deprecationAlertExists }) => {
+ beforeEach(() => {
+ createComponent({ provide: { glFeatures } });
+ });
+
+ it(`alert is ${deprecationAlertExists ? 'shown' : 'not shown'}`, () => {
+ expect(findAlert('warning').exists()).toBe(deprecationAlertExists);
+ });
+ });
+
describe('when the modal resizes', () => {
it('to an xs viewport', async () => {
MockResizeObserver.mockResize('xs');
diff --git a/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js b/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js
index b406c9d843a..99bf391e261 100644
--- a/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js
+++ b/spec/frontend/work_items/components/notes/work_item_note_actions_spec.js
@@ -22,6 +22,7 @@ describe('Work Item Note Actions', () => {
const findDeleteNoteButton = () => wrapper.find('[data-testid="delete-note-action"]');
const findCopyLinkButton = () => wrapper.find('[data-testid="copy-link-action"]');
const findAssignUnassignButton = () => wrapper.find('[data-testid="assign-note-action"]');
+ const findReportAbuseToAdminButton = () => wrapper.find('[data-testid="abuse-note-action"]');
const addEmojiMutationResolver = jest.fn().mockResolvedValue({
data: {
@@ -39,6 +40,7 @@ describe('Work Item Note Actions', () => {
showEdit = true,
showAwardEmoji = true,
showAssignUnassign = false,
+ canReportAbuse = false,
} = {}) => {
wrapper = shallowMount(WorkItemNoteActions, {
propsData: {
@@ -47,6 +49,7 @@ describe('Work Item Note Actions', () => {
noteId,
showAwardEmoji,
showAssignUnassign,
+ canReportAbuse,
},
provide: {
glFeatures: {
@@ -195,4 +198,30 @@ describe('Work Item Note Actions', () => {
expect(wrapper.emitted('assignUser')).toEqual([[]]);
});
});
+
+ describe('report abuse to admin', () => {
+ it('should not report abuse to admin by default', () => {
+ createComponent();
+
+ expect(findReportAbuseToAdminButton().exists()).toBe(false);
+ });
+
+ it('should display assign/unassign when the props is true', () => {
+ createComponent({
+ canReportAbuse: true,
+ });
+
+ expect(findReportAbuseToAdminButton().exists()).toBe(true);
+ });
+
+ it('should emit `reportAbuse` event when report abuse action is clicked', () => {
+ createComponent({
+ canReportAbuse: true,
+ });
+
+ findReportAbuseToAdminButton().vm.$emit('click');
+
+ expect(wrapper.emitted('reportAbuse')).toEqual([[]]);
+ });
+ });
});
diff --git a/spec/frontend/work_items/components/notes/work_item_note_spec.js b/spec/frontend/work_items/components/notes/work_item_note_spec.js
index 69b7c7b0828..f8be2f5667b 100644
--- a/spec/frontend/work_items/components/notes/work_item_note_spec.js
+++ b/spec/frontend/work_items/components/notes/work_item_note_spec.js
@@ -300,5 +300,23 @@ describe('Work Item Note', () => {
});
});
});
+
+ describe('report abuse props', () => {
+ it.each`
+ currentUserId | canReportAbuse | sameAsAuthor
+ ${1} | ${false} | ${'same as'}
+ ${4} | ${true} | ${'not same as'}
+ `(
+ 'should be $canReportAbuse when the author is $sameAsAuthor as the author of the note',
+ ({ currentUserId, canReportAbuse }) => {
+ window.gon = {
+ current_user_id: currentUserId,
+ };
+ createComponent();
+
+ expect(findNoteActions().props('canReportAbuse')).toBe(canReportAbuse);
+ },
+ );
+ });
});
});
diff --git a/spec/frontend/work_items/components/work_item_detail_spec.js b/spec/frontend/work_items/components/work_item_detail_spec.js
index 46189850e09..1d164648e27 100644
--- a/spec/frontend/work_items/components/work_item_detail_spec.js
+++ b/spec/frontend/work_items/components/work_item_detail_spec.js
@@ -26,6 +26,7 @@ import WorkItemMilestone from '~/work_items/components/work_item_milestone.vue';
import WorkItemTree from '~/work_items/components/work_item_links/work_item_tree.vue';
import WorkItemNotes from '~/work_items/components/work_item_notes.vue';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
+import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
import { i18n } from '~/work_items/constants';
import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql';
import workItemDatesSubscription from '~/graphql_shared/subscriptions/work_item_dates.subscription.graphql';
@@ -43,6 +44,7 @@ import {
workItemAssigneesSubscriptionResponse,
workItemMilestoneSubscriptionResponse,
objectiveType,
+ mockWorkItemCommentNote,
} from '../mock_data';
describe('WorkItemDetail component', () => {
@@ -88,6 +90,7 @@ describe('WorkItemDetail component', () => {
const findHierarchyTree = () => wrapper.findComponent(WorkItemTree);
const findNotesWidget = () => wrapper.findComponent(WorkItemNotes);
const findModal = () => wrapper.findComponent(WorkItemDetailModal);
+ const findAbuseCategorySelector = () => wrapper.findComponent(AbuseCategorySelector);
const createComponent = ({
isModal = false,
@@ -128,6 +131,7 @@ describe('WorkItemDetail component', () => {
hasIssuableHealthStatusFeature: true,
projectNamespace: 'namespace',
fullPath: 'group/project',
+ reportAbusePath: '/report/abuse/path',
},
stubs: {
WorkItemWeight: true,
@@ -725,4 +729,30 @@ describe('WorkItemDetail component', () => {
expect(findCreatedUpdated().exists()).toBe(true);
});
+
+ describe('abuse category selector', () => {
+ beforeEach(async () => {
+ setWindowLocation('?work_item_id=2');
+ createComponent();
+ await waitForPromises();
+ });
+
+ it('should not be visible by default', () => {
+ expect(findAbuseCategorySelector().exists()).toBe(false);
+ });
+
+ it('should be visible when the work item modal emits `openReportAbuse` event', async () => {
+ findModal().vm.$emit('openReportAbuse', mockWorkItemCommentNote);
+
+ await nextTick();
+
+ expect(findAbuseCategorySelector().exists()).toBe(true);
+
+ findAbuseCategorySelector().vm.$emit('close-drawer');
+
+ await nextTick();
+
+ expect(findAbuseCategorySelector().exists()).toBe(false);
+ });
+ });
});
diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js
index efa08ced3ad..4bf7d0c57a3 100644
--- a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js
+++ b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js
@@ -12,6 +12,7 @@ import WidgetWrapper from '~/work_items/components/widget_wrapper.vue';
import WorkItemLinks from '~/work_items/components/work_item_links/work_item_links.vue';
import WorkItemLinkChild from '~/work_items/components/work_item_links/work_item_link_child.vue';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
+import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
import { FORM_TYPES } from '~/work_items/constants';
import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import changeWorkItemParentMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
@@ -25,6 +26,7 @@ import {
changeWorkItemParentMutationResponse,
workItemQueryResponse,
projectWorkItemResponse,
+ mockWorkItemCommentNote,
} from '../../mock_data';
Vue.use(VueApollo);
@@ -76,6 +78,7 @@ describe('WorkItemLinks', () => {
provide: {
projectPath: 'project/path',
hasIterationsFeature,
+ reportAbusePath: '/report/abuse/path',
},
propsData: { issuableId: 1 },
apolloProvider: mockApollo,
@@ -105,6 +108,8 @@ describe('WorkItemLinks', () => {
const findFirstWorkItemLinkChild = () => findWorkItemLinkChildItems().at(0);
const findAddLinksForm = () => wrapper.findByTestId('add-links-form');
const findChildrenCount = () => wrapper.findByTestId('children-count');
+ const findWorkItemDetailModal = () => wrapper.findComponent(WorkItemDetailModal);
+ const findAbuseCategorySelector = () => wrapper.findComponent(AbuseCategorySelector);
afterEach(() => {
mockApollo = null;
@@ -328,7 +333,7 @@ describe('WorkItemLinks', () => {
await createComponent();
expect(showModal).not.toHaveBeenCalled();
- expect(wrapper.findComponent(WorkItemDetailModal).props('workItemIid')).toBe(null);
+ expect(findWorkItemDetailModal().props('workItemIid')).toBe(null);
});
it('opens the modal if work item iid URL parameter is found in child items', async () => {
@@ -336,6 +341,31 @@ describe('WorkItemLinks', () => {
await createComponent();
expect(showModal).toHaveBeenCalled();
- expect(wrapper.findComponent(WorkItemDetailModal).props('workItemIid')).toBe('2');
+ expect(findWorkItemDetailModal().props('workItemIid')).toBe('2');
+ });
+
+ describe('abuse category selector', () => {
+ beforeEach(async () => {
+ setWindowLocation('?work_item_id=2');
+ await createComponent();
+ });
+
+ it('should not be visible by default', () => {
+ expect(findAbuseCategorySelector().exists()).toBe(false);
+ });
+
+ it('should be visible when the work item modal emits `openReportAbuse` event', async () => {
+ findWorkItemDetailModal().vm.$emit('openReportAbuse', mockWorkItemCommentNote);
+
+ await nextTick();
+
+ expect(findAbuseCategorySelector().exists()).toBe(true);
+
+ findAbuseCategorySelector().vm.$emit('close-drawer');
+
+ await nextTick();
+
+ expect(findAbuseCategorySelector().exists()).toBe(false);
+ });
});
});
diff --git a/spec/frontend/work_items/components/work_item_notes_spec.js b/spec/frontend/work_items/components/work_item_notes_spec.js
index 3cc6a9813fc..7dbf828c44a 100644
--- a/spec/frontend/work_items/components/work_item_notes_spec.js
+++ b/spec/frontend/work_items/components/work_item_notes_spec.js
@@ -97,6 +97,7 @@ describe('WorkItemNotes component', () => {
workItemIid = mockWorkItemIid,
defaultWorkItemNotesQueryHandler = workItemNotesQueryHandler,
deleteWINoteMutationHandler = deleteWorkItemNoteMutationSuccessHandler,
+ isModal = false,
} = {}) => {
wrapper = shallowMount(WorkItemNotes, {
apolloProvider: createMockApollo([
@@ -116,6 +117,8 @@ describe('WorkItemNotes component', () => {
fullPath: 'test-path',
fetchByIid,
workItemType: 'task',
+ reportAbusePath: '/report/abuse/path',
+ isModal,
},
stubs: {
GlModal: stubComponent(GlModal, { methods: { show: showModal } }),
diff --git a/spec/frontend/work_items/router_spec.js b/spec/frontend/work_items/router_spec.js
index 988fdc301de..86e890ea809 100644
--- a/spec/frontend/work_items/router_spec.js
+++ b/spec/frontend/work_items/router_spec.js
@@ -74,6 +74,7 @@ describe('Work items router', () => {
hasIterationsFeature: false,
hasOkrsFeature: false,
hasIssuableHealthStatusFeature: false,
+ reportAbusePath: '/report/abuse/path',
},
stubs: {
WorkItemWeight: true,
diff --git a/spec/helpers/sidebars_helper_spec.rb b/spec/helpers/sidebars_helper_spec.rb
index 6c0ac024944..5fbda3d77b0 100644
--- a/spec/helpers/sidebars_helper_spec.rb
+++ b/spec/helpers/sidebars_helper_spec.rb
@@ -401,7 +401,7 @@ RSpec.describe SidebarsHelper, feature_category: :navigation do
it 'returns public links and admin area link' do
expect(subject[:context_switcher_links]).to eq([
*public_link,
- { title: s_('Navigation|Admin'), link: '/admin', icon: 'admin' }
+ { title: s_('Navigation|Admin Area'), link: '/admin', icon: 'admin' }
])
end
end
diff --git a/spec/helpers/work_items_helper_spec.rb b/spec/helpers/work_items_helper_spec.rb
new file mode 100644
index 00000000000..4e1eca3d411
--- /dev/null
+++ b/spec/helpers/work_items_helper_spec.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe WorkItemsHelper, feature_category: :team_planning do
+ describe '#work_items_index_data' do
+ subject(:work_items_index_data) { helper.work_items_index_data(project) }
+
+ let_it_be(:project) { build(:project) }
+
+ it 'returns the expected data properties' do
+ expect(work_items_index_data).to include(
+ {
+ full_path: project.full_path,
+ issues_list_path: project_issues_path(project),
+ register_path: new_user_registration_path(redirect_to_referer: 'yes'),
+ sign_in_path: user_session_path(redirect_to_referer: 'yes'),
+ new_comment_template_path: profile_comment_templates_path,
+ report_abuse_path: add_category_abuse_reports_path
+ }
+ )
+ end
+ end
+end
diff --git a/spec/initializers/active_record_transaction_observer_spec.rb b/spec/initializers/active_record_transaction_observer_spec.rb
new file mode 100644
index 00000000000..a834037dce5
--- /dev/null
+++ b/spec/initializers/active_record_transaction_observer_spec.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'ActiveRecord Transaction Observer', feature_category: :application_performance do
+ def load_initializer
+ load Rails.root.join('config/initializers/active_record_transaction_observer.rb')
+ end
+
+ context 'when DBMS is available' do
+ before do
+ allow_next_instance_of(ActiveRecord::Base.connection) do |connection| # rubocop:disable Database/MultipleDatabases
+ allow(connection).to receive(:active?).and_return(true)
+ end
+ end
+
+ it 'calls Gitlab::Database::Transaction::Observer' do
+ allow(Feature::FlipperFeature).to receive(:table_exists?).and_return(true)
+
+ expect(Gitlab::Database::Transaction::Observer).to receive(:register!)
+
+ load_initializer
+ end
+
+ context 'when flipper table does not exist' do
+ before do
+ allow(Feature::FlipperFeature).to receive(:table_exists?).and_raise(ActiveRecord::NoDatabaseError)
+ end
+
+ it 'does not calls Gitlab::Database::Transaction::Observer' do
+ expect(Gitlab::Database::Transaction::Observer).not_to receive(:register!)
+
+ load_initializer
+ end
+ end
+ end
+
+ context 'when DBMS is not available' do
+ before do
+ allow(ActiveRecord::Base).to receive(:connection).and_raise(PG::ConnectionBad)
+ end
+
+ it 'does not calls Gitlab::Database::Transaction::Observer' do
+ expect(Gitlab::Database::Transaction::Observer).not_to receive(:register!)
+
+ load_initializer
+ end
+ end
+end
diff --git a/spec/lib/feature_spec.rb b/spec/lib/feature_spec.rb
index 51f21e7f46e..f0a017897c1 100644
--- a/spec/lib/feature_spec.rb
+++ b/spec/lib/feature_spec.rb
@@ -11,32 +11,6 @@ RSpec.describe Feature, stub_feature_flags: false, feature_category: :shared do
skip_feature_flags_yaml_validation
end
- describe '.feature_flags_available?' do
- it 'returns false on connection error' do
- expect(ActiveRecord::Base.connection).to receive(:active?).and_raise(PG::ConnectionBad) # rubocop:disable Database/MultipleDatabases
-
- expect(described_class.feature_flags_available?).to eq(false)
- end
-
- it 'returns false when connection is not active' do
- expect(ActiveRecord::Base.connection).to receive(:active?).and_return(false) # rubocop:disable Database/MultipleDatabases
-
- expect(described_class.feature_flags_available?).to eq(false)
- end
-
- it 'returns false when the flipper table does not exist' do
- expect(Feature::FlipperFeature).to receive(:table_exists?).and_return(false)
-
- expect(described_class.feature_flags_available?).to eq(false)
- end
-
- it 'returns false on NoDatabaseError' do
- expect(Feature::FlipperFeature).to receive(:table_exists?).and_raise(ActiveRecord::NoDatabaseError)
-
- expect(described_class.feature_flags_available?).to eq(false)
- end
- end
-
describe '.get' do
let(:feature) { double(:feature) }
let(:key) { 'my_feature' }
diff --git a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job_spec.rb b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job_spec.rb
index 31258c42b5f..8e6aea96c58 100644
--- a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job_spec.rb
+++ b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job_spec.rb
@@ -2,7 +2,8 @@
require 'spec_helper'
-RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gitlab_redis_queues, :clean_gitlab_redis_shared_state do
+RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gitlab_redis_queues, :clean_gitlab_redis_shared_state,
+ feature_category: :shared do
using RSpec::Parameterized::TableSyntax
subject(:duplicate_job) do
@@ -63,6 +64,15 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gi
it_behaves_like 'scheduling with deduplication class', 'None'
end
end
+
+ # This context is to be removed when FF `ci_pipeline_process_worker_dedup_until_executed` is removed
+ context 'when deduplication strategy is provided in the job options' do
+ before do
+ job['deduplicate'] = { 'strategy' => 'until_executed' }
+ end
+
+ it_behaves_like 'scheduling with deduplication class', 'UntilExecuted'
+ end
end
describe '#perform' do
@@ -480,6 +490,15 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob, :clean_gi
expect(duplicate_job.options).to eq(worker_options)
end
+
+ # This context is to be removed when FF `ci_pipeline_process_worker_dedup_until_executed` is removed
+ context 'when deduplication options are provided in the job options' do
+ it "returns the job's deduplication options" do
+ job['deduplicate'] = { 'options' => { 'if_deduplicated' => 'reschedule_once', 'ttl' => '60' } }
+
+ expect(duplicate_job.options).to eq({ if_deduplicated: :reschedule_once, ttl: 60 })
+ end
+ end
end
describe '#idempotent?' do
diff --git a/spec/models/bulk_imports/file_transfer/group_config_spec.rb b/spec/models/bulk_imports/file_transfer/group_config_spec.rb
index 8660114b719..e50f52c728f 100644
--- a/spec/models/bulk_imports/file_transfer/group_config_spec.rb
+++ b/spec/models/bulk_imports/file_transfer/group_config_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe BulkImports::FileTransfer::GroupConfig do
+RSpec.describe BulkImports::FileTransfer::GroupConfig, feature_category: :importers do
let_it_be(:exportable) { create(:group) }
let_it_be(:hex) { '123' }
@@ -49,4 +49,51 @@ RSpec.describe BulkImports::FileTransfer::GroupConfig do
expect(subject.relation_excluded_keys('group')).to include('owner_id')
end
end
+
+ describe '#batchable_relation?' do
+ context 'when relation is batchable' do
+ it 'returns true' do
+ expect(subject.batchable_relation?('labels')).to eq(true)
+ end
+ end
+
+ context 'when relation is not batchable' do
+ it 'returns false' do
+ expect(subject.batchable_relation?('namespace_settings')).to eq(false)
+ end
+ end
+
+ context 'when relation is not listed as portable' do
+ it 'returns false' do
+ expect(subject.batchable_relation?('foo')).to eq(false)
+ end
+ end
+ end
+
+ describe '#batchable_relations' do
+ it 'returns a list of collection associations for a group' do
+ expect(subject.batchable_relations).to include('labels', 'boards', 'milestones')
+ expect(subject.batchable_relations).not_to include('namespace_settings')
+ end
+ end
+
+ describe '#export_service_for' do
+ context 'when relation is a tree' do
+ it 'returns TreeExportService' do
+ expect(subject.export_service_for('labels')).to eq(BulkImports::TreeExportService)
+ end
+ end
+
+ context 'when relation is a file' do
+ it 'returns FileExportService' do
+ expect(subject.export_service_for('uploads')).to eq(BulkImports::FileExportService)
+ end
+ end
+
+ context 'when relation is unknown' do
+ it 'raises' do
+ expect { subject.export_service_for('foo') }.to raise_error(BulkImports::Error, 'Unsupported export relation')
+ end
+ end
+ end
end
diff --git a/spec/models/bulk_imports/file_transfer/project_config_spec.rb b/spec/models/bulk_imports/file_transfer/project_config_spec.rb
index 21fe6cfb3fa..014f624165c 100644
--- a/spec/models/bulk_imports/file_transfer/project_config_spec.rb
+++ b/spec/models/bulk_imports/file_transfer/project_config_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe BulkImports::FileTransfer::ProjectConfig do
+RSpec.describe BulkImports::FileTransfer::ProjectConfig, feature_category: :importers do
let_it_be(:exportable) { create(:project) }
let_it_be(:hex) { '123' }
@@ -109,4 +109,31 @@ RSpec.describe BulkImports::FileTransfer::ProjectConfig do
expect(subject.file_relations).to contain_exactly('uploads', 'lfs_objects', 'repository', 'design')
end
end
+
+ describe '#batchable_relation?' do
+ context 'when relation is batchable' do
+ it 'returns true' do
+ expect(subject.batchable_relation?('issues')).to eq(true)
+ end
+ end
+
+ context 'when relation is not batchable' do
+ it 'returns false' do
+ expect(subject.batchable_relation?('project_feature')).to eq(false)
+ end
+ end
+
+ context 'when relation is not listed as portable' do
+ it 'returns false' do
+ expect(subject.batchable_relation?('foo')).to eq(false)
+ end
+ end
+ end
+
+ describe '#batchable_relations' do
+ it 'returns a list of collection associations for a project' do
+ expect(subject.batchable_relations).to include('issues', 'merge_requests', 'milestones')
+ expect(subject.batchable_relations).not_to include('project_feature', 'ci_cd_settings')
+ end
+ end
end
diff --git a/spec/services/bulk_imports/batched_relation_export_service_spec.rb b/spec/services/bulk_imports/batched_relation_export_service_spec.rb
new file mode 100644
index 00000000000..c361dfe5052
--- /dev/null
+++ b/spec/services/bulk_imports/batched_relation_export_service_spec.rb
@@ -0,0 +1,104 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::BatchedRelationExportService, feature_category: :importers do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:portable) { create(:group) }
+
+ let(:relation) { 'labels' }
+ let(:jid) { '123' }
+
+ subject(:service) { described_class.new(user, portable, relation, jid) }
+
+ describe '#execute' do
+ context 'when there are batches to export' do
+ let_it_be(:label) { create(:group_label, group: portable) }
+
+ it 'marks export as started' do
+ service.execute
+
+ export = portable.bulk_import_exports.first
+
+ expect(export.reload.started?).to eq(true)
+ end
+
+ it 'removes existing batches' do
+ expect_next_instance_of(BulkImports::Export) do |export|
+ expect(export.batches).to receive(:destroy_all)
+ end
+
+ service.execute
+ end
+
+ it 'enqueues export jobs for each batch & caches batch record ids' do
+ expect(BulkImports::RelationBatchExportWorker).to receive(:perform_async)
+ expect(Gitlab::Cache::Import::Caching).to receive(:set_add)
+
+ service.execute
+ end
+
+ it 'enqueues FinishBatchedRelationExportWorker' do
+ expect(BulkImports::FinishBatchedRelationExportWorker).to receive(:perform_async)
+
+ service.execute
+ end
+
+ context 'when there are multiple batches' do
+ it 'creates a batch record for each batch of records' do
+ stub_const("#{described_class.name}::BATCH_SIZE", 1)
+
+ create_list(:group_label, 10, group: portable)
+
+ service.execute
+
+ export = portable.bulk_import_exports.first
+
+ expect(export.batches.count).to eq(11)
+ end
+ end
+ end
+
+ context 'when there are no batches to export' do
+ let(:relation) { 'milestones' }
+
+ it 'marks export as finished' do
+ service.execute
+
+ export = portable.bulk_import_exports.first
+
+ expect(export.finished?).to eq(true)
+ expect(export.batches.count).to eq(0)
+ end
+ end
+
+ context 'when exception occurs' do
+ it 'tracks exception and marks export as failed' do
+ allow_next_instance_of(BulkImports::Export) do |export|
+ allow(export).to receive(:update!).and_call_original
+
+ allow(export)
+ .to receive(:update!)
+ .with(status_event: 'finish', total_objects_count: 0, batched: true, batches_count: 0, jid: jid, error: nil)
+ .and_raise(StandardError, 'Error!')
+ end
+
+ expect(Gitlab::ErrorTracking)
+ .to receive(:track_exception)
+ .with(StandardError, portable_id: portable.id, portable_type: portable.class.name)
+
+ service.execute
+
+ export = portable.bulk_import_exports.first
+
+ expect(export.reload.failed?).to eq(true)
+ end
+ end
+ end
+
+ describe '.cache_key' do
+ it 'returns cache key given export and batch ids' do
+ expect(described_class.cache_key(1, 1)).to eq('bulk_imports/batched_relation_export/1/1')
+ end
+ end
+end
diff --git a/spec/services/bulk_imports/export_service_spec.rb b/spec/services/bulk_imports/export_service_spec.rb
index ac7514fde5b..25a4547477c 100644
--- a/spec/services/bulk_imports/export_service_spec.rb
+++ b/spec/services/bulk_imports/export_service_spec.rb
@@ -13,17 +13,36 @@ RSpec.describe BulkImports::ExportService, feature_category: :importers do
subject { described_class.new(portable: group, user: user) }
describe '#execute' do
- it 'schedules RelationExportWorker for each top level relation' do
- expect(subject).to receive(:execute).and_return(ServiceResponse.success).and_call_original
- top_level_relations = BulkImports::FileTransfer.config_for(group).portable_relations
+ let_it_be(:top_level_relations) { BulkImports::FileTransfer.config_for(group).portable_relations }
- top_level_relations.each do |relation|
- expect(BulkImports::RelationExportWorker)
- .to receive(:perform_async)
- .with(user.id, group.id, group.class.name, relation)
+ before do
+ allow(subject).to receive(:execute).and_return(ServiceResponse.success).and_call_original
+ end
+
+ context 'when export is not batched' do
+ it 'schedules RelationExportWorker for each top level relation' do
+ top_level_relations.each do |relation|
+ expect(BulkImports::RelationExportWorker)
+ .to receive(:perform_async)
+ .with(user.id, group.id, group.class.name, relation, false)
+ end
+
+ subject.execute
end
+ end
- subject.execute
+ context 'when export is batched' do
+ subject { described_class.new(portable: group, user: user, batched: true) }
+
+ it 'schedules RelationExportWorker with a `batched: true` flag' do
+ top_level_relations.each do |relation|
+ expect(BulkImports::RelationExportWorker)
+ .to receive(:perform_async)
+ .with(user.id, group.id, group.class.name, relation, true)
+ end
+
+ subject.execute
+ end
end
context 'when exception occurs' do
@@ -38,6 +57,20 @@ RSpec.describe BulkImports::ExportService, feature_category: :importers do
service.execute
end
+
+ context 'when user is not allowed to perform export' do
+ let(:another_user) { create(:user) }
+
+ it 'does not schedule RelationExportWorker' do
+ another_user = create(:user)
+ service = described_class.new(portable: group, user: another_user)
+ response = service.execute
+
+ expect(response.status).to eq(:error)
+ expect(response.message).to eq(Gitlab::ImportExport::Error)
+ expect(response.http_status).to eq(:unprocessable_entity)
+ end
+ end
end
end
end
diff --git a/spec/services/bulk_imports/file_export_service_spec.rb b/spec/services/bulk_imports/file_export_service_spec.rb
index 3c23b86ad5c..001fccb2054 100644
--- a/spec/services/bulk_imports/file_export_service_spec.rb
+++ b/spec/services/bulk_imports/file_export_service_spec.rb
@@ -5,18 +5,20 @@ require 'spec_helper'
RSpec.describe BulkImports::FileExportService, feature_category: :importers do
let_it_be(:project) { create(:project) }
+ let(:relations) do
+ {
+ 'uploads' => BulkImports::UploadsExportService,
+ 'lfs_objects' => BulkImports::LfsObjectsExportService,
+ 'repository' => BulkImports::RepositoryBundleExportService,
+ 'design' => BulkImports::RepositoryBundleExportService
+ }
+ end
+
describe '#execute' do
it 'executes export service and archives exported data for each file relation' do
- relations = {
- 'uploads' => BulkImports::UploadsExportService,
- 'lfs_objects' => BulkImports::LfsObjectsExportService,
- 'repository' => BulkImports::RepositoryBundleExportService,
- 'design' => BulkImports::RepositoryBundleExportService
- }
-
relations.each do |relation, klass|
Dir.mktmpdir do |export_path|
- service = described_class.new(project, export_path, relation)
+ service = described_class.new(project, export_path, relation, nil)
expect_next_instance_of(klass) do |service|
expect(service).to receive(:execute)
@@ -31,18 +33,58 @@ RSpec.describe BulkImports::FileExportService, feature_category: :importers do
context 'when unsupported relation is passed' do
it 'raises an error' do
- service = described_class.new(project, nil, 'unsupported')
+ service = described_class.new(project, nil, 'unsupported', nil)
expect { service.execute }.to raise_error(BulkImports::Error, 'Unsupported relation export type')
end
end
end
+ describe '#execute_batch' do
+ it 'calls execute with provided array of record ids' do
+ relations.each do |relation, klass|
+ Dir.mktmpdir do |export_path|
+ service = described_class.new(project, export_path, relation, nil)
+
+ expect_next_instance_of(klass) do |service|
+ expect(service).to receive(:execute).with({ batch_ids: [1, 2, 3] })
+ end
+
+ service.export_batch([1, 2, 3])
+ end
+ end
+ end
+ end
+
describe '#exported_filename' do
it 'returns filename of the exported file' do
- service = described_class.new(project, nil, 'uploads')
+ service = described_class.new(project, nil, 'uploads', nil)
expect(service.exported_filename).to eq('uploads.tar')
end
end
+
+ describe '#exported_objects_count' do
+ context 'when relation is a collection' do
+ it 'returns a number of exported relations' do
+ %w[uploads lfs_objects].each do |relation|
+ service = described_class.new(project, nil, relation, nil)
+
+ allow(service).to receive_message_chain(:export_service, :exported_objects_count).and_return(10)
+
+ expect(service.exported_objects_count).to eq(10)
+ end
+ end
+ end
+
+ context 'when relation is a repository' do
+ it 'returns 1' do
+ %w[repository design].each do |relation|
+ service = described_class.new(project, nil, relation, nil)
+
+ expect(service.exported_objects_count).to eq(1)
+ end
+ end
+ end
+ end
end
diff --git a/spec/services/bulk_imports/lfs_objects_export_service_spec.rb b/spec/services/bulk_imports/lfs_objects_export_service_spec.rb
index 4f721a3a259..587c99d9897 100644
--- a/spec/services/bulk_imports/lfs_objects_export_service_spec.rb
+++ b/spec/services/bulk_imports/lfs_objects_export_service_spec.rb
@@ -53,6 +53,19 @@ RSpec.describe BulkImports::LfsObjectsExportService, feature_category: :importer
)
end
+ context 'when export is batched' do
+ it 'exports only specified lfs objects' do
+ new_lfs_object = create(:lfs_object, :with_file)
+
+ project.lfs_objects << new_lfs_object
+
+ service.execute(batch_ids: [new_lfs_object.id])
+
+ expect(File).to exist(File.join(export_path, new_lfs_object.oid))
+ expect(File).not_to exist(File.join(export_path, lfs_object.oid))
+ end
+ end
+
context 'when lfs object has file on disk missing' do
it 'does not attempt to copy non-existent file' do
FileUtils.rm(lfs_object.file.path)
@@ -79,4 +92,14 @@ RSpec.describe BulkImports::LfsObjectsExportService, feature_category: :importer
end
end
end
+
+ describe '#exported_objects_count' do
+ it 'return the number of exported lfs objects' do
+ project.lfs_objects << create(:lfs_object, :with_file)
+
+ service.execute
+
+ expect(service.exported_objects_count).to eq(2)
+ end
+ end
end
diff --git a/spec/services/bulk_imports/relation_batch_export_service_spec.rb b/spec/services/bulk_imports/relation_batch_export_service_spec.rb
new file mode 100644
index 00000000000..c3abd02aff8
--- /dev/null
+++ b/spec/services/bulk_imports/relation_batch_export_service_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::RelationBatchExportService, feature_category: :importers do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:label) { create(:label, project: project) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:export) { create(:bulk_import_export, :batched, project: project) }
+ let_it_be(:batch) { create(:bulk_import_export_batch, export: export) }
+ let_it_be(:cache_key) { BulkImports::BatchedRelationExportService.cache_key(export.id, batch.id) }
+
+ subject(:service) { described_class.new(user.id, batch.id) }
+
+ before(:all) do
+ Gitlab::Cache::Import::Caching.set_add(cache_key, label.id)
+ end
+
+ after(:all) do
+ Gitlab::Cache::Import::Caching.expire(cache_key, 0)
+ end
+
+ describe '#execute' do
+ it 'exports relation batch' do
+ expect(Gitlab::Cache::Import::Caching).to receive(:values_from_set).with(cache_key).and_call_original
+
+ service.execute
+ batch.reload
+
+ expect(batch.finished?).to eq(true)
+ expect(batch.objects_count).to eq(1)
+ expect(batch.error).to be_nil
+ expect(export.upload.export_file).to be_present
+ end
+
+ it 'removes exported contents after export' do
+ double = instance_double(BulkImports::FileTransfer::ProjectConfig, export_path: 'foo')
+
+ allow(BulkImports::FileTransfer).to receive(:config_for).and_return(double)
+ allow(double).to receive(:export_service_for).and_raise(StandardError, 'Error!')
+ allow(FileUtils).to receive(:remove_entry)
+
+ expect(FileUtils).to receive(:remove_entry).with('foo')
+
+ service.execute
+ end
+
+ context 'when exception occurs' do
+ before do
+ allow(service).to receive(:gzip).and_raise(StandardError, 'Error!')
+ end
+
+ it 'marks batch as failed' do
+ expect(Gitlab::ErrorTracking)
+ .to receive(:track_exception)
+ .with(StandardError, portable_id: project.id, portable_type: 'Project')
+
+ service.execute
+ batch.reload
+
+ expect(batch.failed?).to eq(true)
+ expect(batch.objects_count).to eq(0)
+ expect(batch.error).to eq('Error!')
+ end
+ end
+ end
+end
diff --git a/spec/services/bulk_imports/relation_export_service_spec.rb b/spec/services/bulk_imports/relation_export_service_spec.rb
index bc999b0b9b3..1c050fe4143 100644
--- a/spec/services/bulk_imports/relation_export_service_spec.rb
+++ b/spec/services/bulk_imports/relation_export_service_spec.rb
@@ -35,6 +35,10 @@ RSpec.describe BulkImports::RelationExportService, feature_category: :importers
expect(export.reload.upload.export_file).to be_present
expect(export.finished?).to eq(true)
+ expect(export.batched?).to eq(false)
+ expect(export.batches_count).to eq(0)
+ expect(export.batches.count).to eq(0)
+ expect(export.total_objects_count).to eq(0)
end
it 'removes temp export files' do
@@ -133,13 +137,23 @@ RSpec.describe BulkImports::RelationExportService, feature_category: :importers
include_examples 'tracks exception', ActiveRecord::RecordInvalid
end
+ end
- context 'when user is not allowed to perform export' do
- let(:another_user) { create(:user) }
+ context 'when export was batched' do
+ let(:relation) { 'milestones' }
+ let(:export) { create(:bulk_import_export, group: group, relation: relation, batched: true, batches_count: 2) }
- subject { described_class.new(another_user, group, relation, jid) }
+ it 'removes existing batches and marks export as not batched' do
+ create(:bulk_import_export_batch, batch_number: 1, export: export)
+ create(:bulk_import_export_batch, batch_number: 2, export: export)
- include_examples 'tracks exception', Gitlab::ImportExport::Error
+ expect { described_class.new(user, group, relation, jid).execute }
+ .to change { export.reload.batches.count }
+ .from(2)
+ .to(0)
+
+ expect(export.batched?).to eq(false)
+ expect(export.batches_count).to eq(0)
end
end
end
diff --git a/spec/services/bulk_imports/tree_export_service_spec.rb b/spec/services/bulk_imports/tree_export_service_spec.rb
index fa96641f1c1..ae78858976f 100644
--- a/spec/services/bulk_imports/tree_export_service_spec.rb
+++ b/spec/services/bulk_imports/tree_export_service_spec.rb
@@ -53,4 +53,14 @@ RSpec.describe BulkImports::TreeExportService, feature_category: :importers do
end
end
end
+
+ describe '#export_batch' do
+ it 'serializes relation with specified ids' do
+ expect_next_instance_of(Gitlab::ImportExport::Json::StreamingSerializer) do |serializer|
+ expect(serializer).to receive(:serialize_relation).with(anything, batch_ids: [1, 2, 3])
+ end
+
+ subject.export_batch([1, 2, 3])
+ end
+ end
end
diff --git a/spec/services/bulk_imports/uploads_export_service_spec.rb b/spec/services/bulk_imports/uploads_export_service_spec.rb
index 8dc67b28d12..709ade4a504 100644
--- a/spec/services/bulk_imports/uploads_export_service_spec.rb
+++ b/spec/services/bulk_imports/uploads_export_service_spec.rb
@@ -3,9 +3,8 @@
require 'spec_helper'
RSpec.describe BulkImports::UploadsExportService, feature_category: :importers do
- let_it_be(:export_path) { Dir.mktmpdir }
- let_it_be(:project) { create(:project, avatar: fixture_file_upload('spec/fixtures/rails_sample.png', 'image/png')) }
-
+ let(:export_path) { Dir.mktmpdir }
+ let(:project) { create(:project, avatar: fixture_file_upload('spec/fixtures/rails_sample.png', 'image/png')) }
let!(:upload) { create(:upload, :with_file, :issuable_upload, uploader: FileUploader, model: project) }
let(:exported_filepath) { File.join(export_path, upload.secret, upload.retrieve_uploader.filename) }
@@ -23,6 +22,16 @@ RSpec.describe BulkImports::UploadsExportService, feature_category: :importers d
expect(File).to exist(exported_filepath)
end
+ context 'when export is batched' do
+ it 'exports only specified uploads' do
+ service.execute(batch_ids: [upload.id])
+
+ expect(service.exported_objects_count).to eq(1)
+ expect(File).not_to exist(File.join(export_path, 'avatar', 'rails_sample.png'))
+ expect(File).to exist(exported_filepath)
+ end
+ end
+
context 'when upload has underlying file missing' do
context 'with an upload missing its file' do
it 'does not cause errors' do
@@ -53,6 +62,16 @@ RSpec.describe BulkImports::UploadsExportService, feature_category: :importers d
}
)
+ expect(Gitlab::ErrorTracking)
+ .to receive(:log_exception)
+ .with(
+ instance_of(exception), {
+ portable_id: project.id,
+ portable_class: 'Project',
+ upload_id: project.avatar.upload.id
+ }
+ )
+
service.execute
expect(File).not_to exist(exported_filepath)
@@ -73,4 +92,12 @@ RSpec.describe BulkImports::UploadsExportService, feature_category: :importers d
end
end
end
+
+ describe '#exported_objects_count' do
+ it 'return the number of exported uploads' do
+ service.execute
+
+ expect(service.exported_objects_count).to eq(2)
+ end
+ end
end
diff --git a/spec/services/system_notes/time_tracking_service_spec.rb b/spec/services/system_notes/time_tracking_service_spec.rb
index 5cc17f55012..71228050085 100644
--- a/spec/services/system_notes/time_tracking_service_spec.rb
+++ b/spec/services/system_notes/time_tracking_service_spec.rb
@@ -37,7 +37,7 @@ RSpec.describe ::SystemNotes::TimeTrackingService, feature_category: :team_plann
end
it 'sets the correct note message' do
- expect(note.note).to eq('removed start date and removed due date')
+ expect(note.note).to eq("removed start date #{start_date.to_s(:long)} and removed due date #{due_date.to_s(:long)}")
end
end
@@ -52,7 +52,7 @@ RSpec.describe ::SystemNotes::TimeTrackingService, feature_category: :team_plann
let(:changed_dates) { { 'due_date' => [nil, due_date], 'start_date' => [start_date, nil] } }
it 'sets the correct note message' do
- expect(note.note).to eq("removed start date and changed due date to #{due_date.to_s(:long)}")
+ expect(note.note).to eq("removed start date #{start_date.to_s(:long)} and changed due date to #{due_date.to_s(:long)}")
end
end
end
@@ -80,7 +80,7 @@ RSpec.describe ::SystemNotes::TimeTrackingService, feature_category: :team_plann
let(:changed_dates) { { 'due_date' => [due_date, nil], 'start_date' => [nil, start_date] } }
it 'sets the correct note message' do
- expect(note.note).to eq("changed start date to #{start_date.to_s(:long)} and removed due date")
+ expect(note.note).to eq("changed start date to #{start_date.to_s(:long)} and removed due date #{due_date.to_s(:long)}")
end
end
end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index c3bddf1a6ae..334c709dcf8 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -311,10 +311,6 @@ RSpec.configure do |config|
# See https://docs.gitlab.com/ee/development/feature_flags/#selectively-disable-by-actor
stub_feature_flags(legacy_merge_request_state_check_for_merged_result_pipelines: false)
- # Disable the `vue_issues_dashboard` feature flag in specs as we migrate the issues
- # dashboard page to Vue. https://gitlab.com/gitlab-org/gitlab/-/issues/379025
- stub_feature_flags(vue_issues_dashboard: false)
-
allow(Gitlab::GitalyClient).to receive(:can_use_disk?).and_return(enable_rugged)
else
unstub_all_feature_flags
diff --git a/spec/support/helpers/filtered_search_helpers.rb b/spec/support/helpers/filtered_search_helpers.rb
index b07f5dcf2e1..ecc749b1e45 100644
--- a/spec/support/helpers/filtered_search_helpers.rb
+++ b/spec/support/helpers/filtered_search_helpers.rb
@@ -69,12 +69,6 @@ module FilteredSearchHelpers
filtered_search.send_keys(:enter)
end
- def init_label_search
- filtered_search.set('label:=')
- # This ensures the dropdown is shown
- expect(find('#js-dropdown-label')).not_to have_css('.filter-dropdown-loading')
- end
-
def expect_filtered_search_input_empty
expect(find('.filtered-search').value).to eq('')
end
diff --git a/spec/views/projects/issues/_related_issues.html.haml_spec.rb b/spec/views/projects/issues/_related_issues.html.haml_spec.rb
new file mode 100644
index 00000000000..0dbca032c4b
--- /dev/null
+++ b/spec/views/projects/issues/_related_issues.html.haml_spec.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'projects/issues/_related_issues.html.haml', feature_category: :team_planning do
+ let_it_be(:project) { build_stubbed(:project) }
+ let_it_be(:issue) { build_stubbed(:issue, project: project) }
+
+ context 'when current user cannot read issue link for the project' do
+ before do
+ allow(view).to receive(:can?).and_return(false)
+ end
+
+ it 'does not render the related issues root node' do
+ render
+
+ expect(rendered).not_to have_selector(".js-related-issues-root")
+ end
+ end
+
+ context 'when current user can read issue link for the project' do
+ before do
+ allow(view).to receive(:can?).and_return(true)
+
+ assign(:project, project)
+ assign(:issue, issue)
+ end
+
+ it 'adds the report abuse path as a data attribute' do
+ render
+
+ expect(rendered).to have_selector(
+ ".js-related-issues-root[data-report-abuse-path=\"#{add_category_abuse_reports_path}\"]"
+ )
+ end
+ end
+end
diff --git a/spec/workers/bulk_imports/finish_batched_relation_export_worker_spec.rb b/spec/workers/bulk_imports/finish_batched_relation_export_worker_spec.rb
new file mode 100644
index 00000000000..6fbcb267c0a
--- /dev/null
+++ b/spec/workers/bulk_imports/finish_batched_relation_export_worker_spec.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::FinishBatchedRelationExportWorker, feature_category: :importers do
+ let(:export) { create(:bulk_import_export, :started) }
+ let(:batch) { create(:bulk_import_export_batch, :finished, export: export) }
+ let(:export_id) { export.id }
+ let(:job_args) { [export_id] }
+
+ describe '#perform' do
+ it_behaves_like 'an idempotent worker' do
+ it 'marks export as finished and expires batches cache' do
+ cache_key = BulkImports::BatchedRelationExportService.cache_key(export.id, batch.id)
+
+ expect(Gitlab::Cache::Import::Caching).to receive(:expire).with(cache_key, 0)
+
+ perform_multiple(job_args)
+
+ expect(export.reload.finished?).to eq(true)
+ end
+
+ context 'when export is finished' do
+ let(:export) { create(:bulk_import_export, :finished) }
+
+ it 'returns without updating export' do
+ perform_multiple(job_args)
+
+ expect(export.reload.finished?).to eq(true)
+ end
+ end
+
+ context 'when export is failed' do
+ let(:export) { create(:bulk_import_export, :failed) }
+
+ it 'returns without updating export' do
+ perform_multiple(job_args)
+
+ expect(export.reload.failed?).to eq(true)
+ end
+ end
+
+ context 'when export is in progress' do
+ it 'reenqueues itself' do
+ create(:bulk_import_export_batch, :started, export: export)
+
+ expect(described_class).to receive(:perform_in).twice
+
+ perform_multiple(job_args)
+
+ expect(export.reload.started?).to eq(true)
+ end
+ end
+
+ context 'when export timed out' do
+ it 'marks export as failed' do
+ expect(export.reload.failed?).to eq(false)
+ expect(batch.reload.failed?).to eq(false)
+
+ export.update!(updated_at: 1.day.ago)
+
+ perform_multiple(job_args)
+
+ expect(export.reload.failed?).to eq(true)
+ expect(batch.reload.failed?).to eq(true)
+ end
+ end
+
+ context 'when export is missing' do
+ let(:export_id) { nil }
+
+ it 'returns' do
+ expect(described_class).not_to receive(:perform_in)
+
+ perform_multiple(job_args)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/workers/bulk_imports/relation_batch_export_worker_spec.rb b/spec/workers/bulk_imports/relation_batch_export_worker_spec.rb
new file mode 100644
index 00000000000..4a2c8d48742
--- /dev/null
+++ b/spec/workers/bulk_imports/relation_batch_export_worker_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::RelationBatchExportWorker, feature_category: :importers do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:batch) { create(:bulk_import_export_batch) }
+
+ let(:job_args) { [user.id, batch.id] }
+
+ describe '#perform' do
+ include_examples 'an idempotent worker' do
+ it 'executes RelationBatchExportService' do
+ service = instance_double(BulkImports::RelationBatchExportService)
+
+ expect(BulkImports::RelationBatchExportService)
+ .to receive(:new)
+ .with(user.id, batch.id)
+ .twice.and_return(service)
+ expect(service).to receive(:execute).twice
+
+ perform_multiple(job_args)
+ end
+ end
+ end
+end
diff --git a/spec/workers/bulk_imports/relation_export_worker_spec.rb b/spec/workers/bulk_imports/relation_export_worker_spec.rb
index c2f7831896b..38ef4df263e 100644
--- a/spec/workers/bulk_imports/relation_export_worker_spec.rb
+++ b/spec/workers/bulk_imports/relation_export_worker_spec.rb
@@ -4,17 +4,18 @@ require 'spec_helper'
RSpec.describe BulkImports::RelationExportWorker, feature_category: :importers do
let_it_be(:jid) { 'jid' }
- let_it_be(:relation) { 'labels' }
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
- let(:job_args) { [user.id, group.id, group.class.name, relation] }
+ let(:batched) { false }
+ let(:relation) { 'labels' }
+ let(:job_args) { [user.id, group.id, group.class.name, relation, batched] }
describe '#perform' do
include_examples 'an idempotent worker' do
context 'when export record does not exist' do
let(:another_group) { create(:group) }
- let(:job_args) { [user.id, another_group.id, another_group.class.name, relation] }
+ let(:job_args) { [user.id, another_group.id, another_group.class.name, relation, batched] }
it 'creates export record' do
another_group.add_owner(user)
@@ -26,21 +27,37 @@ RSpec.describe BulkImports::RelationExportWorker, feature_category: :importers d
end
end
- it 'executes RelationExportService' do
- group.add_owner(user)
+ shared_examples 'export service' do |export_service|
+ it 'executes export service' do
+ group.add_owner(user)
- service = instance_double(BulkImports::RelationExportService)
+ service = instance_double(export_service)
- expect(BulkImports::RelationExportService)
- .to receive(:new)
- .with(user, group, relation, anything)
- .twice
- .and_return(service)
- expect(service)
- .to receive(:execute)
- .twice
+ expect(export_service)
+ .to receive(:new)
+ .with(user, group, relation, anything)
+ .twice
+ .and_return(service)
+ expect(service).to receive(:execute).twice
- perform_multiple(job_args)
+ perform_multiple(job_args)
+ end
+ end
+
+ context 'when export is batched' do
+ let(:batched) { true }
+
+ include_examples 'export service', BulkImports::BatchedRelationExportService
+
+ context 'when relation is not batchable' do
+ let(:relation) { 'namespace_settings' }
+
+ include_examples 'export service', BulkImports::RelationExportService
+ end
+ end
+
+ context 'when export is not batched' do
+ include_examples 'export service', BulkImports::RelationExportService
end
end
end
diff --git a/spec/workers/pipeline_process_worker_spec.rb b/spec/workers/pipeline_process_worker_spec.rb
index 6c6851c51ce..1c76cdca347 100644
--- a/spec/workers/pipeline_process_worker_spec.rb
+++ b/spec/workers/pipeline_process_worker_spec.rb
@@ -5,6 +5,48 @@ require 'spec_helper'
RSpec.describe PipelineProcessWorker, feature_category: :continuous_integration do
let_it_be(:pipeline) { create(:ci_pipeline) }
+ # The two examples below are to be added when FF `ci_pipeline_process_worker_dedup_until_executed` is removed
+ # it 'has the `until_executed` deduplicate strategy' do
+ # expect(described_class.get_deduplicate_strategy).to eq(:until_executed)
+ # end
+
+ # it 'has the option to reschedule once if deduplicated and a TTL of 1 minute' do
+ # expect(described_class.get_deduplication_options).to include({ if_deduplicated: :reschedule_once, ttl: 1.minute })
+ # end
+
+ # This context is to be removed when FF `ci_pipeline_process_worker_dedup_until_executed` is removed
+ describe '#perform_async', :sidekiq_inline do
+ around do |example|
+ Sidekiq::Testing.fake! { example.run }
+ end
+
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:pipeline) { create(:ci_empty_pipeline, project: project) }
+
+ subject { described_class.perform_async(pipeline.id) }
+
+ it 'sets the deduplication settings in the job options' do
+ subject
+
+ job = described_class.jobs.last
+ expect(job['deduplicate']).to eq({ 'strategy' => 'until_executed',
+ 'options' => { 'if_deduplicated' => 'reschedule_once', 'ttl' => '60' } })
+ end
+
+ context 'when FF `ci_pipeline_process_worker_dedup_until_executed` is disabled' do
+ before do
+ stub_feature_flags(ci_pipeline_process_worker_dedup_until_executed: false)
+ end
+
+ it 'does not set the deduplication settings in the job options' do
+ subject
+
+ job = described_class.jobs.last
+ expect(job['deduplicate']).to be_nil
+ end
+ end
+ end
+
include_examples 'an idempotent worker' do
let(:pipeline) { create(:ci_pipeline, :created) }
let(:job_args) { [pipeline.id] }
diff --git a/yarn.lock b/yarn.lock
index 09e8ef8fb44..ceb4171621d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1115,10 +1115,10 @@
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-3.40.0.tgz#f1ebb2fcdbb1181550d53f0db827eca1f5060af0"
integrity sha512-9CVkIbV0VnIFfVBjWcW8+nHzpMhHhC73C9mGPEktEPfpEbaaRws2UywgDEH+C2B8Ba1QdBo/aFr68RDu2VwvfA==
-"@gitlab/ui@61.1.1":
- version "61.1.1"
- resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-61.1.1.tgz#fcd0f66e307456aacfb072d026bf9096bef43658"
- integrity sha512-dd9LqCV/6Ju7pMId0g8vbTyHYa1MavVbvB2WsJpnr+VsCPCLsl8hp9XB238kijhvfQqcExUsqwm0Plsu/nE1xQ==
+"@gitlab/ui@61.3.0":
+ version "61.3.0"
+ resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-61.3.0.tgz#5b1f9c7a01d7571150a796ca5f02fe44f9604fc4"
+ integrity sha512-jOaEGjvtrVT7IZhtu/VZNmlodGgrE/UllciNfJxzm4nbbWMHlALspFp8btt/zxGxiI33oQJLS2qTCEu0QVVZHA==
dependencies:
"@popperjs/core" "^2.11.2"
bootstrap-vue "2.23.1"