summaryrefslogtreecommitdiff
path: root/spec
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-04-09 18:09:24 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2021-04-09 18:09:24 +0000
commitb19efd72743e22fd3b340b3c2906ba113e1390de (patch)
tree6afb0cff9382cf949654608368731ed4883a0678 /spec
parent9ea69b43c3502c4c63e6d47da40786875197fcf3 (diff)
downloadgitlab-ce-b19efd72743e22fd3b340b3c2906ba113e1390de.tar.gz
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec')
-rw-r--r--spec/frontend/captcha/apollo_captcha_link_spec.js8
-rw-r--r--spec/frontend/gfm_auto_complete_spec.js35
-rw-r--r--spec/frontend/issues_list/components/issues_list_app_spec.js458
-rw-r--r--spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js123
-rw-r--r--spec/frontend/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js49
-rw-r--r--spec/frontend/pipeline_editor/graphql/resolvers_spec.js18
-rw-r--r--spec/frontend/pipeline_editor/mock_data.js14
-rw-r--r--spec/frontend/pipeline_editor/pipeline_editor_home_spec.js6
-rw-r--r--spec/frontend/pipelines/pipelines_ci_templates_spec.js33
-rw-r--r--spec/frontend/snippets/components/edit_spec.js71
-rw-r--r--spec/graphql/mutations/concerns/mutations/can_mutate_spammable_spec.rb46
-rw-r--r--spec/helpers/issues_helper_spec.rb11
-rw-r--r--spec/models/ci/build_spec.rb62
-rw-r--r--spec/models/project_spec.rb136
-rw-r--r--spec/requests/api/graphql/mutations/snippets/create_spec.rb4
-rw-r--r--spec/requests/api/graphql/mutations/snippets/update_spec.rb7
-rw-r--r--spec/services/issues/create_service_spec.rb2
-rw-r--r--spec/services/spam/spam_action_service_spec.rb57
-rw-r--r--spec/spam/concerns/has_spam_action_response_fields_spec.rb8
-rw-r--r--spec/support/shared_examples/graphql/mutations/can_mutate_spammable_examples.rb6
-rw-r--r--spec/support/shared_examples/graphql/spam_protection_shared_examples.rb85
-rw-r--r--spec/support/shared_examples/services/snippets_shared_examples.rb41
22 files changed, 980 insertions, 300 deletions
diff --git a/spec/frontend/captcha/apollo_captcha_link_spec.js b/spec/frontend/captcha/apollo_captcha_link_spec.js
index b4863d7bc19..e7ff4812ee7 100644
--- a/spec/frontend/captcha/apollo_captcha_link_spec.js
+++ b/spec/frontend/captcha/apollo_captcha_link_spec.js
@@ -44,7 +44,7 @@ describe('apolloCaptchaLink', () => {
},
errors: [
{
- message: 'Your Query was detected to be SPAM.',
+ message: 'Your Query was detected to be spam.',
path: ['user'],
locations: [{ line: 2, column: 3 }],
extensions: {
@@ -116,7 +116,7 @@ describe('apolloCaptchaLink', () => {
});
});
- it('unresolvable SPAM errors are passed through', (done) => {
+ it('unresolvable spam errors are passed through', (done) => {
setupLink(SPAM_ERROR_RESPONSE);
link.request(mockOperation()).subscribe((result) => {
expect(result).toEqual(SPAM_ERROR_RESPONSE);
@@ -127,8 +127,8 @@ describe('apolloCaptchaLink', () => {
});
});
- describe('resolvable SPAM errors', () => {
- it('re-submits request with SPAM headers if the captcha modal was solved correctly', (done) => {
+ describe('resolvable spam errors', () => {
+ it('re-submits request with spam headers if the captcha modal was solved correctly', (done) => {
waitForCaptchaToBeSolved.mockResolvedValue(CAPTCHA_RESPONSE);
setupLink(CAPTCHA_ERROR_RESPONSE, SUCCESS_RESPONSE);
link.request(mockOperation()).subscribe((result) => {
diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js
index 13dbda9cf55..5453c93eac3 100644
--- a/spec/frontend/gfm_auto_complete_spec.js
+++ b/spec/frontend/gfm_auto_complete_spec.js
@@ -665,6 +665,41 @@ describe('GfmAutoComplete', () => {
expect(GfmAutoComplete.Members.nameOrUsernameIncludes(member, query)).toBe(result);
});
});
+
+ describe('sorter', () => {
+ const query = 'c';
+
+ const items = [
+ { search: 'DougHackett elayne.krieger' },
+ { search: 'BerylHuel cherie.block' },
+ { search: 'ErlindaMayert nicolle' },
+ { search: 'Administrator root' },
+ { search: 'PhoebeSchaden salina' },
+ { search: 'CatherinTerry tommy.will' },
+ { search: 'AntoineLedner ammie' },
+ { search: 'KinaCummings robena' },
+ { search: 'CharlsieHarber xzbdulia' },
+ ];
+
+ const expected = [
+ // Members whose name/username starts with `c` are grouped first
+ { search: 'BerylHuel cherie.block' },
+ { search: 'CatherinTerry tommy.will' },
+ { search: 'CharlsieHarber xzbdulia' },
+ // Members whose name/username contains `c` are grouped second
+ { search: 'DougHackett elayne.krieger' },
+ { search: 'ErlindaMayert nicolle' },
+ { search: 'PhoebeSchaden salina' },
+ { search: 'KinaCummings robena' },
+ // Remaining members are grouped last
+ { search: 'Administrator root' },
+ { search: 'AntoineLedner ammie' },
+ ];
+
+ it('sorts by match with start of name/username, then match with any part of name/username, and maintains sort order', () => {
+ expect(GfmAutoComplete.Members.sort(query, items)).toMatchObject(expected);
+ });
+ });
});
});
diff --git a/spec/frontend/issues_list/components/issues_list_app_spec.js b/spec/frontend/issues_list/components/issues_list_app_spec.js
index 67ae644911a..476804bda12 100644
--- a/spec/frontend/issues_list/components/issues_list_app_spec.js
+++ b/spec/frontend/issues_list/components/issues_list_app_spec.js
@@ -1,5 +1,5 @@
-import { GlButton } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
+import { GlButton, GlEmptyState, GlLink } from '@gitlab/ui';
+import { mount, shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
import { TEST_HOST } from 'helpers/test_constants';
import waitForPromises from 'helpers/wait_for_promises';
@@ -28,13 +28,24 @@ describe('IssuesListApp component', () => {
let axiosMock;
let wrapper;
- const calendarPath = 'calendar/path';
- const endpoint = 'api/endpoint';
- const exportCsvPath = 'export/csv/path';
- const fullPath = 'path/to/project';
- const issuesPath = `${fullPath}/-/issues`;
- const newIssuePath = `new/issue/path`;
- const rssPath = 'rss/path';
+ const defaultProvide = {
+ calendarPath: 'calendar/path',
+ canBulkUpdate: false,
+ emptyStateSvgPath: 'empty-state.svg',
+ endpoint: 'api/endpoint',
+ exportCsvPath: 'export/csv/path',
+ fullPath: 'path/to/project',
+ hasIssues: true,
+ isSignedIn: false,
+ issuesPath: 'path/to/issues',
+ jiraIntegrationPath: 'jira/integration/path',
+ newIssuePath: 'new/issue/path',
+ rssPath: 'rss/path',
+ showImportButton: true,
+ showNewIssueLink: true,
+ signInPath: 'sign/in/path',
+ };
+
const state = 'opened';
const xPage = 1;
const xTotal = 25;
@@ -51,27 +62,27 @@ describe('IssuesListApp component', () => {
},
};
+ const findCsvImportExportButtons = () => wrapper.findComponent(CsvImportExportButtons);
+ const findGlButton = () => wrapper.findComponent(GlButton);
const findGlButtons = () => wrapper.findAllComponents(GlButton);
const findGlButtonAt = (index) => findGlButtons().at(index);
+ const findGlEmptyState = () => wrapper.findComponent(GlEmptyState);
+ const findGlLink = () => wrapper.findComponent(GlLink);
const findIssuableList = () => wrapper.findComponent(IssuableList);
- const mountComponent = ({ provide = {} } = {}) =>
- shallowMount(IssuesListApp, {
+ const mountComponent = ({ provide = {}, mountFn = shallowMount } = {}) =>
+ mountFn(IssuesListApp, {
provide: {
- calendarPath,
- endpoint,
- exportCsvPath,
- fullPath,
- issuesPath,
- newIssuePath,
- rssPath,
+ ...defaultProvide,
...provide,
},
});
beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios);
- axiosMock.onGet(endpoint).reply(200, fetchIssuesResponse.data, fetchIssuesResponse.headers);
+ axiosMock
+ .onGet(defaultProvide.endpoint)
+ .reply(200, fetchIssuesResponse.data, fetchIssuesResponse.headers);
});
afterEach(() => {
@@ -88,7 +99,7 @@ describe('IssuesListApp component', () => {
it('renders', () => {
expect(findIssuableList().props()).toMatchObject({
- namespace: fullPath,
+ namespace: defaultProvide.fullPath,
recentSearchesStorageKey: 'issues',
searchInputPlaceholder: 'Search or filter results…',
sortOptions,
@@ -96,7 +107,7 @@ describe('IssuesListApp component', () => {
tabs: IssuableListTabs,
currentTab: IssuableStates.Opened,
tabCounts,
- showPaginationControls: true,
+ showPaginationControls: false,
issuables: [],
totalItems: xTotal,
currentPage: xPage,
@@ -112,7 +123,7 @@ describe('IssuesListApp component', () => {
wrapper = mountComponent();
expect(findGlButtonAt(0).attributes()).toMatchObject({
- href: rssPath,
+ href: defaultProvide.rssPath,
icon: 'rss',
'aria-label': IssuesListApp.i18n.rssLabel,
});
@@ -122,7 +133,7 @@ describe('IssuesListApp component', () => {
wrapper = mountComponent();
expect(findGlButtonAt(1).attributes()).toMatchObject({
- href: calendarPath,
+ href: defaultProvide.calendarPath,
icon: 'calendar',
'aria-label': IssuesListApp.i18n.calendarLabel,
});
@@ -140,8 +151,8 @@ describe('IssuesListApp component', () => {
await waitForPromises();
- expect(wrapper.findComponent(CsvImportExportButtons).props()).toMatchObject({
- exportCsvPath: `${exportCsvPath}${search}`,
+ expect(findCsvImportExportButtons().props()).toMatchObject({
+ exportCsvPath: `${defaultProvide.exportCsvPath}${search}`,
issuableCount: xTotal,
});
});
@@ -153,7 +164,7 @@ describe('IssuesListApp component', () => {
expect(findGlButtonAt(2).text()).toBe('Edit issues');
});
- it('does not render when user has permissions', () => {
+ it('does not render when user does not have permissions', () => {
wrapper = mountComponent({ provide: { canBulkUpdate: false } });
expect(findGlButtons().filter((button) => button.text() === 'Edit issues')).toHaveLength(0);
@@ -175,7 +186,7 @@ describe('IssuesListApp component', () => {
wrapper = mountComponent({ provide: { showNewIssueLink: true } });
expect(findGlButtonAt(2).text()).toBe('New issue');
- expect(findGlButtonAt(2).attributes('href')).toBe(newIssuePath);
+ expect(findGlButtonAt(2).attributes('href')).toBe(defaultProvide.newIssuePath);
});
it('does not render when user does not have permissions', () => {
@@ -186,20 +197,50 @@ describe('IssuesListApp component', () => {
});
});
- describe('initial sort', () => {
- it.each(Object.keys(sortParams))('is set as %s when the url query matches', (sortKey) => {
- Object.defineProperty(window, 'location', {
- writable: true,
- value: {
- href: setUrlParams(sortParams[sortKey], TEST_HOST),
- },
+ describe('initial url params', () => {
+ describe('page', () => {
+ it('is set from the url params', () => {
+ const page = 5;
+
+ Object.defineProperty(window, 'location', {
+ writable: true,
+ value: { href: setUrlParams({ page }, TEST_HOST) },
+ });
+
+ wrapper = mountComponent();
+
+ expect(findIssuableList().props('currentPage')).toBe(page);
});
+ });
- wrapper = mountComponent();
+ describe('sort', () => {
+ it.each(Object.keys(sortParams))('is set as %s from the url params', (sortKey) => {
+ Object.defineProperty(window, 'location', {
+ writable: true,
+ value: { href: setUrlParams(sortParams[sortKey], TEST_HOST) },
+ });
- expect(findIssuableList().props()).toMatchObject({
- initialSortBy: sortKey,
- urlParams: sortParams[sortKey],
+ wrapper = mountComponent();
+
+ expect(findIssuableList().props()).toMatchObject({
+ initialSortBy: sortKey,
+ urlParams: sortParams[sortKey],
+ });
+ });
+ });
+
+ describe('state', () => {
+ it('is set from the url params', () => {
+ const initialState = IssuableStates.All;
+
+ Object.defineProperty(window, 'location', {
+ writable: true,
+ value: { href: setUrlParams({ state: initialState }, TEST_HOST) },
+ });
+
+ wrapper = mountComponent();
+
+ expect(findIssuableList().props('currentTab')).toBe(initialState);
});
});
});
@@ -221,148 +262,285 @@ describe('IssuesListApp component', () => {
);
});
- describe('when "click-tab" event is emitted by IssuableList', () => {
- beforeEach(() => {
- axiosMock.onGet(endpoint).reply(200, fetchIssuesResponse.data, {
- 'x-page': 2,
- 'x-total': xTotal,
+ describe('empty states', () => {
+ describe('when there are issues', () => {
+ describe('when search returns no results', () => {
+ beforeEach(async () => {
+ Object.defineProperty(window, 'location', {
+ writable: true,
+ value: { href: setUrlParams({ search: 'no results' }, TEST_HOST) },
+ });
+
+ wrapper = mountComponent({ provide: { hasIssues: true } });
+
+ await waitForPromises();
+ });
+
+ it('shows empty state', () => {
+ expect(findGlEmptyState().props()).toMatchObject({
+ description: IssuesListApp.i18n.noSearchResultsDescription,
+ title: IssuesListApp.i18n.noSearchResultsTitle,
+ svgPath: defaultProvide.emptyStateSvgPath,
+ });
+ });
+ });
+
+ describe('when "Open" tab has no issues', () => {
+ beforeEach(() => {
+ wrapper = mountComponent({ provide: { hasIssues: true } });
+ });
+
+ it('shows empty state', () => {
+ expect(findGlEmptyState().props()).toMatchObject({
+ description: IssuesListApp.i18n.noOpenIssuesDescription,
+ title: IssuesListApp.i18n.noOpenIssuesTitle,
+ svgPath: defaultProvide.emptyStateSvgPath,
+ });
+ });
});
- wrapper = mountComponent();
+ describe('when "Closed" tab has no issues', () => {
+ beforeEach(async () => {
+ Object.defineProperty(window, 'location', {
+ writable: true,
+ value: { href: setUrlParams({ state: IssuableStates.Closed }, TEST_HOST) },
+ });
- findIssuableList().vm.$emit('click-tab', IssuableStates.Closed);
+ wrapper = mountComponent({ provide: { hasIssues: true } });
+ });
+
+ it('shows empty state', () => {
+ expect(findGlEmptyState().props()).toMatchObject({
+ title: IssuesListApp.i18n.noClosedIssuesTitle,
+ svgPath: defaultProvide.emptyStateSvgPath,
+ });
+ });
+ });
});
- it('makes API call to filter the list by the new state and resets the page to 1', () => {
- expect(axiosMock.history.get[1].params).toMatchObject({
- page: 1,
- state: IssuableStates.Closed,
+ describe('when there are no issues', () => {
+ describe('when user is logged in', () => {
+ beforeEach(() => {
+ wrapper = mountComponent({
+ provide: { hasIssues: false, isSignedIn: true },
+ mountFn: mount,
+ });
+ });
+
+ it('shows empty state', () => {
+ expect(findGlEmptyState().props()).toMatchObject({
+ description: IssuesListApp.i18n.noIssuesSignedInDescription,
+ title: IssuesListApp.i18n.noIssuesSignedInTitle,
+ svgPath: defaultProvide.emptyStateSvgPath,
+ });
+ });
+
+ it('shows "New issue" and import/export buttons', () => {
+ expect(findGlButton().text()).toBe(IssuesListApp.i18n.newIssueLabel);
+ expect(findGlButton().attributes('href')).toBe(defaultProvide.newIssuePath);
+ expect(findCsvImportExportButtons().props()).toMatchObject({
+ exportCsvPath: defaultProvide.exportCsvPath,
+ issuableCount: 0,
+ });
+ });
+
+ it('shows Jira integration information', () => {
+ const paragraphs = wrapper.findAll('p');
+ expect(paragraphs.at(2).text()).toContain(IssuesListApp.i18n.jiraIntegrationTitle);
+ expect(paragraphs.at(3).text()).toContain(
+ 'Enable the Jira integration to view your Jira issues in GitLab.',
+ );
+ expect(paragraphs.at(4).text()).toContain(
+ IssuesListApp.i18n.jiraIntegrationSecondaryMessage,
+ );
+ expect(findGlLink().text()).toBe('Enable the Jira integration');
+ expect(findGlLink().attributes('href')).toBe(defaultProvide.jiraIntegrationPath);
+ });
+ });
+
+ describe('when user is logged out', () => {
+ beforeEach(() => {
+ wrapper = mountComponent({
+ provide: { hasIssues: false, isSignedIn: false },
+ });
+ });
+
+ it('shows empty state', () => {
+ expect(findGlEmptyState().props()).toMatchObject({
+ description: IssuesListApp.i18n.noIssuesSignedOutDescription,
+ title: IssuesListApp.i18n.noIssuesSignedOutTitle,
+ svgPath: defaultProvide.emptyStateSvgPath,
+ primaryButtonText: IssuesListApp.i18n.noIssuesSignedOutButtonText,
+ primaryButtonLink: defaultProvide.signInPath,
+ });
+ });
});
});
});
- describe('when "page-change" event is emitted by IssuableList', () => {
- const data = [{ id: 10, title: 'title', state }];
- const page = 2;
- const totalItems = 21;
+ describe('events', () => {
+ describe('when "click-tab" event is emitted by IssuableList', () => {
+ beforeEach(() => {
+ axiosMock.onGet(defaultProvide.endpoint).reply(200, fetchIssuesResponse.data, {
+ 'x-page': 2,
+ 'x-total': xTotal,
+ });
- beforeEach(async () => {
- axiosMock.onGet(endpoint).reply(200, data, {
- 'x-page': page,
- 'x-total': totalItems,
+ wrapper = mountComponent();
+
+ findIssuableList().vm.$emit('click-tab', IssuableStates.Closed);
});
- wrapper = mountComponent();
+ it('makes API call to filter the list by the new state and resets the page to 1', () => {
+ expect(axiosMock.history.get[1].params).toMatchObject({
+ page: 1,
+ state: IssuableStates.Closed,
+ });
+ });
+ });
- findIssuableList().vm.$emit('page-change', page);
+ describe('when "page-change" event is emitted by IssuableList', () => {
+ const data = [{ id: 10, title: 'title', state }];
+ const page = 2;
+ const totalItems = 21;
- await waitForPromises();
- });
+ beforeEach(async () => {
+ axiosMock.onGet(defaultProvide.endpoint).reply(200, data, {
+ 'x-page': page,
+ 'x-total': totalItems,
+ });
+
+ wrapper = mountComponent();
- it('fetches issues with expected params', () => {
- expect(axiosMock.history.get[1].params).toEqual({
- page,
- per_page: PAGE_SIZE,
- state,
- with_labels_details: true,
+ findIssuableList().vm.$emit('page-change', page);
+
+ await waitForPromises();
});
- });
- it('updates IssuableList with response data', () => {
- expect(findIssuableList().props()).toMatchObject({
- issuables: data,
- totalItems,
- currentPage: page,
- previousPage: page - 1,
- nextPage: page + 1,
- urlParams: { page, state },
+ it('fetches issues with expected params', () => {
+ expect(axiosMock.history.get[1].params).toEqual({
+ page,
+ per_page: PAGE_SIZE,
+ state,
+ with_labels_details: true,
+ });
+ });
+
+ it('updates IssuableList with response data', () => {
+ expect(findIssuableList().props()).toMatchObject({
+ issuables: data,
+ totalItems,
+ currentPage: page,
+ previousPage: page - 1,
+ nextPage: page + 1,
+ urlParams: { page, state },
+ });
});
});
- });
- describe('when "reorder" event is emitted by IssuableList', () => {
- const issueOne = { id: 1, iid: 101, title: 'Issue one' };
- const issueTwo = { id: 2, iid: 102, title: 'Issue two' };
- const issueThree = { id: 3, iid: 103, title: 'Issue three' };
- const issueFour = { id: 4, iid: 104, title: 'Issue four' };
- const issues = [issueOne, issueTwo, issueThree, issueFour];
+ describe('when "reorder" event is emitted by IssuableList', () => {
+ const issueOne = { id: 1, iid: 101, title: 'Issue one' };
+ const issueTwo = { id: 2, iid: 102, title: 'Issue two' };
+ const issueThree = { id: 3, iid: 103, title: 'Issue three' };
+ const issueFour = { id: 4, iid: 104, title: 'Issue four' };
+ const issues = [issueOne, issueTwo, issueThree, issueFour];
- beforeEach(async () => {
- axiosMock.onGet(endpoint).reply(200, issues, fetchIssuesResponse.headers);
- wrapper = mountComponent();
- await waitForPromises();
- });
+ beforeEach(async () => {
+ axiosMock.onGet(defaultProvide.endpoint).reply(200, issues, fetchIssuesResponse.headers);
+ wrapper = mountComponent();
+ await waitForPromises();
+ });
- describe('when successful', () => {
- describe.each`
- description | issueToMove | oldIndex | newIndex | moveBeforeId | moveAfterId
- ${'to the beginning of the list'} | ${issueThree} | ${2} | ${0} | ${null} | ${issueOne.id}
- ${'down the list'} | ${issueOne} | ${0} | ${1} | ${issueTwo.id} | ${issueThree.id}
- ${'up the list'} | ${issueThree} | ${2} | ${1} | ${issueOne.id} | ${issueTwo.id}
- ${'to the end of the list'} | ${issueTwo} | ${1} | ${3} | ${issueFour.id} | ${null}
- `(
- 'when moving issue $description',
- ({ issueToMove, oldIndex, newIndex, moveBeforeId, moveAfterId }) => {
- it('makes API call to reorder the issue', async () => {
- findIssuableList().vm.$emit('reorder', { oldIndex, newIndex });
-
- await waitForPromises();
-
- expect(axiosMock.history.put[0]).toMatchObject({
- url: `${issuesPath}/${issueToMove.iid}/reorder`,
- data: JSON.stringify({ move_before_id: moveBeforeId, move_after_id: moveAfterId }),
+ describe('when successful', () => {
+ describe.each`
+ description | issueToMove | oldIndex | newIndex | moveBeforeId | moveAfterId
+ ${'to the beginning of the list'} | ${issueThree} | ${2} | ${0} | ${null} | ${issueOne.id}
+ ${'down the list'} | ${issueOne} | ${0} | ${1} | ${issueTwo.id} | ${issueThree.id}
+ ${'up the list'} | ${issueThree} | ${2} | ${1} | ${issueOne.id} | ${issueTwo.id}
+ ${'to the end of the list'} | ${issueTwo} | ${1} | ${3} | ${issueFour.id} | ${null}
+ `(
+ 'when moving issue $description',
+ ({ issueToMove, oldIndex, newIndex, moveBeforeId, moveAfterId }) => {
+ it('makes API call to reorder the issue', async () => {
+ findIssuableList().vm.$emit('reorder', { oldIndex, newIndex });
+
+ await waitForPromises();
+
+ expect(axiosMock.history.put[0]).toMatchObject({
+ url: `${defaultProvide.issuesPath}/${issueToMove.iid}/reorder`,
+ data: JSON.stringify({ move_before_id: moveBeforeId, move_after_id: moveAfterId }),
+ });
});
+ },
+ );
+ });
+
+ describe('when unsuccessful', () => {
+ it('displays an error message', async () => {
+ axiosMock.onPut(`${defaultProvide.issuesPath}/${issueOne.iid}/reorder`).reply(500);
+
+ findIssuableList().vm.$emit('reorder', { oldIndex: 0, newIndex: 1 });
+
+ await waitForPromises();
+
+ expect(createFlash).toHaveBeenCalledWith({ message: IssuesListApp.i18n.reorderError });
+ });
+ });
+ });
+
+ describe('when "sort" event is emitted by IssuableList', () => {
+ it.each(Object.keys(sortParams))(
+ 'fetches issues with correct params with payload `%s`',
+ async (sortKey) => {
+ wrapper = mountComponent();
+
+ findIssuableList().vm.$emit('sort', sortKey);
+
+ await waitForPromises();
+
+ expect(axiosMock.history.get[1].params).toEqual({
+ page: xPage,
+ per_page: sortKey === RELATIVE_POSITION_ASC ? PAGE_SIZE_MANUAL : PAGE_SIZE,
+ state,
+ with_labels_details: true,
+ ...sortParams[sortKey],
});
},
);
});
- describe('when unsuccessful', () => {
- it('displays an error message', async () => {
- axiosMock.onPut(`${issuesPath}/${issueOne.iid}/reorder`).reply(500);
+ describe('when "update-legacy-bulk-edit" event is emitted by IssuableList', () => {
+ beforeEach(() => {
+ wrapper = mountComponent();
+ jest.spyOn(eventHub, '$emit');
+ });
- findIssuableList().vm.$emit('reorder', { oldIndex: 0, newIndex: 1 });
+ it('emits an "issuables:updateBulkEdit" event to the legacy bulk edit class', async () => {
+ findIssuableList().vm.$emit('update-legacy-bulk-edit');
await waitForPromises();
- expect(createFlash).toHaveBeenCalledWith({ message: IssuesListApp.i18n.reorderError });
+ expect(eventHub.$emit).toHaveBeenCalledWith('issuables:updateBulkEdit');
});
});
- });
- describe('when "sort" event is emitted by IssuableList', () => {
- it.each(Object.keys(sortParams))(
- 'fetches issues with correct params for "sort" payload `%s`',
- async (sortKey) => {
+ describe('when "filter" event is emitted by IssuableList', () => {
+ beforeEach(async () => {
wrapper = mountComponent();
- findIssuableList().vm.$emit('sort', sortKey);
+ const payload = [
+ { type: 'filtered-search-term', value: { data: 'no' } },
+ { type: 'filtered-search-term', value: { data: 'issues' } },
+ ];
- await waitForPromises();
+ findIssuableList().vm.$emit('filter', payload);
- expect(axiosMock.history.get[1].params).toEqual({
- page: xPage,
- per_page: sortKey === RELATIVE_POSITION_ASC ? PAGE_SIZE_MANUAL : PAGE_SIZE,
- state,
- with_labels_details: true,
- ...sortParams[sortKey],
- });
- },
- );
- });
-
- describe('when "update-legacy-bulk-edit" event is emitted by IssuableList', () => {
- beforeEach(() => {
- wrapper = mountComponent();
- jest.spyOn(eventHub, '$emit');
- });
-
- it('emits an "issuables:updateBulkEdit" event to the legacy bulk edit class', async () => {
- findIssuableList().vm.$emit('update-legacy-bulk-edit');
-
- await waitForPromises();
+ await waitForPromises();
+ });
- expect(eventHub.$emit).toHaveBeenCalledWith('issuables:updateBulkEdit');
+ it('makes an API call to search for issues with the search term', () => {
+ expect(axiosMock.history.get[1].params).toMatchObject({ search: 'no issues' });
+ });
});
});
});
diff --git a/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js b/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js
new file mode 100644
index 00000000000..fa937100982
--- /dev/null
+++ b/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js
@@ -0,0 +1,123 @@
+import { GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import BranchSwitcher from '~/pipeline_editor/components/file_nav/branch_switcher.vue';
+import { DEFAULT_FAILURE } from '~/pipeline_editor/constants';
+import { mockDefaultBranch, mockProjectBranches, mockProjectFullPath } from '../../mock_data';
+
+const localVue = createLocalVue();
+localVue.use(VueApollo);
+
+describe('Pipeline editor branch switcher', () => {
+ let wrapper;
+ let mockApollo;
+ let mockAvailableBranchQuery;
+
+ const createComponentWithApollo = () => {
+ const resolvers = {
+ Query: {
+ project: mockAvailableBranchQuery,
+ },
+ };
+
+ mockApollo = createMockApollo([], resolvers);
+ wrapper = shallowMount(BranchSwitcher, {
+ localVue,
+ apolloProvider: mockApollo,
+ provide: {
+ projectFullPath: mockProjectFullPath,
+ },
+ data() {
+ return {
+ currentBranch: mockDefaultBranch,
+ };
+ },
+ });
+ };
+
+ const findDropdown = () => wrapper.findComponent(GlDropdown);
+ const findDropdownItems = () => wrapper.findAll(GlDropdownItem);
+
+ beforeEach(() => {
+ mockAvailableBranchQuery = jest.fn();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('while querying', () => {
+ beforeEach(() => {
+ createComponentWithApollo();
+ });
+
+ it('does not render dropdown', () => {
+ expect(findDropdown().exists()).toBe(false);
+ });
+ });
+
+ describe('after querying', () => {
+ beforeEach(async () => {
+ mockAvailableBranchQuery.mockResolvedValue(mockProjectBranches);
+ createComponentWithApollo();
+ await waitForPromises();
+ });
+
+ it('query is called with correct variables', async () => {
+ expect(mockAvailableBranchQuery).toHaveBeenCalledTimes(1);
+ expect(mockAvailableBranchQuery).toHaveBeenCalledWith(
+ expect.anything(),
+ {
+ fullPath: mockProjectFullPath,
+ },
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it('renders list of branches', () => {
+ expect(findDropdown().exists()).toBe(true);
+ expect(findDropdownItems()).toHaveLength(mockProjectBranches.repository.branches.length);
+ });
+
+ it('renders current branch at the top of the list with a check mark', () => {
+ const firstDropdownItem = findDropdownItems().at(0);
+ const icon = firstDropdownItem.findComponent(GlIcon);
+
+ expect(firstDropdownItem.text()).toBe(mockDefaultBranch);
+ expect(icon.exists()).toBe(true);
+ expect(icon.props('name')).toBe('check');
+ });
+
+ it('does not render check mark for other branches', () => {
+ const secondDropdownItem = findDropdownItems().at(1);
+ const icon = secondDropdownItem.findComponent(GlIcon);
+
+ expect(icon.classes()).toContain('gl-visibility-hidden');
+ });
+ });
+
+ describe('on fetch error', () => {
+ beforeEach(async () => {
+ mockAvailableBranchQuery.mockResolvedValue(new Error());
+ createComponentWithApollo();
+ await waitForPromises();
+ });
+
+ it('does not render dropdown', () => {
+ expect(findDropdown().exists()).toBe(false);
+ });
+
+ it('shows an error message', () => {
+ expect(wrapper.emitted('showError')).toBeDefined();
+ expect(wrapper.emitted('showError')[0]).toEqual([
+ {
+ reasons: [wrapper.vm.$options.i18n.fetchError],
+ type: DEFAULT_FAILURE,
+ },
+ ]);
+ });
+ });
+});
diff --git a/spec/frontend/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js b/spec/frontend/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js
new file mode 100644
index 00000000000..94a0a7d14ee
--- /dev/null
+++ b/spec/frontend/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js
@@ -0,0 +1,49 @@
+import { shallowMount } from '@vue/test-utils';
+import BranchSwitcher from '~/pipeline_editor/components/file_nav/branch_switcher.vue';
+import PipelineEditorFileNav from '~/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue';
+
+describe('Pipeline editor file nav', () => {
+ let wrapper;
+ const mockProvide = {
+ glFeatures: {
+ pipelineEditorBranchSwitcher: true,
+ },
+ };
+
+ const createComponent = ({ provide = {} } = {}) => {
+ wrapper = shallowMount(PipelineEditorFileNav, {
+ provide: {
+ ...mockProvide,
+ ...provide,
+ },
+ });
+ };
+
+ const findBranchSwitcher = () => wrapper.findComponent(BranchSwitcher);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('template', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders the branch switcher', () => {
+ expect(findBranchSwitcher().exists()).toBe(true);
+ });
+ });
+
+ describe('with branch switcher feature flag OFF', () => {
+ it('does not render the branch switcher', () => {
+ createComponent({
+ provide: {
+ glFeatures: { pipelineEditorBranchSwitcher: false },
+ },
+ });
+
+ expect(findBranchSwitcher().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/pipeline_editor/graphql/resolvers_spec.js b/spec/frontend/pipeline_editor/graphql/resolvers_spec.js
index d39c0d80296..f0932fc55d3 100644
--- a/spec/frontend/pipeline_editor/graphql/resolvers_spec.js
+++ b/spec/frontend/pipeline_editor/graphql/resolvers_spec.js
@@ -9,6 +9,7 @@ import {
mockDefaultBranch,
mockLintResponse,
mockProjectFullPath,
+ mockProjectBranches,
} from '../mock_data';
jest.mock('~/api', () => {
@@ -46,6 +47,23 @@ describe('~/pipeline_editor/graphql/resolvers', () => {
await expect(result.rawData).resolves.toBe(mockCiYml);
});
});
+
+ describe('project', () => {
+ it('resolves project data with type names', async () => {
+ const result = await resolvers.Query.project();
+
+ // eslint-disable-next-line no-underscore-dangle
+ expect(result.__typename).toBe('Project');
+ });
+
+ it('resolves project with available list of branches', async () => {
+ const result = await resolvers.Query.project();
+
+ expect(result.repository.branches).toHaveLength(
+ mockProjectBranches.repository.branches.length,
+ );
+ });
+ });
});
describe('Mutation', () => {
diff --git a/spec/frontend/pipeline_editor/mock_data.js b/spec/frontend/pipeline_editor/mock_data.js
index 16d5ba0e714..7f651a42231 100644
--- a/spec/frontend/pipeline_editor/mock_data.js
+++ b/spec/frontend/pipeline_editor/mock_data.js
@@ -138,6 +138,20 @@ export const mergeUnwrappedCiConfig = (mergedConfig) => {
};
};
+export const mockProjectBranches = {
+ __typename: 'Project',
+ repository: {
+ __typename: 'Repository',
+ branches: [
+ { __typename: 'Branch', name: 'master' },
+ { __typename: 'Branch', name: 'main' },
+ { __typename: 'Branch', name: 'develop' },
+ { __typename: 'Branch', name: 'production' },
+ { __typename: 'Branch', name: 'test' },
+ ],
+ },
+};
+
export const mockProjectPipeline = {
pipeline: {
commitPath: '/-/commit/aabbccdd',
diff --git a/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js
index 43e22db1d44..a1e3d24acfa 100644
--- a/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js
+++ b/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js
@@ -2,6 +2,7 @@ import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import CommitSection from '~/pipeline_editor/components/commit/commit_section.vue';
+import PipelineEditorFileNav from '~/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue';
import PipelineEditorHeader from '~/pipeline_editor/components/header/pipeline_editor_header.vue';
import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue';
import { MERGED_TAB, VISUALIZE_TAB } from '~/pipeline_editor/constants';
@@ -27,6 +28,7 @@ describe('Pipeline editor home wrapper', () => {
const findPipelineEditorHeader = () => wrapper.findComponent(PipelineEditorHeader);
const findPipelineEditorTabs = () => wrapper.findComponent(PipelineEditorTabs);
const findCommitSection = () => wrapper.findComponent(CommitSection);
+ const findFileNav = () => wrapper.findComponent(PipelineEditorFileNav);
afterEach(() => {
wrapper.destroy();
@@ -38,6 +40,10 @@ describe('Pipeline editor home wrapper', () => {
createComponent();
});
+ it('shows the file nav', () => {
+ expect(findFileNav().exists()).toBe(true);
+ });
+
it('shows the pipeline editor header', () => {
expect(findPipelineEditorHeader().exists()).toBe(true);
});
diff --git a/spec/frontend/pipelines/pipelines_ci_templates_spec.js b/spec/frontend/pipelines/pipelines_ci_templates_spec.js
index 89f519adf7e..db2ade6f446 100644
--- a/spec/frontend/pipelines/pipelines_ci_templates_spec.js
+++ b/spec/frontend/pipelines/pipelines_ci_templates_spec.js
@@ -7,16 +7,23 @@ const addCiYmlPath = "/-/new/master?commit_message='Add%20.gitlab-ci.yml'";
describe('Pipelines CI Templates', () => {
let wrapper;
+ const GlEmoji = { template: '<img/>' };
+
const createWrapper = () => {
return shallowMount(PipelinesCiTemplate, {
provide: {
addCiYmlPath,
},
+ stubs: {
+ GlEmoji,
+ },
});
};
+ const findTestTemplateLinks = () => wrapper.findAll('[data-testid="test-template-link"]');
const findTemplateDescriptions = () => wrapper.findAll('[data-testid="template-description"]');
const findTemplateLinks = () => wrapper.findAll('[data-testid="template-link"]');
+ const findTemplateNames = () => wrapper.findAll('[data-testid="template-name"]');
const findTemplateLogos = () => wrapper.findAll('[data-testid="template-logo"]');
afterEach(() => {
@@ -24,7 +31,19 @@ describe('Pipelines CI Templates', () => {
wrapper = null;
});
- describe('renders templates', () => {
+ describe('renders test template', () => {
+ beforeEach(() => {
+ wrapper = createWrapper();
+ });
+
+ it('links to the hello world template', () => {
+ expect(findTestTemplateLinks().at(0).attributes('href')).toBe(
+ addCiYmlPath.concat('&template=Hello-World'),
+ );
+ });
+ });
+
+ describe('renders template list', () => {
beforeEach(() => {
wrapper = createWrapper();
});
@@ -37,20 +56,24 @@ describe('Pipelines CI Templates', () => {
expect(content).toContain(...keys);
});
+ it('has the correct template name', () => {
+ expect(findTemplateNames().at(0).text()).toBe('Android');
+ });
+
it('links to the correct template', () => {
- expect(findTemplateLinks().at(0).attributes('href')).toEqual(
+ expect(findTemplateLinks().at(0).attributes('href')).toBe(
addCiYmlPath.concat('&template=Android'),
);
});
it('has the description of the template', () => {
- expect(findTemplateDescriptions().at(0).text()).toEqual(
- 'Continuous deployment template to test and deploy your Android project.',
+ expect(findTemplateDescriptions().at(0).text()).toBe(
+ 'CI/CD template to test and deploy your Android project.',
);
});
it('has the right logo of the template', () => {
- expect(findTemplateLogos().at(0).attributes('src')).toEqual(
+ expect(findTemplateLogos().at(0).attributes('src')).toBe(
'/assets/illustrations/logos/android.svg',
);
});
diff --git a/spec/frontend/snippets/components/edit_spec.js b/spec/frontend/snippets/components/edit_spec.js
index 2b6d3ca8c2a..efdb52cfcd9 100644
--- a/spec/frontend/snippets/components/edit_spec.js
+++ b/spec/frontend/snippets/components/edit_spec.js
@@ -5,10 +5,9 @@ import { nextTick } from 'vue';
import VueApollo, { ApolloMutation } from 'vue-apollo';
import { useFakeDate } from 'helpers/fake_date';
import createMockApollo from 'helpers/mock_apollo_helper';
-import { stubComponent } from 'helpers/stub_component';
import waitForPromises from 'helpers/wait_for_promises';
import GetSnippetQuery from 'shared_queries/snippet/snippet.query.graphql';
-import CaptchaModal from '~/captcha/captcha_modal.vue';
+import UnsolvedCaptchaError from '~/captcha/unsolved_captcha_error';
import { deprecatedCreateFlash as Flash } from '~/flash';
import * as urlUtils from '~/lib/utils/url_utility';
import SnippetEditApp from '~/snippets/components/edit.vue';
@@ -30,9 +29,8 @@ jest.mock('~/flash');
const TEST_UPLOADED_FILES = ['foo/bar.txt', 'alpha/beta.js'];
const TEST_API_ERROR = new Error('TEST_API_ERROR');
+const TEST_CAPTCHA_ERROR = new UnsolvedCaptchaError();
const TEST_MUTATION_ERROR = 'Test mutation error';
-const TEST_CAPTCHA_RESPONSE = 'i-got-a-captcha';
-const TEST_CAPTCHA_SITE_KEY = 'abc123';
const TEST_ACTIONS = {
NO_CONTENT: merge({}, testEntries.created.diff, { content: '' }),
NO_PATH: merge({}, testEntries.created.diff, { filePath: '' }),
@@ -59,9 +57,6 @@ const createMutationResponse = (key, obj = {}) => ({
__typename: 'Snippet',
webUrl: TEST_WEB_URL,
},
- spamLogId: null,
- needsCaptchaResponse: false,
- captchaSiteKey: null,
},
obj,
),
@@ -71,13 +66,6 @@ const createMutationResponse = (key, obj = {}) => ({
const createMutationResponseWithErrors = (key) =>
createMutationResponse(key, { errors: [TEST_MUTATION_ERROR] });
-const createMutationResponseWithRecaptcha = (key) =>
- createMutationResponse(key, {
- errors: ['ignored captcha error message'],
- needsCaptchaResponse: true,
- captchaSiteKey: TEST_CAPTCHA_SITE_KEY,
- });
-
const getApiData = ({
id,
title = '',
@@ -126,7 +114,6 @@ describe('Snippet Edit app', () => {
});
const findBlobActions = () => wrapper.find(SnippetBlobActionsEdit);
- const findCaptchaModal = () => wrapper.find(CaptchaModal);
const findSubmitButton = () => wrapper.find('[data-testid="snippet-submit-btn"]');
const findCancelButton = () => wrapper.find('[data-testid="snippet-cancel-btn"]');
const hasDisabledSubmit = () => Boolean(findSubmitButton().attributes('disabled'));
@@ -159,7 +146,6 @@ describe('Snippet Edit app', () => {
stubs: {
ApolloMutation,
FormFooterActions,
- CaptchaModal: stubComponent(CaptchaModal),
},
provide: {
selectedLevel,
@@ -209,7 +195,6 @@ describe('Snippet Edit app', () => {
});
it('should render components', () => {
- expect(wrapper.find(CaptchaModal).exists()).toBe(true);
expect(wrapper.find(TitleField).exists()).toBe(true);
expect(wrapper.find(SnippetDescriptionEdit).exists()).toBe(true);
expect(wrapper.find(SnippetVisibilityEdit).exists()).toBe(true);
@@ -338,10 +323,10 @@ describe('Snippet Edit app', () => {
},
);
- describe('with apollo network error', () => {
+ describe.each([TEST_API_ERROR, TEST_CAPTCHA_ERROR])('with apollo network error', (error) => {
beforeEach(async () => {
jest.spyOn(console, 'error').mockImplementation();
- mutateSpy.mockRejectedValue(TEST_API_ERROR);
+ mutateSpy.mockRejectedValue(error);
await createComponentAndSubmit();
});
@@ -353,7 +338,7 @@ describe('Snippet Edit app', () => {
it('should flash', () => {
// Apollo automatically wraps the resolver's error in a NetworkError
expect(Flash).toHaveBeenCalledWith(
- `Can't update snippet: Network error: ${TEST_API_ERROR.message}`,
+ `Can't update snippet: Network error: ${error.message}`,
);
});
@@ -363,54 +348,10 @@ describe('Snippet Edit app', () => {
// eslint-disable-next-line no-console
expect(console.error).toHaveBeenCalledWith(
'[gitlab] unexpected error while updating snippet',
- expect.objectContaining({ message: `Network error: ${TEST_API_ERROR.message}` }),
+ expect.objectContaining({ message: `Network error: ${error.message}` }),
);
});
});
-
- describe('when needsCaptchaResponse is true', () => {
- let modal;
-
- beforeEach(async () => {
- mutateSpy
- .mockResolvedValueOnce(createMutationResponseWithRecaptcha('updateSnippet'))
- .mockResolvedValueOnce(createMutationResponseWithErrors('updateSnippet'));
-
- await createComponentAndSubmit();
-
- modal = findCaptchaModal();
-
- mutateSpy.mockClear();
- });
-
- it('should display captcha modal', () => {
- expect(urlUtils.redirectTo).not.toHaveBeenCalled();
- expect(modal.props()).toEqual({
- needsCaptchaResponse: true,
- captchaSiteKey: TEST_CAPTCHA_SITE_KEY,
- });
- });
-
- describe.each`
- response | expectedCalls
- ${null} | ${[]}
- ${TEST_CAPTCHA_RESPONSE} | ${[['updateSnippet', { input: { ...getApiData(createSnippet()), captchaResponse: TEST_CAPTCHA_RESPONSE } }]]}
- `('when captcha response is $response', ({ response, expectedCalls }) => {
- beforeEach(async () => {
- modal.vm.$emit('receivedCaptchaResponse', response);
-
- await nextTick();
- });
-
- it('sets needsCaptchaResponse to false', () => {
- expect(modal.props('needsCaptchaResponse')).toEqual(false);
- });
-
- it(`expected to call times = ${expectedCalls.length}`, () => {
- expect(mutateSpy.mock.calls).toEqual(expectedCalls);
- });
- });
- });
});
});
diff --git a/spec/graphql/mutations/concerns/mutations/can_mutate_spammable_spec.rb b/spec/graphql/mutations/concerns/mutations/can_mutate_spammable_spec.rb
deleted file mode 100644
index 8d1fce406fa..00000000000
--- a/spec/graphql/mutations/concerns/mutations/can_mutate_spammable_spec.rb
+++ /dev/null
@@ -1,46 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Mutations::CanMutateSpammable do
- let(:mutation_class) do
- Class.new(Mutations::BaseMutation) do
- include Mutations::CanMutateSpammable
- end
- end
-
- let(:request) { double(:request) }
- let(:query) { double(:query, schema: GitlabSchema) }
- let(:context) { GraphQL::Query::Context.new(query: query, object: nil, values: { request: request }) }
-
- subject(:mutation) { mutation_class.new(object: nil, context: context, field: nil) }
-
- describe '#additional_spam_params' do
- it 'returns additional spam-related params' do
- expect(subject.send(:additional_spam_params)).to eq({ api: true, request: request })
- end
- end
-
- describe '#with_spam_action_fields' do
- let(:spam_log) { double(:spam_log, id: 1) }
- let(:spammable) { double(:spammable, spam?: true, render_recaptcha?: true, spam_log: spam_log) }
-
- before do
- allow(Gitlab::CurrentSettings).to receive(:recaptcha_site_key) { 'abc123' }
- end
-
- it 'merges in spam action fields from spammable' do
- result = subject.send(:with_spam_action_response_fields, spammable) do
- { other_field: true }
- end
- expect(result)
- .to eq({
- spam: true,
- needs_captcha_response: true,
- spam_log_id: 1,
- captcha_site_key: 'abc123',
- other_field: true
- })
- end
- end
-end
diff --git a/spec/helpers/issues_helper_spec.rb b/spec/helpers/issues_helper_spec.rb
index b5b2ca1dcd3..21a01f349b5 100644
--- a/spec/helpers/issues_helper_spec.rb
+++ b/spec/helpers/issues_helper_spec.rb
@@ -288,24 +288,31 @@ RSpec.describe IssuesHelper do
allow(helper).to receive(:current_user).and_return(current_user)
allow(helper).to receive(:finder).and_return(finder)
allow(helper).to receive(:can?).and_return(true)
- allow(helper).to receive(:url_for).and_return('#')
+ allow(helper).to receive(:image_path).and_return('#')
allow(helper).to receive(:import_csv_namespace_project_issues_path).and_return('#')
+ allow(helper).to receive(:url_for).and_return('#')
expected = {
calendar_path: '#',
can_bulk_update: 'true',
can_edit: 'true',
+ can_import_issues: 'true',
email: current_user&.notification_email,
+ empty_state_svg_path: '#',
endpoint: expose_path(api_v4_projects_issues_path(id: project.id)),
export_csv_path: export_csv_project_issues_path(project),
full_path: project.full_path,
+ has_issues: project_issues(project).exists?.to_s,
import_csv_issues_path: '#',
+ is_signed_in: current_user.present?.to_s,
issues_path: project_issues_path(project),
+ jira_integration_path: help_page_url('user/project/integrations/jira', anchor: 'view-jira-issues'),
max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes),
new_issue_path: new_project_issue_path(project, issue: { assignee_id: finder.assignee.id, milestone_id: finder.milestones.first.id }),
project_import_jira_path: project_import_jira_path(project),
rss_path: '#',
- show_new_issue_link: 'true'
+ show_new_issue_link: 'true',
+ sign_in_path: new_user_session_path
}
expect(helper.issues_list_data(project, current_user, finder)).to include(expected)
diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb
index 76e71264ade..a736d001138 100644
--- a/spec/models/ci/build_spec.rb
+++ b/spec/models/ci/build_spec.rb
@@ -585,6 +585,68 @@ RSpec.describe Ci::Build do
is_expected.to be_falsey
end
end
+
+ context 'with runners_cached_states feature flag enabled' do
+ before do
+ stub_feature_flags(runners_cached_states: true)
+ end
+
+ it 'caches the result in Redis' do
+ expect(Rails.cache).to receive(:fetch).with(['has-online-runners', build.id], expires_in: 1.minute)
+
+ build.any_runners_online?
+ end
+ end
+
+ context 'with runners_cached_states feature flag disabled' do
+ before do
+ stub_feature_flags(runners_cached_states: false)
+ end
+
+ it 'does not cache' do
+ expect(Rails.cache).not_to receive(:fetch).with(['has-online-runners', build.id], expires_in: 1.minute)
+
+ build.any_runners_online?
+ end
+ end
+ end
+
+ describe '#any_runners_available?' do
+ subject { build.any_runners_available? }
+
+ context 'when no runners' do
+ it { is_expected.to be_falsey }
+ end
+
+ context 'when there are runners' do
+ let!(:runner) { create(:ci_runner, :project, projects: [build.project]) }
+
+ it { is_expected.to be_truthy }
+ end
+
+ context 'with runners_cached_states feature flag enabled' do
+ before do
+ stub_feature_flags(runners_cached_states: true)
+ end
+
+ it 'caches the result in Redis' do
+ expect(Rails.cache).to receive(:fetch).with(['has-available-runners', build.project.id], expires_in: 1.minute)
+
+ build.any_runners_available?
+ end
+ end
+
+ context 'with runners_cached_states feature flag disabled' do
+ before do
+ stub_feature_flags(runners_cached_states: false)
+ end
+
+ it 'does not cache' do
+ expect(Rails.cache).not_to receive(:fetch).with(['has-available-runners', build.project.id], expires_in: 1.minute)
+
+ build.any_runners_available?
+ end
+ end
end
describe '#artifacts?' do
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index 01132e18745..b964a18e148 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -1629,6 +1629,8 @@ RSpec.describe Project, factory_default: :keep do
end
describe '#any_active_runners?' do
+ subject { project.any_active_runners? }
+
context 'shared runners' do
let(:project) { create(:project, shared_runners_enabled: shared_runners_enabled) }
let(:specific_runner) { create(:ci_runner, :project, projects: [project]) }
@@ -1638,19 +1640,19 @@ RSpec.describe Project, factory_default: :keep do
let(:shared_runners_enabled) { false }
it 'has no runners available' do
- expect(project.any_active_runners?).to be_falsey
+ is_expected.to be_falsey
end
it 'has a specific runner' do
specific_runner
- expect(project.any_active_runners?).to be_truthy
+ is_expected.to be_truthy
end
it 'has a shared runner, but they are prohibited to use' do
shared_runner
- expect(project.any_active_runners?).to be_falsey
+ is_expected.to be_falsey
end
it 'checks the presence of specific runner' do
@@ -1672,7 +1674,7 @@ RSpec.describe Project, factory_default: :keep do
it 'has a shared runner' do
shared_runner
- expect(project.any_active_runners?).to be_truthy
+ is_expected.to be_truthy
end
it 'checks the presence of shared runner' do
@@ -1698,13 +1700,13 @@ RSpec.describe Project, factory_default: :keep do
let(:group_runners_enabled) { false }
it 'has no runners available' do
- expect(project.any_active_runners?).to be_falsey
+ is_expected.to be_falsey
end
it 'has a group runner, but they are prohibited to use' do
group_runner
- expect(project.any_active_runners?).to be_falsey
+ is_expected.to be_falsey
end
end
@@ -1714,7 +1716,7 @@ RSpec.describe Project, factory_default: :keep do
it 'has a group runner' do
group_runner
- expect(project.any_active_runners?).to be_truthy
+ is_expected.to be_truthy
end
it 'checks the presence of group runner' do
@@ -1732,6 +1734,126 @@ RSpec.describe Project, factory_default: :keep do
end
end
+ describe '#any_online_runners?' do
+ subject { project.any_online_runners? }
+
+ context 'shared runners' do
+ let(:project) { create(:project, shared_runners_enabled: shared_runners_enabled) }
+ let(:specific_runner) { create(:ci_runner, :project, :online, projects: [project]) }
+ let(:shared_runner) { create(:ci_runner, :instance, :online) }
+ let(:offline_runner) { create(:ci_runner, :instance) }
+
+ context 'for shared runners disabled' do
+ let(:shared_runners_enabled) { false }
+
+ it 'has no runners available' do
+ is_expected.to be_falsey
+ end
+
+ it 'has a specific runner' do
+ specific_runner
+
+ is_expected.to be_truthy
+ end
+
+ it 'has a shared runner, but they are prohibited to use' do
+ shared_runner
+
+ is_expected.to be_falsey
+ end
+
+ it 'checks the presence of specific runner' do
+ specific_runner
+
+ expect(project.any_online_runners? { |runner| runner == specific_runner }).to be_truthy
+ end
+
+ it 'returns false if match cannot be found' do
+ specific_runner
+
+ expect(project.any_online_runners? { false }).to be_falsey
+ end
+
+ it 'returns false if runner is offline' do
+ offline_runner
+
+ is_expected.to be_falsey
+ end
+ end
+
+ context 'for shared runners enabled' do
+ let(:shared_runners_enabled) { true }
+
+ it 'has a shared runner' do
+ shared_runner
+
+ is_expected.to be_truthy
+ end
+
+ it 'checks the presence of shared runner' do
+ shared_runner
+
+ expect(project.any_online_runners? { |runner| runner == shared_runner }).to be_truthy
+ end
+
+ it 'returns false if match cannot be found' do
+ shared_runner
+
+ expect(project.any_online_runners? { false }).to be_falsey
+ end
+ end
+ end
+
+ context 'group runners' do
+ let(:project) { create(:project, group_runners_enabled: group_runners_enabled) }
+ let(:group) { create(:group, projects: [project]) }
+ let(:group_runner) { create(:ci_runner, :group, :online, groups: [group]) }
+ let(:offline_runner) { create(:ci_runner, :group, groups: [group]) }
+
+ context 'for group runners disabled' do
+ let(:group_runners_enabled) { false }
+
+ it 'has no runners available' do
+ is_expected.to be_falsey
+ end
+
+ it 'has a group runner, but they are prohibited to use' do
+ group_runner
+
+ is_expected.to be_falsey
+ end
+ end
+
+ context 'for group runners enabled' do
+ let(:group_runners_enabled) { true }
+
+ it 'has a group runner' do
+ group_runner
+
+ is_expected.to be_truthy
+ end
+
+ it 'has an offline group runner' do
+ offline_runner
+
+ is_expected.to be_falsey
+ end
+
+ it 'checks the presence of group runner' do
+ group_runner
+
+ expect(project.any_online_runners? { |runner| runner == group_runner }).to be_truthy
+ end
+
+ it 'returns false if match cannot be found' do
+ group_runner
+
+ expect(project.any_online_runners? { false }).to be_falsey
+ end
+ end
+ end
+ end
+
describe '#shared_runners' do
let!(:runner) { create(:ci_runner, :instance) }
diff --git a/spec/requests/api/graphql/mutations/snippets/create_spec.rb b/spec/requests/api/graphql/mutations/snippets/create_spec.rb
index 1c2260070ec..d944c9e9e57 100644
--- a/spec/requests/api/graphql/mutations/snippets/create_spec.rb
+++ b/spec/requests/api/graphql/mutations/snippets/create_spec.rb
@@ -211,5 +211,9 @@ RSpec.describe 'Creating a Snippet' do
end
end
end
+
+ it_behaves_like 'has spam protection' do
+ let(:mutation_class) { ::Mutations::Snippets::Create }
+ end
end
end
diff --git a/spec/requests/api/graphql/mutations/snippets/update_spec.rb b/spec/requests/api/graphql/mutations/snippets/update_spec.rb
index 43dc8d8bc44..28ab593526a 100644
--- a/spec/requests/api/graphql/mutations/snippets/update_spec.rb
+++ b/spec/requests/api/graphql/mutations/snippets/update_spec.rb
@@ -157,6 +157,9 @@ RSpec.describe 'Updating a Snippet' do
it_behaves_like 'graphql update actions'
it_behaves_like 'when the snippet is not found'
it_behaves_like 'snippet edit usage data counters'
+ it_behaves_like 'has spam protection' do
+ let(:mutation_class) { ::Mutations::Snippets::Update }
+ end
end
describe 'ProjectSnippet' do
@@ -201,6 +204,10 @@ RSpec.describe 'Updating a Snippet' do
end
it_behaves_like 'snippet edit usage data counters'
+
+ it_behaves_like 'has spam protection' do
+ let(:mutation_class) { ::Mutations::Snippets::Update }
+ end
end
it_behaves_like 'when the snippet is not found'
diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb
index a1ca5ad3c2f..d362f4efb7c 100644
--- a/spec/services/issues/create_service_spec.rb
+++ b/spec/services/issues/create_service_spec.rb
@@ -461,7 +461,7 @@ RSpec.describe Issues::CreateService do
end
context 'checking spam' do
- let(:request) { double(:request) }
+ let(:request) { double(:request, headers: nil) }
let(:api) { true }
let(:captcha_response) { 'abc123' }
let(:spam_log_id) { 1 }
diff --git a/spec/services/spam/spam_action_service_spec.rb b/spec/services/spam/spam_action_service_spec.rb
index 371923f1518..e8ac826df1c 100644
--- a/spec/services/spam/spam_action_service_spec.rb
+++ b/spec/services/spam/spam_action_service_spec.rb
@@ -5,6 +5,8 @@ require 'spec_helper'
RSpec.describe Spam::SpamActionService do
include_context 'includes Spam constants'
+ let(:request) { double(:request, env: env, headers: {}) }
+ let(:issue) { create(:issue, project: project, author: user) }
let(:fake_ip) { '1.2.3.4' }
let(:fake_user_agent) { 'fake-user-agent' }
let(:fake_referrer) { 'fake-http-referrer' }
@@ -14,11 +16,8 @@ RSpec.describe Spam::SpamActionService do
'HTTP_REFERRER' => fake_referrer }
end
- let(:request) { double(:request, env: env) }
-
let_it_be(:project) { create(:project, :public) }
let_it_be(:user) { create(:user) }
- let(:issue) { create(:issue, project: project, author: user) }
before do
issue.spam = false
@@ -48,7 +47,7 @@ RSpec.describe Spam::SpamActionService do
shared_examples 'creates a spam log' do
it do
- expect { subject }.to change { SpamLog.count }.by(1)
+ expect { subject }.to change(SpamLog, :count).by(1)
new_spam_log = SpamLog.last
expect(new_spam_log.user_id).to eq(user.id)
@@ -62,7 +61,7 @@ RSpec.describe Spam::SpamActionService do
end
describe '#execute' do
- let(:request) { double(:request, env: env) }
+ let(:request) { double(:request, env: env, headers: nil) }
let(:fake_captcha_verification_service) { double(:captcha_verification_service) }
let(:fake_verdict_service) { double(:spam_verdict_service) }
let(:allowlisted) { false }
@@ -70,7 +69,7 @@ RSpec.describe Spam::SpamActionService do
let(:captcha_response) { 'abc123' }
let(:spam_log_id) { existing_spam_log.id }
let(:spam_params) do
- Spam::SpamActionService.filter_spam_params!(
+ ::Spam::SpamParams.new(
api: api,
captcha_response: captcha_response,
spam_log_id: spam_log_id
@@ -111,10 +110,30 @@ RSpec.describe Spam::SpamActionService do
allow(Spam::SpamVerdictService).to receive(:new).with(verdict_service_args).and_return(fake_verdict_service)
end
+ context 'when the captcha params are passed in the headers' do
+ let(:request) { double(:request, env: env, headers: headers) }
+ let(:spam_params) { Spam::SpamActionService.filter_spam_params!({ api: api }, request) }
+ let(:headers) do
+ {
+ 'X-GitLab-Captcha-Response' => captcha_response,
+ 'X-GitLab-Spam-Log-Id' => spam_log_id
+ }
+ end
+
+ it 'extracts the headers correctly' do
+ expect(fake_captcha_verification_service)
+ .to receive(:execute).with(captcha_response: captcha_response, request: request).and_return(true)
+ expect(SpamLog)
+ .to receive(:verify_recaptcha!).with(user_id: user.id, id: spam_log_id)
+
+ subject
+ end
+ end
+
context 'when captcha response verification returns true' do
before do
- expect(fake_captcha_verification_service)
- .to receive(:execute).with(captcha_response: captcha_response, request: request) { true }
+ allow(fake_captcha_verification_service)
+ .to receive(:execute).with(captcha_response: captcha_response, request: request).and_return(true)
end
it "doesn't check with the SpamVerdictService" do
@@ -136,8 +155,8 @@ RSpec.describe Spam::SpamActionService do
context 'when captcha response verification returns false' do
before do
- expect(fake_captcha_verification_service)
- .to receive(:execute).with(captcha_response: captcha_response, request: request) { false }
+ allow(fake_captcha_verification_service)
+ .to receive(:execute).with(captcha_response: captcha_response, request: request).and_return(false)
end
context 'when spammable attributes have not changed' do
@@ -146,21 +165,20 @@ RSpec.describe Spam::SpamActionService do
end
it 'does not create a spam log' do
- expect { subject }
- .not_to change { SpamLog.count }
+ expect { subject }.not_to change(SpamLog, :count)
end
end
context 'when spammable attributes have changed' do
let(:expected_service_check_response_message) do
- /check Issue spammable model for any errors or captcha requirement/
+ /Check Issue spammable model for any errors or CAPTCHA requirement/
end
before do
- issue.description = 'SPAM!'
+ issue.description = 'Lovely Spam! Wonderful Spam!'
end
- context 'if allowlisted' do
+ context 'when allowlisted' do
let(:allowlisted) { true }
it 'does not perform spam check' do
@@ -229,7 +247,7 @@ RSpec.describe Spam::SpamActionService do
response = subject
expect(response.message).to match(expected_service_check_response_message)
- expect(issue.needs_recaptcha?).to be_truthy
+ expect(issue).to be_needs_recaptcha
end
end
@@ -253,8 +271,7 @@ RSpec.describe Spam::SpamActionService do
end
it 'does not create a spam log' do
- expect { subject }
- .not_to change { SpamLog.count }
+ expect { subject }.not_to change(SpamLog, :count)
end
it 'clears spam flags' do
@@ -264,9 +281,9 @@ RSpec.describe Spam::SpamActionService do
end
end
- context 'spam verdict service options' do
+ context 'with spam verdict service options' do
before do
- allow(fake_verdict_service).to receive(:execute) { ALLOW }
+ allow(fake_verdict_service).to receive(:execute).and_return(ALLOW)
end
context 'when the request is nil' do
diff --git a/spec/spam/concerns/has_spam_action_response_fields_spec.rb b/spec/spam/concerns/has_spam_action_response_fields_spec.rb
index 4d5f8d9d431..9752f6a0b69 100644
--- a/spec/spam/concerns/has_spam_action_response_fields_spec.rb
+++ b/spec/spam/concerns/has_spam_action_response_fields_spec.rb
@@ -19,16 +19,12 @@ RSpec.describe Spam::Concerns::HasSpamActionResponseFields do
end
it 'merges in spam action fields from spammable' do
- result = subject.send(:with_spam_action_response_fields, spammable) do
- { other_field: true }
- end
- expect(result)
+ expect(subject.spam_action_response_fields(spammable))
.to eq({
spam: true,
needs_captcha_response: true,
spam_log_id: 1,
- captcha_site_key: recaptcha_site_key,
- other_field: true
+ captcha_site_key: recaptcha_site_key
})
end
end
diff --git a/spec/support/shared_examples/graphql/mutations/can_mutate_spammable_examples.rb b/spec/support/shared_examples/graphql/mutations/can_mutate_spammable_examples.rb
index bb4270d7db6..fc795012ce7 100644
--- a/spec/support/shared_examples/graphql/mutations/can_mutate_spammable_examples.rb
+++ b/spec/support/shared_examples/graphql/mutations/can_mutate_spammable_examples.rb
@@ -21,13 +21,13 @@ RSpec.shared_examples 'a mutation which can mutate a spammable' do
end
end
- describe "#with_spam_action_response_fields" do
+ describe "#spam_action_response_fields" do
it 'resolves with spam action fields' do
subject
# NOTE: We do not need to assert on the specific values of spam action fields here, we only need
- # to verify that #with_spam_action_response_fields was invoked and that the fields are present in the
- # response. The specific behavior of #with_spam_action_response_fields is covered in the
+ # to verify that #spam_action_response_fields was invoked and that the fields are present in the
+ # response. The specific behavior of #spam_action_response_fields is covered in the
# HasSpamActionResponseFields unit tests.
expect(mutation_response.keys)
.to include('spam', 'spamLogId', 'needsCaptchaResponse', 'captchaSiteKey')
diff --git a/spec/support/shared_examples/graphql/spam_protection_shared_examples.rb b/spec/support/shared_examples/graphql/spam_protection_shared_examples.rb
new file mode 100644
index 00000000000..8fb89a4f80e
--- /dev/null
+++ b/spec/support/shared_examples/graphql/spam_protection_shared_examples.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.shared_examples 'has spam protection' do
+ include AfterNextHelpers
+
+ describe '#check_spam_action_response!' do
+ let(:variables) { nil }
+ let(:headers) { {} }
+ let(:spam_log_id) { 123 }
+ let(:captcha_site_key) { 'abc123' }
+
+ def send_request
+ post_graphql_mutation(mutation, current_user: current_user)
+ end
+
+ before do
+ allow_next(mutation_class).to receive(:spam_action_response_fields).and_return(
+ spam: spam,
+ needs_captcha_response: render_captcha,
+ spam_log_id: spam_log_id,
+ captcha_site_key: captcha_site_key
+ )
+ end
+
+ context 'when the object is spam (DISALLOW)' do
+ shared_examples 'disallow response' do
+ it 'informs the client that the request was denied as spam' do
+ send_request
+
+ expect(graphql_errors)
+ .to contain_exactly a_hash_including('message' => ::Mutations::SpamProtection::SPAM_DISALLOWED_MESSAGE)
+ expect(graphql_errors)
+ .to contain_exactly a_hash_including('extensions' => { "spam" => true })
+ end
+ end
+
+ let(:spam) { true }
+
+ context 'and no CAPTCHA is available' do
+ let(:render_captcha) { false }
+
+ it_behaves_like 'disallow response'
+ end
+
+ context 'and a CAPTCHA is required' do
+ let(:render_captcha) { true }
+
+ it_behaves_like 'disallow response'
+ end
+ end
+
+ context 'when the object is not spam (CONDITIONAL ALLOW)' do
+ let(:spam) { false }
+
+ context 'and no CAPTCHA is required' do
+ let(:render_captcha) { false }
+
+ it 'does not return a to-level error' do
+ send_request
+
+ expect(graphql_errors).to be_blank
+ end
+ end
+
+ context 'and a CAPTCHA is required' do
+ let(:render_captcha) { true }
+
+ it 'informs the client that the request may be retried after solving the CAPTCHA' do
+ send_request
+
+ expect(graphql_errors)
+ .to contain_exactly a_hash_including('message' => ::Mutations::SpamProtection::NEEDS_CAPTCHA_RESPONSE_MESSAGE)
+ expect(graphql_errors)
+ .to contain_exactly a_hash_including('extensions' => {
+ "captcha_site_key" => captcha_site_key,
+ "needs_captcha_response" => true,
+ "spam_log_id" => spam_log_id
+ })
+ end
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/services/snippets_shared_examples.rb b/spec/support/shared_examples/services/snippets_shared_examples.rb
index 10add3a7299..0c4db7ded69 100644
--- a/spec/support/shared_examples/services/snippets_shared_examples.rb
+++ b/spec/support/shared_examples/services/snippets_shared_examples.rb
@@ -1,7 +1,8 @@
# frozen_string_literal: true
RSpec.shared_examples 'checking spam' do
- let(:request) { double(:request) }
+ let(:request) { double(:request, headers: headers) }
+ let(:headers) { nil }
let(:api) { true }
let(:captcha_response) { 'abc123' }
let(:spam_log_id) { 1 }
@@ -44,6 +45,44 @@ RSpec.shared_examples 'checking spam' do
subject
end
+ context 'when CAPTCHA arguments are passed in the headers' do
+ let(:headers) do
+ {
+ 'X-GitLab-Spam-Log-Id' => spam_log_id,
+ 'X-GitLab-Captcha-Response' => captcha_response
+ }
+ end
+
+ let(:extra_opts) do
+ {
+ request: request,
+ api: api,
+ disable_spam_action_service: disable_spam_action_service
+ }
+ end
+
+ it 'executes the SpamActionService correctly' do
+ spam_params = Spam::SpamParams.new(
+ api: api,
+ captcha_response: captcha_response,
+ spam_log_id: spam_log_id
+ )
+ expect_next_instance_of(
+ Spam::SpamActionService,
+ {
+ spammable: kind_of(Snippet),
+ request: request,
+ user: an_instance_of(User),
+ action: action
+ }
+ ) do |instance|
+ expect(instance).to receive(:execute).with(spam_params: spam_params)
+ end
+
+ subject
+ end
+ end
+
context 'when spam action service is disabled' do
let(:disable_spam_action_service) { true }