diff --git a/spec/frontend/contributors/component/contributors_spec.js b/spec/frontend/contributors/component/contributors_spec.js
index 2f0b5719326..c14bbc2f9f9 100644
--- a/spec/frontend/contributors/component/contributors_spec.js
+++ b/spec/frontend/contributors/component/contributors_spec.js
@@ -1,21 +1,29 @@
-import { mount } from '@vue/test-utils';
-import { GlLoadingIcon } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import Vue, { nextTick } from 'vue';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
import ContributorsCharts from '~/contributors/components/contributors.vue';
import { createStore } from '~/contributors/stores';
import axios from '~/lib/utils/axios_utils';
+import { visitUrl } from '~/lib/utils/url_utility';
+import RefSelector from '~/ref/components/ref_selector.vue';
+import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants';
+
+jest.mock('~/lib/utils/url_utility', () => ({
+ visitUrl: jest.fn(),
+}));
let wrapper;
let mock;
let store;
const Component = Vue.extend(ContributorsCharts);
-const endpoint = 'contributors';
+const endpoint = 'contributors/-/graphs';
const branch = 'main';
const chartData = [
{ author_name: 'John', author_email: 'jawnnypoo@gmail.com', date: '2019-05-05' },
{ author_name: 'John', author_email: 'jawnnypoo@gmail.com', date: '2019-03-03' },
];
+const projectId = '23';
+const commitsPath = 'some/path';
function factory() {
mock = new MockAdapter(axios);
@@ -23,19 +31,27 @@ function factory() {
mock.onGet().reply(200, chartData);
store = createStore();
- wrapper = mount(Component, {
+ wrapper = mountExtended(Component, {
propsData: {
endpoint,
branch,
+ projectId,
+ commitsPath,
},
stubs: {
GlLoadingIcon: true,
GlAreaChart: true,
+ RefSelector: true,
},
store,
});
}
+const findLoadingIcon = () => wrapper.findByTestId('loading-app-icon');
+const findRefSelector = () => wrapper.findComponent(RefSelector);
+const findHistoryButton = () => wrapper.findByTestId('history-button');
+const findContributorsCharts = () => wrapper.findByTestId('contributors-charts');
+
describe('Contributors charts', () => {
beforeEach(() => {
factory();
@@ -53,15 +69,46 @@ describe('Contributors charts', () => {
it('should display loader whiled loading data', async () => {
wrapper.vm.$store.state.loading = true;
await nextTick();
- expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
+ expect(findLoadingIcon().exists()).toBe(true);
});
- it('should render charts when loading completed and there is chart data', async () => {
+ it('should render charts and a RefSelector when loading completed and there is chart data', async () => {
wrapper.vm.$store.state.loading = false;
wrapper.vm.$store.state.chartData = chartData;
await nextTick();
- expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(false);
- expect(wrapper.find('.contributors-charts').exists()).toBe(true);
+
+ expect(findLoadingIcon().exists()).toBe(false);
+ expect(findRefSelector().exists()).toBe(true);
+ expect(findRefSelector().props()).toMatchObject({
+ enabledRefTypes: [REF_TYPE_BRANCHES, REF_TYPE_TAGS],
+ value: branch,
+ projectId,
+ translations: { dropdownHeader: 'Switch branch/tag' },
+ useSymbolicRefNames: false,
+ state: true,
+ name: '',
+ });
+ expect(findContributorsCharts().exists()).toBe(true);
expect(wrapper.element).toMatchSnapshot();
});
+
+ it('should have a history button with a set href attribute', async () => {
+ wrapper.vm.$store.state.loading = false;
+ wrapper.vm.$store.state.chartData = chartData;
+ await nextTick();
+
+ const historyButton = findHistoryButton();
+ expect(historyButton.exists()).toBe(true);
+ expect(historyButton.attributes('href')).toBe(commitsPath);
+ });
+
+ it('visits a URL when clicking on a branch/tag', async () => {
+ wrapper.vm.$store.state.loading = false;
+ wrapper.vm.$store.state.chartData = chartData;
+ await nextTick();
+
+ findRefSelector().vm.$emit('input', branch);
+
+ expect(visitUrl).toHaveBeenCalledWith(`${endpoint}/${branch}`);
+ });
});
diff --git a/spec/frontend/projects/settings/components/default_branch_selector_spec.js b/spec/frontend/projects/settings/components/default_branch_selector_spec.js
index bfbf3e234f4..ca9a72663d2 100644
--- a/spec/frontend/projects/settings/components/default_branch_selector_spec.js
+++ b/spec/frontend/projects/settings/components/default_branch_selector_spec.js
@@ -34,6 +34,7 @@ describe('projects/settings/components/default_branch_selector', () => {
projectId,
refType: null,
state: true,
+ toggleButtonClass: null,
translations: {
dropdownHeader: expect.any(String),
searchPlaceholder: expect.any(String),
diff --git a/spec/frontend/ref/components/ref_selector_spec.js b/spec/frontend/ref/components/ref_selector_spec.js
index e12659a0eff..ec14d8bacb2 100644
--- a/spec/frontend/ref/components/ref_selector_spec.js
+++ b/spec/frontend/ref/components/ref_selector_spec.js
@@ -715,7 +715,7 @@ describe('Ref selector component', () => {
describe('validation state', () => {
const invalidClass = 'gl-inset-border-1-red-500!';
const isInvalidClassApplied = () =>
- wrapper.findComponent(GlDropdown).props('toggleClass')[invalidClass];
+ wrapper.findComponent(GlDropdown).props('toggleClass')[0][invalidClass];
describe('valid state', () => {
describe('when the state prop is not provided', () => {
diff --git a/spec/frontend/usage_quotas/components/usage_quotas_app_spec.js b/spec/frontend/usage_quotas/components/usage_quotas_app_spec.js
new file mode 100644
index 00000000000..cb70ea4e72d
--- /dev/null
+++ b/spec/frontend/usage_quotas/components/usage_quotas_app_spec.js
@@ -0,0 +1,39 @@
+import { GlSprintf } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import UsageQuotasApp from '~/usage_quotas/components/usage_quotas_app.vue';
+import { USAGE_QUOTAS_TITLE } from '~/usage_quotas/constants';
+import { defaultProvide } from '../mock_data';
+
+describe('UsageQuotasApp', () => {
+ let wrapper;
+
+ const createComponent = ({ provide = {} } = {}) => {
+ wrapper = shallowMountExtended(UsageQuotasApp, {
+ provide: {
+ ...defaultProvide,
+ ...provide,
+ },
+ stubs: {
+ GlSprintf,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findSubTitle = () => wrapper.findByTestId('usage-quotas-page-subtitle');
+
+ it('renders the view title', () => {
+ expect(wrapper.text()).toContain(USAGE_QUOTAS_TITLE);
+ });
+
+ it('renders the view subtitle', () => {
+ expect(findSubTitle().text()).toContain(defaultProvide.namespaceName);
+ });
+});
diff --git a/spec/frontend/usage_quotas/mock_data.js b/spec/frontend/usage_quotas/mock_data.js
new file mode 100644
index 00000000000..a9d2a7ad1db
--- /dev/null
+++ b/spec/frontend/usage_quotas/mock_data.js
@@ -0,0 +1,3 @@
+export const defaultProvide = {
+ namespaceName: 'Group 1',
+};
diff --git a/spec/frontend/vue_shared/components/entity_select/group_select_spec.js b/spec/frontend/vue_shared/components/entity_select/group_select_spec.js
index 2c94b34971d..b9479b0d51d 100644
--- a/spec/frontend/vue_shared/components/entity_select/group_select_spec.js
+++ b/spec/frontend/vue_shared/components/entity_select/group_select_spec.js
@@ -6,8 +6,8 @@ import { HTTP_STATUS_INTERNAL_SERVER_ERROR } from '~/lib/utils/http_status';
import GroupSelect from '~/vue_shared/components/entity_select/group_select.vue';
import EntitySelect from '~/vue_shared/components/entity_select/entity_select.vue';
import {
- TOGGLE_TEXT,
- HEADER_TEXT,
+ GROUP_TOGGLE_TEXT,
+ GROUP_HEADER_TEXT,
FETCH_GROUPS_ERROR,
FETCH_GROUP_ERROR,
} from '~/vue_shared/components/entity_select/constants';
@@ -74,8 +74,8 @@ describe('GroupSelect', () => {
${'label'} | ${label}
${'inputName'} | ${inputName}
${'inputId'} | ${inputId}
- ${'defaultToggleText'} | ${TOGGLE_TEXT}
- ${'headerText'} | ${HEADER_TEXT}
+ ${'defaultToggleText'} | ${GROUP_TOGGLE_TEXT}
+ ${'headerText'} | ${GROUP_HEADER_TEXT}
`('passes the $prop prop to entity-select', ({ prop, expectedValue }) => {
expect(findEntitySelect().props(prop)).toBe(expectedValue);
});
diff --git a/spec/frontend/vue_shared/components/entity_select/project_select_spec.js b/spec/frontend/vue_shared/components/entity_select/project_select_spec.js
new file mode 100644
index 00000000000..ef5fa78f5cc
--- /dev/null
+++ b/spec/frontend/vue_shared/components/entity_select/project_select_spec.js
@@ -0,0 +1,152 @@
+import { GlCollapsibleListbox } from '@gitlab/ui';
+import MockAdapter from 'axios-mock-adapter';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import axios from '~/lib/utils/axios_utils';
+import { HTTP_STATUS_OK, HTTP_STATUS_INTERNAL_SERVER_ERROR } from '~/lib/utils/http_status';
+import ProjectSelect from '~/vue_shared/components/entity_select/project_select.vue';
+import EntitySelect from '~/vue_shared/components/entity_select/entity_select.vue';
+import {
+ PROJECT_TOGGLE_TEXT,
+ PROJECT_HEADER_TEXT,
+ FETCH_PROJECTS_ERROR,
+ FETCH_PROJECT_ERROR,
+} from '~/vue_shared/components/entity_select/constants';
+import waitForPromises from 'helpers/wait_for_promises';
+
+describe('ProjectSelect', () => {
+ let wrapper;
+ let mock;
+
+ // Stubs
+ const GlAlert = {
+ template: '
',
+ };
+
+ // Props
+ const label = 'label';
+ const inputName = 'inputName';
+ const inputId = 'inputId';
+ const groupId = '22';
+
+ // Mocks
+ const apiVersion = 'v4';
+ const projectMock = {
+ name_with_namespace: 'selectedProject',
+ id: '1',
+ };
+ const groupProjectEndpoint = `/api/${apiVersion}/groups/${groupId}/projects.json`;
+ const projectEndpoint = `/api/${apiVersion}/projects/${projectMock.id}`;
+
+ // Finders
+ const findListbox = () => wrapper.findComponent(GlCollapsibleListbox);
+ const findEntitySelect = () => wrapper.findComponent(EntitySelect);
+ const findAlert = () => wrapper.findComponent(GlAlert);
+
+ // Helpers
+ const createComponent = ({ props = {} } = {}) => {
+ wrapper = mountExtended(ProjectSelect, {
+ propsData: {
+ label,
+ inputName,
+ inputId,
+ groupId,
+ ...props,
+ },
+ stubs: {
+ GlAlert,
+ EntitySelect,
+ },
+ });
+ };
+ const openListbox = () => findListbox().vm.$emit('shown');
+
+ beforeAll(() => {
+ gon.api_version = apiVersion;
+ });
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('entity_select props', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it.each`
+ prop | expectedValue
+ ${'label'} | ${label}
+ ${'inputName'} | ${inputName}
+ ${'inputId'} | ${inputId}
+ ${'defaultToggleText'} | ${PROJECT_TOGGLE_TEXT}
+ ${'headerText'} | ${PROJECT_HEADER_TEXT}
+ `('passes the $prop prop to entity-select', ({ prop, expectedValue }) => {
+ expect(findEntitySelect().props(prop)).toBe(expectedValue);
+ });
+ });
+
+ describe('on mount', () => {
+ it('fetches projects when the listbox is opened', async () => {
+ createComponent();
+ await waitForPromises();
+
+ expect(mock.history.get).toHaveLength(0);
+
+ openListbox();
+ await waitForPromises();
+
+ expect(mock.history.get).toHaveLength(1);
+ expect(mock.history.get[0].url).toBe(groupProjectEndpoint);
+ expect(mock.history.get[0].params).toEqual({
+ include_subgroups: false,
+ order_by: 'similarity',
+ per_page: 20,
+ search: '',
+ simple: true,
+ with_shared: true,
+ });
+ });
+
+ describe('with an initial selection', () => {
+ it("fetches the initially selected value's name", async () => {
+ mock.onGet(projectEndpoint).reply(HTTP_STATUS_OK, projectMock);
+ createComponent({ props: { initialSelection: projectMock.id } });
+ await waitForPromises();
+
+ expect(mock.history.get).toHaveLength(1);
+ expect(findListbox().props('toggleText')).toBe(projectMock.name_with_namespace);
+ });
+
+ it('show an error if fetching the individual project fails', async () => {
+ mock
+ .onGet(groupProjectEndpoint)
+ .reply(200, [{ full_name: 'notTheSelectedProject', id: '2' }]);
+ mock.onGet(projectEndpoint).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR);
+ createComponent({ props: { initialSelection: projectMock.id } });
+
+ expect(findAlert().exists()).toBe(false);
+
+ await waitForPromises();
+
+ expect(findAlert().exists()).toBe(true);
+ expect(findAlert().text()).toBe(FETCH_PROJECT_ERROR);
+ });
+ });
+ });
+
+ it('shows an error when fetching projects fails', async () => {
+ mock.onGet(groupProjectEndpoint).reply(HTTP_STATUS_INTERNAL_SERVER_ERROR);
+ createComponent();
+ openListbox();
+ expect(findAlert().exists()).toBe(false);
+
+ await waitForPromises();
+
+ expect(findAlert().exists()).toBe(true);
+ expect(findAlert().text()).toBe(FETCH_PROJECTS_ERROR);
+ });
+});
diff --git a/spec/helpers/todos_helper_spec.rb b/spec/helpers/todos_helper_spec.rb
index fcdb41eb4af..26951b0c1e7 100644
--- a/spec/helpers/todos_helper_spec.rb
+++ b/spec/helpers/todos_helper_spec.rb
@@ -40,7 +40,7 @@ RSpec.describe TodosHelper do
end
let_it_be(:group_todo) do
- create(:todo, target: group)
+ create(:todo, target: group, group: group, project: nil, user: user)
end
let_it_be(:project_access_request_todo) do
@@ -435,4 +435,21 @@ RSpec.describe TodosHelper do
it { expect(result).to match("Due #{l(Date.tomorrow, format: Date::DATE_FORMATS[:medium])}") }
end
end
+
+ describe '#todo_parent_path' do
+ context 'when todo resource parent is a group' do
+ subject(:result) { helper.todo_parent_path(group_todo) }
+
+ it { expect(result).to eq(group_todo.group.name) }
+ end
+
+ context 'when todo resource parent is not a group' do
+ it 'returns project title with namespace' do
+ result = helper.todo_parent_path(project_access_request_todo)
+
+ expect(result).to include(project_access_request_todo.project.name)
+ expect(result).to include(project_access_request_todo.project.namespace.human_name)
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/analytics/cycle_analytics/base_query_builder_spec.rb b/spec/lib/gitlab/analytics/cycle_analytics/base_query_builder_spec.rb
index 271022e7c55..e83ee0c6b75 100644
--- a/spec/lib/gitlab/analytics/cycle_analytics/base_query_builder_spec.rb
+++ b/spec/lib/gitlab/analytics/cycle_analytics/base_query_builder_spec.rb
@@ -25,6 +25,14 @@ RSpec.describe Gitlab::Analytics::CycleAnalytics::BaseQueryBuilder do
freeze_time
end
+ context 'when an unknown parent class is given' do
+ it 'raises error' do
+ stage = instance_double('Analytics::CycleAnalytics::Stage', parent: Issue.new)
+
+ expect { described_class.new(stage: stage) }.to raise_error(/unknown parent_class: Issue/)
+ end
+ end
+
describe 'date range parameters' do
context 'when filters by only the `from` parameter' do
before do
diff --git a/spec/lib/gitlab/database/load_balancing/transaction_leaking_spec.rb b/spec/lib/gitlab/database/load_balancing/transaction_leaking_spec.rb
index 1eb077fe6ca..56fbaef031d 100644
--- a/spec/lib/gitlab/database/load_balancing/transaction_leaking_spec.rb
+++ b/spec/lib/gitlab/database/load_balancing/transaction_leaking_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Load balancer behavior with errors inside a transaction', :redis, :delete do
+RSpec.describe 'Load balancer behavior with errors inside a transaction', :redis, :delete, feature_category: :database do # rubocop:disable Layout/LineLength
include StubENV
let(:model) { ActiveRecord::Base }
let(:db_host) { model.connection_pool.db_config.host }
@@ -55,50 +55,8 @@ RSpec.describe 'Load balancer behavior with errors inside a transaction', :redis
conn.execute("INSERT INTO #{test_table_name} (value) VALUES (2)")
end
- context 'with the PREVENT_LOAD_BALANCER_RETRIES_IN_TRANSACTION environment variable not set' do
- it 'logs a warning when violating transaction semantics with writes' do
- conn = model.connection
-
- expect(::Gitlab::Database::LoadBalancing::Logger).to receive(:warn).with(hash_including(event: :transaction_leak))
- expect(::Gitlab::Database::LoadBalancing::Logger).to receive(:warn).with(hash_including(event: :read_write_retry))
-
- conn.transaction do
- expect(conn).to be_transaction_open
-
- execute(conn)
-
- expect(conn).not_to be_transaction_open
- end
-
- values = conn.execute("SELECT value FROM #{test_table_name}").to_a.map { |row| row['value'] }
- expect(values).to contain_exactly(2) # Does not include 1 because the transaction was aborted and leaked
- end
-
- it 'does not log a warning when no transaction is open to be leaked' do
- conn = model.connection
-
- expect(::Gitlab::Database::LoadBalancing::Logger)
- .not_to receive(:warn).with(hash_including(event: :transaction_leak))
- expect(::Gitlab::Database::LoadBalancing::Logger)
- .to receive(:warn).with(hash_including(event: :read_write_retry))
-
- expect(conn).not_to be_transaction_open
-
- execute(conn)
-
- expect(conn).not_to be_transaction_open
-
- values = conn.execute("SELECT value FROM #{test_table_name}").to_a.map { |row| row['value'] }
- expect(values).to contain_exactly(1, 2) # Includes both rows because there was no transaction to roll back
- end
- end
-
- context 'with the PREVENT_LOAD_BALANCER_RETRIES_IN_TRANSACTION environment variable set' do
- before do
- stub_env('PREVENT_LOAD_BALANCER_RETRIES_IN_TRANSACTION' => '1')
- end
-
- it 'raises an exception when a retry would occur during a transaction' do
+ context 'in a transaction' do
+ it 'raises an exception when a retry would occur' do
expect(::Gitlab::Database::LoadBalancing::Logger)
.not_to receive(:warn).with(hash_including(event: :transaction_leak))
@@ -108,8 +66,10 @@ RSpec.describe 'Load balancer behavior with errors inside a transaction', :redis
end
end.to raise_error(ActiveRecord::StatementInvalid) { |e| expect(e.cause).to be_a(PG::ConnectionBad) }
end
+ end
- it 'retries when not in a transaction' do
+ context 'without a transaction' do
+ it 'retries' do
expect(::Gitlab::Database::LoadBalancing::Logger)
.not_to receive(:warn).with(hash_including(event: :transaction_leak))
expect(::Gitlab::Database::LoadBalancing::Logger)
diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb
index f196db709b7..7f838e0caf9 100644
--- a/spec/mailers/notify_spec.rb
+++ b/spec/mailers/notify_spec.rb
@@ -1401,6 +1401,7 @@ RSpec.describe Notify do
context 'for service desk issues' do
before do
+ stub_feature_flags(service_desk_custom_email: false)
issue.update!(external_author: 'service.desk@example.com')
issue.issue_email_participants.create!(email: 'service.desk@example.com')
end
@@ -1411,6 +1412,7 @@ RSpec.describe Notify do
it_behaves_like 'an unsubscribeable thread'
it_behaves_like 'appearance header and footer enabled'
it_behaves_like 'appearance header and footer not enabled'
+ it_behaves_like 'a mail with default delivery method'
it 'has the correct recipient' do
is_expected.to deliver_to('service.desk@example.com')
@@ -1444,6 +1446,41 @@ RSpec.describe Notify do
expect_sender(User.support_bot)
end
end
+
+ context 'when service_desk_custom_email is active' do
+ before do
+ stub_feature_flags(service_desk_custom_email: true)
+ end
+
+ it_behaves_like 'a mail with default delivery method'
+
+ it 'uses service bot name by default' do
+ expect_sender(User.support_bot)
+ end
+
+ context 'when custom email is enabled' do
+ let_it_be(:settings) do
+ create(
+ :service_desk_setting,
+ project: project,
+ custom_email_enabled: true,
+ custom_email: 'supersupport@example.com',
+ custom_email_smtp_address: 'smtp.example.com',
+ custom_email_smtp_port: 587,
+ custom_email_smtp_username: 'supersupport@example.com',
+ custom_email_smtp_password: 'supersecret'
+ )
+ end
+
+ it 'uses custom email and service bot name in "from" header' do
+ expect_sender(User.support_bot, sender_email: 'supersupport@example.com')
+ end
+
+ it 'uses SMTP delivery method and has correct settings' do
+ expect_service_desk_custom_email_delivery_options(settings)
+ end
+ end
+ end
end
describe 'new note email' do
@@ -1454,6 +1491,7 @@ RSpec.describe Notify do
it_behaves_like 'an unsubscribeable thread'
it_behaves_like 'appearance header and footer enabled'
it_behaves_like 'appearance header and footer not enabled'
+ it_behaves_like 'a mail with default delivery method'
it 'has the correct recipient' do
is_expected.to deliver_to('service.desk@example.com')
@@ -1469,6 +1507,41 @@ RSpec.describe Notify do
is_expected.to have_body_text(first_note.note)
end
end
+
+ context 'when service_desk_custom_email is active' do
+ before do
+ stub_feature_flags(service_desk_custom_email: true)
+ end
+
+ it_behaves_like 'a mail with default delivery method'
+
+ it 'uses author\'s name in "from" header' do
+ expect_sender(first_note.author)
+ end
+
+ context 'when custom email is enabled' do
+ let_it_be(:settings) do
+ create(
+ :service_desk_setting,
+ project: project,
+ custom_email_enabled: true,
+ custom_email: 'supersupport@example.com',
+ custom_email_smtp_address: 'smtp.example.com',
+ custom_email_smtp_port: 587,
+ custom_email_smtp_username: 'supersupport@example.com',
+ custom_email_smtp_password: 'supersecret'
+ )
+ end
+
+ it 'uses custom email and author\'s name in "from" header' do
+ expect_sender(first_note.author, sender_email: project.service_desk_setting.custom_email)
+ end
+
+ it 'uses SMTP delivery method and has correct settings' do
+ expect_service_desk_custom_email_delivery_options(settings)
+ end
+ end
+ end
end
end
end
@@ -2271,9 +2344,20 @@ RSpec.describe Notify do
end
end
- def expect_sender(user)
+ def expect_sender(user, sender_email: nil)
sender = subject.header[:from].addrs[0]
expect(sender.display_name).to eq("#{user.name} (@#{user.username})")
- expect(sender.address).to eq(gitlab_sender)
+ expect(sender.address).to eq(sender_email.presence || gitlab_sender)
+ end
+
+ def expect_service_desk_custom_email_delivery_options(service_desk_setting)
+ expect(subject.delivery_method).to be_a Mail::SMTP
+ expect(subject.delivery_method.settings).to include(
+ address: service_desk_setting.custom_email_smtp_address,
+ port: service_desk_setting.custom_email_smtp_port,
+ user_name: service_desk_setting.custom_email_smtp_username,
+ password: service_desk_setting.custom_email_smtp_password,
+ domain: service_desk_setting.custom_email.split('@').last
+ )
end
end
diff --git a/spec/migrations/20230117114739_clear_duplicate_jobs_cookies_spec.rb b/spec/migrations/20230117114739_clear_duplicate_jobs_cookies_spec.rb
new file mode 100644
index 00000000000..5c572b49d3d
--- /dev/null
+++ b/spec/migrations/20230117114739_clear_duplicate_jobs_cookies_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe ClearDuplicateJobsCookies, :migration, feature_category: :redis do
+ def with_redis(&block)
+ Gitlab::Redis::Queues.with(&block)
+ end
+
+ it 'deletes duplicate jobs cookies' do
+ delete = ['resque:gitlab:duplicate:blabla:1:cookie:v2', 'resque:gitlab:duplicate:foobar:2:cookie:v2']
+ keep = ['resque:gitlab:duplicate:something', 'something:cookie:v2']
+ with_redis { |r| (delete + keep).each { |key| r.set(key, 'value') } }
+
+ expect(with_redis { |r| r.exists(delete + keep) }).to eq(4)
+
+ migrate!
+
+ expect(with_redis { |r| r.exists(delete) }).to eq(0)
+ expect(with_redis { |r| r.exists(keep) }).to eq(2)
+ end
+end
diff --git a/spec/models/analytics/cycle_analytics/stage_event_hash_spec.rb b/spec/models/analytics/cycle_analytics/stage_event_hash_spec.rb
index ffddaf1e1b2..a24f237fa9d 100644
--- a/spec/models/analytics/cycle_analytics/stage_event_hash_spec.rb
+++ b/spec/models/analytics/cycle_analytics/stage_event_hash_spec.rb
@@ -8,6 +8,7 @@ RSpec.describe Analytics::CycleAnalytics::StageEventHash, type: :model do
describe 'associations' do
it { is_expected.to have_many(:cycle_analytics_project_stages) }
+ it { is_expected.to have_many(:cycle_analytics_stages) }
end
describe 'validations' do
@@ -30,14 +31,14 @@ RSpec.describe Analytics::CycleAnalytics::StageEventHash, type: :model do
end
describe '.cleanup_if_unused' do
- it 'removes the record' do
+ it 'removes the record if there is no project or group stages with given stage events hash' do
described_class.cleanup_if_unused(stage_event_hash.id)
expect(described_class.find_by_id(stage_event_hash.id)).to be_nil
end
- it 'does not remove the record' do
- id = create(:cycle_analytics_project_stage).stage_event_hash_id
+ it 'does not remove the record if at least 1 group stage for the given stage events hash exists' do
+ id = create(:cycle_analytics_stage).stage_event_hash_id
described_class.cleanup_if_unused(id)
diff --git a/spec/models/analytics/cycle_analytics/stage_spec.rb b/spec/models/analytics/cycle_analytics/stage_spec.rb
new file mode 100644
index 00000000000..e37edda80b1
--- /dev/null
+++ b/spec/models/analytics/cycle_analytics/stage_spec.rb
@@ -0,0 +1,126 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Analytics::CycleAnalytics::Stage, feature_category: :value_stream_management do
+ describe 'uniqueness validation on name' do
+ subject { build(:cycle_analytics_stage) }
+
+ it { is_expected.to validate_uniqueness_of(:name).scoped_to([:group_id, :group_value_stream_id]) }
+ end
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:namespace).required }
+ it { is_expected.to belong_to(:value_stream) }
+ end
+
+ it_behaves_like 'value stream analytics namespace models' do
+ let(:factory_name) { :cycle_analytics_stage }
+ end
+
+ it_behaves_like 'value stream analytics stage' do
+ let(:factory) { :cycle_analytics_stage }
+ let(:parent) { create(:group) }
+ let(:parent_name) { :namespace }
+ end
+
+ describe '.distinct_stages_within_hierarchy' do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:sub_group) { create(:group, parent: group) }
+
+ before do
+ # event identifiers are the same
+ create(:cycle_analytics_stage, name: 'Stage A1', namespace: group,
+ start_event_identifier: :merge_request_created, end_event_identifier: :merge_request_merged)
+ create(:cycle_analytics_stage, name: 'Stage A2', namespace: sub_group,
+ start_event_identifier: :merge_request_created, end_event_identifier: :merge_request_merged)
+ create(:cycle_analytics_stage, name: 'Stage A3', namespace: sub_group,
+ start_event_identifier: :merge_request_created, end_event_identifier: :merge_request_merged)
+
+ create(:cycle_analytics_stage,
+ name: 'Stage B1',
+ namespace: group,
+ start_event_identifier: :merge_request_last_build_started,
+ end_event_identifier: :merge_request_last_build_finished)
+ end
+
+ it 'returns distinct stages by the event identifiers' do
+ stages = described_class.distinct_stages_within_hierarchy(group).to_a
+
+ expected_event_pairs = [
+ %w[merge_request_created merge_request_merged],
+ %w[merge_request_last_build_started merge_request_last_build_finished]
+ ]
+
+ current_event_pairs = stages.map do |stage|
+ [stage.start_event_identifier, stage.end_event_identifier]
+ end
+
+ expect(current_event_pairs).to eq(expected_event_pairs)
+ end
+ end
+
+ describe 'events tracking' do
+ let(:category) { described_class.to_s }
+ let(:label) { described_class.table_name }
+ let(:namespace) { create(:group) }
+ let(:action) { "database_event_#{property}" }
+ let(:value_stream) { create(:cycle_analytics_value_stream) }
+ let(:feature_flag_name) { :product_intelligence_database_event_tracking }
+ let(:stage) { described_class.create!(stage_params) }
+ let(:stage_params) do
+ {
+ namespace: namespace,
+ name: 'st1',
+ start_event_identifier: :merge_request_created,
+ end_event_identifier: :merge_request_merged,
+ group_value_stream_id: value_stream.id
+ }
+ end
+
+ let(:record_tracked_attributes) do
+ {
+ "id" => stage.id,
+ "created_at" => stage.created_at,
+ "updated_at" => stage.updated_at,
+ "relative_position" => stage.relative_position,
+ "start_event_identifier" => stage.start_event_identifier,
+ "end_event_identifier" => stage.end_event_identifier,
+ "group_id" => stage.group_id,
+ "start_event_label_id" => stage.start_event_label_id,
+ "end_event_label_id" => stage.end_event_label_id,
+ "hidden" => stage.hidden,
+ "custom" => stage.custom,
+ "name" => stage.name,
+ "group_value_stream_id" => stage.group_value_stream_id
+ }
+ end
+
+ describe '#create' do
+ it_behaves_like 'Snowplow event tracking' do
+ let(:property) { 'create' }
+ let(:extra) { record_tracked_attributes }
+
+ subject(:new_group_stage) { stage }
+ end
+ end
+
+ describe '#update', :freeze_time do
+ it_behaves_like 'Snowplow event tracking' do
+ subject(:create_group_stage) { stage.update!(name: 'st 2') }
+
+ let(:extra) { record_tracked_attributes.merge('name' => 'st 2') }
+ let(:property) { 'update' }
+ end
+ end
+
+ describe '#destroy' do
+ it_behaves_like 'Snowplow event tracking' do
+ subject(:delete_stage_group) { stage.destroy! }
+
+ let(:extra) { record_tracked_attributes }
+ let(:property) { 'destroy' }
+ end
+ end
+ end
+end
diff --git a/spec/models/analytics/cycle_analytics/value_stream_spec.rb b/spec/models/analytics/cycle_analytics/value_stream_spec.rb
new file mode 100644
index 00000000000..e32fbef30ae
--- /dev/null
+++ b/spec/models/analytics/cycle_analytics/value_stream_spec.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Analytics::CycleAnalytics::ValueStream, type: :model, feature_category: :value_stream_management do
+ describe 'associations' do
+ it { is_expected.to belong_to(:namespace).required }
+ it { is_expected.to have_many(:stages) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:name) }
+ it { is_expected.to validate_length_of(:name).is_at_most(100) }
+
+ it 'validates uniqueness of name' do
+ group = create(:group)
+ create(:cycle_analytics_value_stream, name: 'test', namespace: group)
+
+ value_stream = build(:cycle_analytics_value_stream, name: 'test', namespace: group)
+
+ expect(value_stream).to be_invalid
+ expect(value_stream.errors.messages).to eq(name: [I18n.t('errors.messages.taken')])
+ end
+
+ it_behaves_like 'value stream analytics namespace models' do
+ let(:factory_name) { :cycle_analytics_value_stream }
+ end
+ end
+
+ describe 'ordering of stages' do
+ let(:group) { create(:group) }
+ let(:value_stream) do
+ create(:cycle_analytics_value_stream, namespace: group, stages: [
+ create(:cycle_analytics_stage, namespace: group, name: "stage 1", relative_position: 5),
+ create(:cycle_analytics_stage, namespace: group, name: "stage 2", relative_position: nil),
+ create(:cycle_analytics_stage, namespace: group, name: "stage 3", relative_position: 1)
+ ])
+ end
+
+ before do
+ value_stream.reload
+ end
+
+ describe 'stages attribute' do
+ it 'sorts stages by relative position' do
+ names = value_stream.stages.map(&:name)
+ expect(names).to eq(['stage 3', 'stage 1', 'stage 2'])
+ end
+ end
+ end
+
+ describe '#custom?' do
+ context 'when value stream is not persisted' do
+ subject(:value_stream) { build(:cycle_analytics_value_stream, name: value_stream_name) }
+
+ context 'when the name of the value stream is default' do
+ let(:value_stream_name) { Analytics::CycleAnalytics::Stages::BaseService::DEFAULT_VALUE_STREAM_NAME }
+
+ it { is_expected.not_to be_custom }
+ end
+
+ context 'when the name of the value stream is not default' do
+ let(:value_stream_name) { 'value_stream_1' }
+
+ it { is_expected.to be_custom }
+ end
+ end
+
+ context 'when value stream is persisted' do
+ subject(:value_stream) { create(:cycle_analytics_value_stream, name: 'value_stream_1') }
+
+ it { is_expected.to be_custom }
+ end
+ end
+end
diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb
index e05eeb7772b..63b8ebcecab 100644
--- a/spec/models/namespace_spec.rb
+++ b/spec/models/namespace_spec.rb
@@ -36,6 +36,8 @@ RSpec.describe Namespace, feature_category: :subgroups do
it { is_expected.to have_many(:work_items) }
it { is_expected.to have_many :achievements }
it { is_expected.to have_many(:namespace_commit_emails).class_name('Users::NamespaceCommitEmail') }
+ it { is_expected.to have_many(:cycle_analytics_stages) }
+ it { is_expected.to have_many(:value_streams) }
it do
is_expected.to have_one(:ci_cd_settings).class_name('NamespaceCiCdSetting').inverse_of(:namespace).autosave(true)
diff --git a/spec/models/service_desk_setting_spec.rb b/spec/models/service_desk_setting_spec.rb
index c1ec35732b8..32c36375a3d 100644
--- a/spec/models/service_desk_setting_spec.rb
+++ b/spec/models/service_desk_setting_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe ServiceDeskSetting do
+RSpec.describe ServiceDeskSetting, feature_category: :service_desk do
describe 'validations' do
subject(:service_desk_setting) { create(:service_desk_setting) }
@@ -12,6 +12,48 @@ RSpec.describe ServiceDeskSetting do
it { is_expected.to allow_value('abc123_').for(:project_key) }
it { is_expected.not_to allow_value('abc 12').for(:project_key).with_message("can contain only lowercase letters, digits, and '_'.") }
it { is_expected.not_to allow_value('Big val').for(:project_key) }
+ it { is_expected.to validate_length_of(:custom_email).is_at_most(255) }
+ it { is_expected.to validate_length_of(:custom_email_smtp_address).is_at_most(255) }
+ it { is_expected.to validate_length_of(:custom_email_smtp_username).is_at_most(255) }
+
+ describe '#custom_email_enabled' do
+ it { expect(subject.custom_email_enabled).to be_falsey }
+ it { expect(described_class.new(custom_email_enabled: true).custom_email_enabled).to be_truthy }
+ end
+
+ context 'when custom_email_enabled is true' do
+ before do
+ subject.custom_email_enabled = true
+ end
+
+ it { is_expected.to validate_presence_of(:custom_email) }
+ it { is_expected.to validate_uniqueness_of(:custom_email).allow_nil }
+ it { is_expected.to allow_value('support@example.com').for(:custom_email) }
+ it { is_expected.to allow_value('support@xn--brggen-4ya.de').for(:custom_email) } # converted domain name with umlaut
+ it { is_expected.to allow_value('support1@shop.example.com').for(:custom_email) }
+ it { is_expected.to allow_value('support-shop_with.crazy-address@shop.example.com').for(:custom_email) }
+ it { is_expected.not_to allow_value('support@example@example.com').for(:custom_email) }
+ it { is_expected.not_to allow_value('support.example.com').for(:custom_email) }
+ it { is_expected.not_to allow_value('example.com').for(:custom_email) }
+ it { is_expected.not_to allow_value('example').for(:custom_email) }
+ it { is_expected.not_to allow_value('" "@example.org').for(:custom_email) }
+ it { is_expected.not_to allow_value('support+12@example.com').for(:custom_email) }
+ it { is_expected.not_to allow_value('user@[IPv6:2001:db8::1]').for(:custom_email) }
+ it { is_expected.not_to allow_value('">"@example.org').for(:custom_email) }
+ it { is_expected.not_to allow_value('file://example').for(:custom_email) }
+ it { is_expected.not_to allow_value('no email at all').for(:custom_email) }
+
+ it { is_expected.to validate_presence_of(:custom_email_smtp_username) }
+
+ it { is_expected.to validate_presence_of(:custom_email_smtp_port) }
+ it { is_expected.to validate_numericality_of(:custom_email_smtp_port).only_integer.is_greater_than(0) }
+
+ it { is_expected.to validate_presence_of(:custom_email_smtp_address) }
+ it { is_expected.to allow_value('smtp.gmail.com').for(:custom_email_smtp_address) }
+ it { is_expected.not_to allow_value('https://example.com').for(:custom_email_smtp_address) }
+ it { is_expected.not_to allow_value('file://example').for(:custom_email_smtp_address) }
+ it { is_expected.not_to allow_value('/example').for(:custom_email_smtp_address) }
+ end
describe '.valid_issue_template' do
let_it_be(:project) { create(:project, :custom_repo, files: { '.gitlab/issue_templates/service_desk.md' => 'template' }) }
@@ -67,6 +109,27 @@ RSpec.describe ServiceDeskSetting do
end
end
+ describe 'encrypted password' do
+ let_it_be(:settings) do
+ create(
+ :service_desk_setting,
+ custom_email_enabled: true,
+ custom_email: 'supersupport@example.com',
+ custom_email_smtp_address: 'smtp.example.com',
+ custom_email_smtp_port: 587,
+ custom_email_smtp_username: 'supersupport@example.com',
+ custom_email_smtp_password: 'supersecret'
+ )
+ end
+
+ it 'saves and retrieves the encrypted custom email smtp password and iv correctly' do
+ expect(settings.encrypted_custom_email_smtp_password).not_to be_nil
+ expect(settings.encrypted_custom_email_smtp_password_iv).not_to be_nil
+
+ expect(settings.custom_email_smtp_password).to eq('supersecret')
+ end
+ end
+
describe 'associations' do
it { is_expected.to belong_to(:project) }
end
diff --git a/spec/requests/api/project_attributes.yml b/spec/requests/api/project_attributes.yml
index cc399d25429..df2c9c1a23e 100644
--- a/spec/requests/api/project_attributes.yml
+++ b/spec/requests/api/project_attributes.yml
@@ -168,5 +168,13 @@ build_service_desk_setting: # service_desk_setting
- issue_template_key
- file_template_project_id
- outgoing_name
+ - custom_email_enabled
+ - custom_email
+ - custom_email_smtp_address
+ - custom_email_smtp_port
+ - custom_email_smtp_username
+ - encrypted_custom_email_smtp_password
+ - encrypted_custom_email_smtp_password_iv
+ - custom_email_smtp_password
remapped_attributes:
project_key: service_desk_address
diff --git a/spec/requests/groups/usage_quotas_controller_spec.rb b/spec/requests/groups/usage_quotas_controller_spec.rb
index 90fd08063f3..a329398aab3 100644
--- a/spec/requests/groups/usage_quotas_controller_spec.rb
+++ b/spec/requests/groups/usage_quotas_controller_spec.rb
@@ -23,7 +23,7 @@ RSpec.describe Groups::UsageQuotasController, :with_license, feature_category: :
request
expect(response).to have_gitlab_http_status(:ok)
- expect(response.body).to match(/Placeholder for usage quotas Vue app/)
+ expect(response.body).to match(/js-usage-quotas-view/)
end
it 'renders 404 page if subgroup' do
diff --git a/spec/services/analytics/cycle_analytics/stages/list_service_spec.rb b/spec/services/analytics/cycle_analytics/stages/list_service_spec.rb
index 24f0123ed3b..7bfae0cd9fc 100644
--- a/spec/services/analytics/cycle_analytics/stages/list_service_spec.rb
+++ b/spec/services/analytics/cycle_analytics/stages/list_service_spec.rb
@@ -5,11 +5,14 @@ require 'spec_helper'
RSpec.describe Analytics::CycleAnalytics::Stages::ListService do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
+ let_it_be(:project_namespace) { project.project_namespace.reload }
- let(:value_stream) { Analytics::CycleAnalytics::ProjectValueStream.build_default_value_stream(project) }
+ let(:value_stream) { Analytics::CycleAnalytics::ValueStream.build_default_value_stream(project_namespace) }
let(:stages) { subject.payload[:stages] }
- subject { described_class.new(parent: project, current_user: user).execute }
+ subject do
+ described_class.new(parent: project_namespace, current_user: user, params: { value_stream: value_stream }).execute
+ end
before_all do
project.add_reporter(user)
diff --git a/spec/support/shared_examples/mailers/notify_shared_examples.rb b/spec/support/shared_examples/mailers/notify_shared_examples.rb
index b0cbf0b0d65..2e182fb399d 100644
--- a/spec/support/shared_examples/mailers/notify_shared_examples.rb
+++ b/spec/support/shared_examples/mailers/notify_shared_examples.rb
@@ -280,6 +280,12 @@ RSpec.shared_examples 'no email is sent' do
end
end
+RSpec.shared_examples 'a mail with default delivery method' do
+ it 'uses mailer default delivery method' do
+ expect(subject.delivery_method).to be_a ActionMailer::Base.delivery_methods[described_class.delivery_method]
+ end
+end
+
RSpec.shared_examples 'does not render a manage notifications link' do
it do
aggregate_failures do
diff --git a/spec/tooling/danger/specs_spec.rb b/spec/tooling/danger/specs_spec.rb
index 422923827a8..052e86e7f32 100644
--- a/spec/tooling/danger/specs_spec.rb
+++ b/spec/tooling/danger/specs_spec.rb
@@ -19,14 +19,16 @@ RSpec.describe Tooling::Danger::Specs, feature_category: :tooling do
let(:file_lines) do
[
" describe 'foo' do",
- " expect(foo).to match(['bar'])",
+ " expect(foo).to match(['bar', 'baz'])",
" end",
- " expect(foo).to match(['bar'])", # same line as line 1 above, we expect two different suggestions
+ " expect(foo).to match(['bar', 'baz'])", # same line as line 1 above, we expect two different suggestions
" ",
- " expect(foo).to match ['bar']",
- " expect(foo).to eq(['bar'])",
- " expect(foo).to eq ['bar']",
- " expect(foo).to(match(['bar']))",
+ " expect(foo).to match ['bar', 'baz']",
+ " expect(foo).to eq(['bar', 'baz'])",
+ " expect(foo).to eq ['bar', 'baz']",
+ " expect(foo).to(match(['bar', 'baz']))",
+ " expect(foo).to(eq(['bar', 'baz']))",
+ " expect(foo).to(eq([bar, baz]))",
" expect(foo).to(eq(['bar']))",
" foo.eq(['bar'])"
]
@@ -35,28 +37,30 @@ RSpec.describe Tooling::Danger::Specs, feature_category: :tooling do
let(:matching_lines) do
[
"+ expect(foo).to match(['should not error'])",
- "+ expect(foo).to match(['bar'])",
- "+ expect(foo).to match(['bar'])",
- "+ expect(foo).to match ['bar']",
- "+ expect(foo).to eq(['bar'])",
- "+ expect(foo).to eq ['bar']",
- "+ expect(foo).to(match(['bar']))",
- "+ expect(foo).to(eq(['bar']))"
+ "+ expect(foo).to match(['bar', 'baz'])",
+ "+ expect(foo).to match(['bar', 'baz'])",
+ "+ expect(foo).to match ['bar', 'baz']",
+ "+ expect(foo).to eq(['bar', 'baz'])",
+ "+ expect(foo).to eq ['bar', 'baz']",
+ "+ expect(foo).to(match(['bar', 'baz']))",
+ "+ expect(foo).to(eq(['bar', 'baz']))",
+ "+ expect(foo).to(eq([bar, baz]))"
]
end
let(:changed_lines) do
[
- " expect(foo).to match(['bar'])",
- " expect(foo).to match(['bar'])",
- " expect(foo).to match ['bar']",
- " expect(foo).to eq(['bar'])",
- " expect(foo).to eq ['bar']",
- "- expect(foo).to match(['bar'])",
- "- expect(foo).to match(['bar'])",
- "- expect(foo).to match ['bar']",
- "- expect(foo).to eq(['bar'])",
- "- expect(foo).to eq ['bar']",
+ " expect(foo).to match(['bar', 'baz'])",
+ " expect(foo).to match(['bar', 'baz'])",
+ " expect(foo).to match ['bar', 'baz']",
+ " expect(foo).to eq(['bar', 'baz'])",
+ " expect(foo).to eq ['bar', 'baz']",
+ "- expect(foo).to match(['bar', 'baz'])",
+ "- expect(foo).to match(['bar', 'baz'])",
+ "- expect(foo).to match ['bar', 'baz']",
+ "- expect(foo).to eq(['bar', 'baz'])",
+ "- expect(foo).to eq ['bar', 'baz']",
+ "- expect(foo).to eq [bar, foo]",
"+ expect(foo).to eq([])"
] + matching_lines
end
@@ -118,13 +122,14 @@ RSpec.describe Tooling::Danger::Specs, feature_category: :tooling do
it 'adds suggestions at the correct lines' do
[
- { suggested_line: " expect(foo).to match_array(['bar'])", number: 2 },
- { suggested_line: " expect(foo).to match_array(['bar'])", number: 4 },
- { suggested_line: " expect(foo).to match_array ['bar']", number: 6 },
- { suggested_line: " expect(foo).to match_array(['bar'])", number: 7 },
- { suggested_line: " expect(foo).to match_array ['bar']", number: 8 },
- { suggested_line: " expect(foo).to(match_array(['bar']))", number: 9 },
- { suggested_line: " expect(foo).to(match_array(['bar']))", number: 10 }
+ { suggested_line: " expect(foo).to match_array(['bar', 'baz'])", number: 2 },
+ { suggested_line: " expect(foo).to match_array(['bar', 'baz'])", number: 4 },
+ { suggested_line: " expect(foo).to match_array ['bar', 'baz']", number: 6 },
+ { suggested_line: " expect(foo).to match_array(['bar', 'baz'])", number: 7 },
+ { suggested_line: " expect(foo).to match_array ['bar', 'baz']", number: 8 },
+ { suggested_line: " expect(foo).to(match_array(['bar', 'baz']))", number: 9 },
+ { suggested_line: " expect(foo).to(match_array(['bar', 'baz']))", number: 10 },
+ { suggested_line: " expect(foo).to(match_array([bar, baz]))", number: 11 }
].each do |test_case|
comment = format(template, suggested_line: test_case[:suggested_line])
expect(specs).to receive(:markdown).with(comment, file: filename, line: test_case[:number])
diff --git a/spec/views/admin/application_settings/ci_cd.html.haml_spec.rb b/spec/views/admin/application_settings/ci_cd.html.haml_spec.rb
index e4ebdd706d4..5ef9399487f 100644
--- a/spec/views/admin/application_settings/ci_cd.html.haml_spec.rb
+++ b/spec/views/admin/application_settings/ci_cd.html.haml_spec.rb
@@ -25,7 +25,7 @@ RSpec.describe 'admin/application_settings/ci_cd.html.haml' do
render
expect(rendered).to have_content("Runner registration")
- expect(rendered).to have_content("If no options are selected, only administrators can register runners.")
+ expect(rendered).to have_content(s_("Runners|If both settings are disabled, new runners cannot be registered."))
end
end
end
diff --git a/tooling/danger/specs.rb b/tooling/danger/specs.rb
index 6c0459a4344..04f3c9d4c9a 100644
--- a/tooling/danger/specs.rb
+++ b/tooling/danger/specs.rb
@@ -5,7 +5,7 @@ module Tooling
module Specs
SPEC_FILES_REGEX = 'spec/'
EE_PREFIX = 'ee/'
- MATCH_WITH_ARRAY_REGEX = /(?
to\(?\s*)(?match|eq)(?[( ]?\[[^\]]+)/.freeze
+ MATCH_WITH_ARRAY_REGEX = /(?to\(?\s*)(?match|eq)(?[( ]?\[(?=.*,)[^\]]+)/.freeze
MATCH_WITH_ARRAY_REPLACEMENT = '\kmatch_array\k'
PROJECT_FACTORIES = %w[