Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/spec
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2023-02-23 06:12:37 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2023-02-23 06:12:37 +0300
commitf34b26bb882947bcc1126de19fa55eb8763af32e (patch)
tree7774da06e9e981fc80bf05b6269e1ecb8b6ab5a4 /spec
parent04dabf41f65cf1c25d80d92b1cc5568bfcca80ee (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec')
-rw-r--r--spec/features/profiles/user_creates_saved_reply_spec.rb29
-rw-r--r--spec/frontend/fixtures/saved_replies.rb28
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/package_versions_list_spec.js80
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/details/version_row_spec.js28
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js3
-rw-r--r--spec/frontend/saved_replies/components/form_spec.js116
-rw-r--r--spec/lib/gitlab/database/async_foreign_keys/postgres_async_foreign_key_validation_spec.rb2
-rw-r--r--spec/requests/api/graphql/project/flow_metrics_spec.rb19
-rw-r--r--spec/services/system_notes/commit_service_spec.rb82
-rw-r--r--spec/support/shared_examples/analytics/cycle_analytics/flow_metrics_examples.rb124
10 files changed, 493 insertions, 18 deletions
diff --git a/spec/features/profiles/user_creates_saved_reply_spec.rb b/spec/features/profiles/user_creates_saved_reply_spec.rb
new file mode 100644
index 00000000000..1d851b5cea0
--- /dev/null
+++ b/spec/features/profiles/user_creates_saved_reply_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Profile > Saved replies > User creates saved reply', :js,
+ feature_category: :user_profile do
+ let_it_be(:user) { create(:user) }
+
+ before do
+ sign_in(user)
+
+ visit profile_saved_replies_path
+
+ wait_for_requests
+ end
+
+ it 'shows the user a list of their saved replies' do
+ find('[data-testid="saved-reply-name-input"]').set('test')
+ find('[data-testid="saved-reply-content-input"]').set('Test content')
+
+ click_button 'Save'
+
+ wait_for_requests
+
+ expect(page).to have_content('My saved replies (1)')
+ expect(page).to have_content('test')
+ expect(page).to have_content('Test content')
+ end
+end
diff --git a/spec/frontend/fixtures/saved_replies.rb b/spec/frontend/fixtures/saved_replies.rb
index c80ba06bca1..613e4a1b447 100644
--- a/spec/frontend/fixtures/saved_replies.rb
+++ b/spec/frontend/fixtures/saved_replies.rb
@@ -43,4 +43,32 @@ RSpec.describe GraphQL::Query, type: :request, feature_category: :user_profile d
expect_graphql_errors_to_be_empty
end
end
+
+ context 'when user creates saved reply' do
+ base_input_path = 'saved_replies/queries/'
+ base_output_path = 'graphql/saved_replies/'
+ query_name = 'create_saved_reply.mutation.graphql'
+
+ it "#{base_output_path}#{query_name}.json" do
+ query = get_graphql_query_as_string("#{base_input_path}#{query_name}")
+
+ post_graphql(query, current_user: current_user, variables: { name: "Test", content: "Test content" })
+
+ expect_graphql_errors_to_be_empty
+ end
+ end
+
+ context 'when user creates saved reply and it errors' do
+ base_input_path = 'saved_replies/queries/'
+ base_output_path = 'graphql/saved_replies/'
+ query_name = 'create_saved_reply.mutation.graphql'
+
+ it "#{base_output_path}create_saved_reply_with_errors.mutation.graphql.json" do
+ query = get_graphql_query_as_string("#{base_input_path}#{query_name}")
+
+ post_graphql(query, current_user: current_user, variables: { name: nil, content: nil })
+
+ expect(flattened_errors).not_to be_empty
+ end
+ end
end
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/package_versions_list_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/package_versions_list_spec.js
index 27c0ab96cfc..fc7f5c80d45 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/details/package_versions_list_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/package_versions_list_spec.js
@@ -1,14 +1,18 @@
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { stubComponent } from 'helpers/stub_component';
import DeleteModal from '~/packages_and_registries/package_registry/components/delete_modal.vue';
+import DeletePackageModal from '~/packages_and_registries/shared/components/delete_package_modal.vue';
import PackageVersionsList from '~/packages_and_registries/package_registry/components/details/package_versions_list.vue';
import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue';
import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
import VersionRow from '~/packages_and_registries/package_registry/components/details/version_row.vue';
import Tracking from '~/tracking';
import {
+ CANCEL_DELETE_PACKAGE_VERSION_TRACKING_ACTION,
CANCEL_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION,
+ DELETE_PACKAGE_VERSION_TRACKING_ACTION,
DELETE_PACKAGE_VERSIONS_TRACKING_ACTION,
+ REQUEST_DELETE_PACKAGE_VERSION_TRACKING_ACTION,
REQUEST_DELETE_PACKAGE_VERSIONS_TRACKING_ACTION,
} from '~/packages_and_registries/package_registry/constants';
import { packageData } from '../../mock_data';
@@ -22,7 +26,7 @@ describe('PackageVersionsList', () => {
name: 'version 1',
}),
packageData({
- id: `gid://gitlab/Packages::Package/112`,
+ id: 'gid://gitlab/Packages::Package/112',
name: 'version 2',
}),
];
@@ -31,8 +35,10 @@ describe('PackageVersionsList', () => {
findLoader: () => wrapper.findComponent(PackagesListLoader),
findRegistryList: () => wrapper.findComponent(RegistryList),
findEmptySlot: () => wrapper.findComponent(EmptySlotStub),
- findListRow: () => wrapper.findAllComponents(VersionRow),
+ findListRow: () => wrapper.findComponent(VersionRow),
+ findAllListRow: () => wrapper.findAllComponents(VersionRow),
findDeletePackagesModal: () => wrapper.findComponent(DeleteModal),
+ findPackageListDeleteModal: () => wrapper.findComponent(DeletePackageModal),
};
const mountComponent = (props) => {
wrapper = shallowMountExtended(PackageVersionsList, {
@@ -118,16 +124,16 @@ describe('PackageVersionsList', () => {
});
it('displays package version rows', () => {
- expect(uiElements.findListRow().exists()).toEqual(true);
- expect(uiElements.findListRow()).toHaveLength(packageList.length);
+ expect(uiElements.findAllListRow().exists()).toEqual(true);
+ expect(uiElements.findAllListRow()).toHaveLength(packageList.length);
});
it('binds the correct props', () => {
- expect(uiElements.findListRow().at(0).props()).toMatchObject({
+ expect(uiElements.findAllListRow().at(0).props()).toMatchObject({
packageEntity: expect.objectContaining(packageList[0]),
});
- expect(uiElements.findListRow().at(1).props()).toMatchObject({
+ expect(uiElements.findAllListRow().at(1).props()).toMatchObject({
packageEntity: expect.objectContaining(packageList[1]),
});
});
@@ -159,6 +165,68 @@ describe('PackageVersionsList', () => {
});
});
+ describe.each`
+ description | finderFunction | deletePayload
+ ${'when the user can destroy the package'} | ${uiElements.findListRow} | ${packageList[0]}
+ ${'when the user can bulk destroy packages and deletes only one package'} | ${uiElements.findRegistryList} | ${[packageList[0]]}
+ `('$description', ({ finderFunction, deletePayload }) => {
+ let eventSpy;
+ const category = 'UI::NpmPackages';
+ const { findPackageListDeleteModal } = uiElements;
+
+ beforeEach(() => {
+ eventSpy = jest.spyOn(Tracking, 'event');
+ mountComponent({ canDestroy: true });
+ finderFunction().vm.$emit('delete', deletePayload);
+ });
+
+ it('passes itemToBeDeleted to the modal', () => {
+ expect(findPackageListDeleteModal().props('itemToBeDeleted')).toStrictEqual(packageList[0]);
+ });
+
+ it('requesting delete tracks the right action', () => {
+ expect(eventSpy).toHaveBeenCalledWith(
+ category,
+ REQUEST_DELETE_PACKAGE_VERSION_TRACKING_ACTION,
+ expect.any(Object),
+ );
+ });
+
+ describe('when modal confirms', () => {
+ beforeEach(() => {
+ findPackageListDeleteModal().vm.$emit('ok');
+ });
+
+ it('emits delete when modal confirms', () => {
+ expect(wrapper.emitted('delete')[0][0]).toEqual([packageList[0]]);
+ });
+
+ it('tracks the right action', () => {
+ expect(eventSpy).toHaveBeenCalledWith(
+ category,
+ DELETE_PACKAGE_VERSION_TRACKING_ACTION,
+ expect.any(Object),
+ );
+ });
+ });
+
+ it.each(['ok', 'cancel'])('resets itemToBeDeleted when modal emits %s', async (event) => {
+ await findPackageListDeleteModal().vm.$emit(event);
+
+ expect(findPackageListDeleteModal().props('itemToBeDeleted')).toBeNull();
+ });
+
+ it('canceling delete tracks the right action', () => {
+ findPackageListDeleteModal().vm.$emit('cancel');
+
+ expect(eventSpy).toHaveBeenCalledWith(
+ category,
+ CANCEL_DELETE_PACKAGE_VERSION_TRACKING_ACTION,
+ expect.any(Object),
+ );
+ });
+ });
+
describe('when the user can bulk destroy versions', () => {
let eventSpy;
const { findDeletePackagesModal, findRegistryList } = uiElements;
diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/version_row_spec.js b/spec/frontend/packages_and_registries/package_registry/components/details/version_row_spec.js
index 8cb51aaf738..9f3dcc18fb6 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/details/version_row_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/details/version_row_spec.js
@@ -1,4 +1,4 @@
-import { GlFormCheckbox, GlIcon, GlLink, GlSprintf, GlTruncate } from '@gitlab/ui';
+import { GlDropdownItem, GlFormCheckbox, GlIcon, GlLink, GlSprintf, GlTruncate } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
@@ -24,6 +24,7 @@ describe('VersionRow', () => {
const findPackageName = () => wrapper.findComponent(GlTruncate);
const findWarningIcon = () => wrapper.findComponent(GlIcon);
const findBulkDeleteAction = () => wrapper.findComponent(GlFormCheckbox);
+ const findDeleteDropdownItem = () => wrapper.findComponent(GlDropdownItem);
function createComponent({ packageEntity = packageVersion, selected = false } = {}) {
wrapper = shallowMountExtended(VersionRow, {
@@ -112,6 +113,31 @@ describe('VersionRow', () => {
});
});
+ describe('delete button', () => {
+ it('does not exist when package cannot be destroyed', () => {
+ createComponent({ packageEntity: { ...packageVersion, canDestroy: false } });
+
+ expect(findDeleteDropdownItem().exists()).toBe(false);
+ });
+
+ it('exists and has the correct props', () => {
+ createComponent();
+
+ expect(findDeleteDropdownItem().exists()).toBe(true);
+ expect(findDeleteDropdownItem().attributes()).toMatchObject({
+ variant: 'danger',
+ });
+ });
+
+ it('emits the delete event when the delete button is clicked', () => {
+ createComponent();
+
+ findDeleteDropdownItem().vm.$emit('click');
+
+ expect(wrapper.emitted('delete')).toHaveLength(1);
+ });
+ });
+
describe(`when the package is in ${PACKAGE_ERROR_STATUS} status`, () => {
beforeEach(() => {
createComponent({
diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js
index 19d56fe8cc6..0d40cb4fde0 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js
@@ -1,5 +1,5 @@
import { GlFormCheckbox, GlSprintf, GlTruncate } from '@gitlab/ui';
-import Vue, { nextTick } from 'vue';
+import Vue from 'vue';
import VueRouter from 'vue-router';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
@@ -141,7 +141,6 @@ describe('packages_list_row', () => {
findDeleteDropdown().vm.$emit('click');
- await nextTick();
expect(wrapper.emitted('delete')).toHaveLength(1);
});
});
diff --git a/spec/frontend/saved_replies/components/form_spec.js b/spec/frontend/saved_replies/components/form_spec.js
new file mode 100644
index 00000000000..693703ca572
--- /dev/null
+++ b/spec/frontend/saved_replies/components/form_spec.js
@@ -0,0 +1,116 @@
+import Vue, { nextTick } from 'vue';
+import { mount } from '@vue/test-utils';
+import { GlAlert } from '@gitlab/ui';
+import VueApollo from 'vue-apollo';
+import createdSavedReplyResponse from 'test_fixtures/graphql/saved_replies/create_saved_reply.mutation.graphql.json';
+import createdSavedReplyErrorResponse from 'test_fixtures/graphql/saved_replies/create_saved_reply_with_errors.mutation.graphql.json';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import Form from '~/saved_replies/components/form.vue';
+import createSavedReplyMutation from '~/saved_replies/queries/create_saved_reply.mutation.graphql';
+
+let wrapper;
+let createSavedReplyResponseSpy;
+
+function createMockApolloProvider(response) {
+ Vue.use(VueApollo);
+
+ createSavedReplyResponseSpy = jest.fn().mockResolvedValue(response);
+
+ const requestHandlers = [[createSavedReplyMutation, createSavedReplyResponseSpy]];
+
+ return createMockApollo(requestHandlers);
+}
+
+function createComponent(response = createdSavedReplyResponse) {
+ const mockApollo = createMockApolloProvider(response);
+
+ return mount(Form, {
+ apolloProvider: mockApollo,
+ });
+}
+
+const findSavedReplyNameInput = () => wrapper.find('[data-testid="saved-reply-name-input"]');
+const findSavedReplyNameFormGroup = () =>
+ wrapper.find('[data-testid="saved-reply-name-form-group"]');
+const findSavedReplyContentInput = () => wrapper.find('[data-testid="saved-reply-content-input"]');
+const findSavedReplyContentFormGroup = () =>
+ wrapper.find('[data-testid="saved-reply-content-form-group"]');
+const findSavedReplyFrom = () => wrapper.find('[data-testid="saved-reply-form"]');
+const findAlerts = () => wrapper.findAllComponents(GlAlert);
+const findSubmitBtn = () => wrapper.find('[data-testid="saved-reply-form-submit-btn"]');
+
+describe('Saved replies form component', () => {
+ describe('create saved reply', () => {
+ it('calls apollo mutation', async () => {
+ wrapper = createComponent();
+
+ findSavedReplyNameInput().setValue('Test');
+ findSavedReplyContentInput().setValue('Test content');
+ findSavedReplyFrom().trigger('submit');
+
+ await waitForPromises();
+
+ expect(createSavedReplyResponseSpy).toHaveBeenCalledWith({
+ content: 'Test content',
+ name: 'Test',
+ });
+ });
+
+ it('does not submit when form validation fails', async () => {
+ wrapper = createComponent();
+
+ findSavedReplyFrom().trigger('submit');
+
+ await waitForPromises();
+
+ expect(createSavedReplyResponseSpy).not.toHaveBeenCalled();
+ });
+
+ it.each`
+ findFormGroup | findInput | fieldName
+ ${findSavedReplyNameFormGroup} | ${findSavedReplyContentInput} | ${'name'}
+ ${findSavedReplyContentFormGroup} | ${findSavedReplyNameInput} | ${'content'}
+ `('shows errors for empty $fieldName input', async ({ findFormGroup, findInput }) => {
+ wrapper = createComponent(createdSavedReplyErrorResponse);
+
+ findInput().setValue('Test');
+ findSavedReplyFrom().trigger('submit');
+
+ await waitForPromises();
+
+ expect(findFormGroup().classes('is-invalid')).toBe(true);
+ });
+
+ it('displays errors when mutation fails', async () => {
+ wrapper = createComponent(createdSavedReplyErrorResponse);
+
+ findSavedReplyNameInput().setValue('Test');
+ findSavedReplyContentInput().setValue('Test content');
+ findSavedReplyFrom().trigger('submit');
+
+ await waitForPromises();
+
+ const { errors } = createdSavedReplyErrorResponse;
+ const alertMessages = findAlerts().wrappers.map((x) => x.text());
+
+ expect(alertMessages).toEqual(errors.map((x) => x.message));
+ });
+
+ it('shows loading state when saving', async () => {
+ wrapper = createComponent();
+
+ findSavedReplyNameInput().setValue('Test');
+ findSavedReplyContentInput().setValue('Test content');
+ findSavedReplyFrom().trigger('submit');
+
+ await nextTick();
+
+ expect(findSubmitBtn().props('loading')).toBe(true);
+
+ await waitForPromises();
+
+ expect(findSubmitBtn().props('loading')).toBe(false);
+ });
+ });
+});
diff --git a/spec/lib/gitlab/database/async_foreign_keys/postgres_async_foreign_key_validation_spec.rb b/spec/lib/gitlab/database/async_foreign_keys/postgres_async_foreign_key_validation_spec.rb
index ba201d93f52..40ab9cb2dd2 100644
--- a/spec/lib/gitlab/database/async_foreign_keys/postgres_async_foreign_key_validation_spec.rb
+++ b/spec/lib/gitlab/database/async_foreign_keys/postgres_async_foreign_key_validation_spec.rb
@@ -14,7 +14,7 @@ RSpec.describe Gitlab::Database::AsyncForeignKeys::PostgresAsyncForeignKeyValida
subject { fk_validation }
it { is_expected.to validate_presence_of(:name) }
- it { is_expected.to validate_uniqueness_of(:name) }
+ it { is_expected.to validate_uniqueness_of(:name).scoped_to(:table_name) }
it { is_expected.to validate_length_of(:name).is_at_most(identifier_limit) }
it { is_expected.to validate_presence_of(:table_name) }
it { is_expected.to validate_length_of(:table_name).is_at_most(identifier_limit) }
diff --git a/spec/requests/api/graphql/project/flow_metrics_spec.rb b/spec/requests/api/graphql/project/flow_metrics_spec.rb
new file mode 100644
index 00000000000..0bdf7bad8db
--- /dev/null
+++ b/spec/requests/api/graphql/project/flow_metrics_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'getting project flow metrics', feature_category: :value_stream_management do
+ include GraphqlHelpers
+
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project1) { create(:project, group: group) }
+ # This is done so we can use the same count expectations in the shared examples and
+ # reuse the shared example for the group-level test.
+ let_it_be(:project2) { project1 }
+ let_it_be(:current_user) { create(:user, maintainer_projects: [project1]) }
+
+ it_behaves_like 'value stream analytics flow metrics issueCount examples' do
+ let(:full_path) { project1.full_path }
+ let(:context) { :project }
+ end
+end
diff --git a/spec/services/system_notes/commit_service_spec.rb b/spec/services/system_notes/commit_service_spec.rb
index 0399603980d..8dfb83f63fe 100644
--- a/spec/services/system_notes/commit_service_spec.rb
+++ b/spec/services/system_notes/commit_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe SystemNotes::CommitService do
+RSpec.describe SystemNotes::CommitService, feature_category: :code_review_workflow do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, :repository, group: group) }
let_it_be(:author) { create(:user) }
@@ -13,7 +13,7 @@ RSpec.describe SystemNotes::CommitService do
subject { commit_service.add_commits(new_commits, old_commits, oldrev) }
let(:noteable) { create(:merge_request, source_project: project, target_project: project) }
- let(:new_commits) { noteable.commits }
+ let(:new_commits) { create_commits(10) }
let(:old_commits) { [] }
let(:oldrev) { nil }
@@ -43,6 +43,48 @@ RSpec.describe SystemNotes::CommitService do
expect(decoded_note_content).to include("<li>#{commit.short_id} - #{commit.title}</li>")
end
end
+
+ context 'with HTML content' do
+ let(:new_commits) { [double(title: '<pre>This is a test</pre>', short_id: '12345678')] }
+
+ it 'escapes HTML titles' do
+ expect(note_lines[1]).to eq("<ul><li>12345678 - &lt;pre&gt;This is a test&lt;/pre&gt;</li></ul>")
+ end
+ end
+
+ context 'with one commit exceeding the NEW_COMMIT_DISPLAY_LIMIT' do
+ let(:new_commits) { create_commits(11) }
+ let(:earlier_commit_summary_line) { note_lines[1] }
+
+ it 'includes the truncated new commits summary' do
+ expect(earlier_commit_summary_line).to start_with("<ul><li>#{new_commits[0].short_id} - 1 earlier commit")
+ end
+
+ context 'with oldrev' do
+ let(:oldrev) { '12345678abcd' }
+
+ it 'includes the truncated new commits summary with the oldrev' do
+ expect(earlier_commit_summary_line).to start_with("<ul><li>#{new_commits[0].short_id} - 1 earlier commit")
+ end
+ end
+ end
+
+ context 'with multiple commits exceeding the NEW_COMMIT_DISPLAY_LIMIT' do
+ let(:new_commits) { create_commits(13) }
+ let(:earlier_commit_summary_line) { note_lines[1] }
+
+ it 'includes the truncated new commits summary' do
+ expect(earlier_commit_summary_line).to start_with("<ul><li>#{new_commits[0].short_id}..#{new_commits[2].short_id} - 3 earlier commits")
+ end
+
+ context 'with oldrev' do
+ let(:oldrev) { '12345678abcd' }
+
+ it 'includes the truncated new commits summary with the oldrev' do
+ expect(earlier_commit_summary_line).to start_with("<ul><li>12345678...#{new_commits[2].short_id} - 3 earlier commits")
+ end
+ end
+ end
end
describe 'summary line for existing commits' do
@@ -54,6 +96,15 @@ RSpec.describe SystemNotes::CommitService do
it 'includes the existing commit' do
expect(summary_line).to start_with("<ul><li>#{old_commits.first.short_id} - 1 commit from branch <code>feature</code>")
end
+
+ context 'with new commits exceeding the display limit' do
+ let(:summary_line) { note_lines[1] }
+ let(:new_commits) { create_commits(13) }
+
+ it 'includes the existing commit as well as the truncated new commit summary' do
+ expect(summary_line).to start_with("<ul><li>#{old_commits.first.short_id} - 1 commit from branch <code>feature</code></li><li>#{old_commits.last.short_id}...#{new_commits[2].short_id} - 3 earlier commits")
+ end
+ end
end
context 'with multiple existing commits' do
@@ -66,6 +117,15 @@ RSpec.describe SystemNotes::CommitService do
expect(summary_line)
.to start_with("<ul><li>#{Commit.truncate_sha(oldrev)}...#{old_commits.last.short_id} - 26 commits from branch <code>feature</code>")
end
+
+ context 'with new commits exceeding the display limit' do
+ let(:new_commits) { create_commits(13) }
+
+ it 'includes the existing commit as well as the truncated new commit summary' do
+ expect(summary_line)
+ .to start_with("<ul><li>#{Commit.truncate_sha(oldrev)}...#{old_commits.last.short_id} - 26 commits from branch <code>feature</code></li><li>#{old_commits.last.short_id}...#{new_commits[2].short_id} - 3 earlier commits")
+ end
+ end
end
context 'without oldrev' do
@@ -73,6 +133,15 @@ RSpec.describe SystemNotes::CommitService do
expect(summary_line)
.to start_with("<ul><li>#{old_commits[0].short_id}..#{old_commits[-1].short_id} - 26 commits from branch <code>feature</code>")
end
+
+ context 'with new commits exceeding the display limit' do
+ let(:new_commits) { create_commits(13) }
+
+ it 'includes the existing commit as well as the truncated new commit summary' do
+ expect(summary_line)
+ .to start_with("<ul><li>#{old_commits.first.short_id}..#{old_commits.last.short_id} - 26 commits from branch <code>feature</code></li><li>#{old_commits.last.short_id}...#{new_commits[2].short_id} - 3 earlier commits")
+ end
+ end
end
context 'on a fork' do
@@ -106,12 +175,9 @@ RSpec.describe SystemNotes::CommitService do
end
end
- describe '#new_commit_summary' do
- it 'escapes HTML titles' do
- commit = double(title: '<pre>This is a test</pre>', short_id: '12345678')
- escaped = '&lt;pre&gt;This is a test&lt;/pre&gt;'
-
- expect(described_class.new.new_commit_summary([commit])).to all(match(/- #{escaped}/))
+ def create_commits(count)
+ Array.new(count) do |i|
+ double(title: "Test commit #{i}", short_id: "abcd00#{i}")
end
end
end
diff --git a/spec/support/shared_examples/analytics/cycle_analytics/flow_metrics_examples.rb b/spec/support/shared_examples/analytics/cycle_analytics/flow_metrics_examples.rb
new file mode 100644
index 00000000000..046036c40ba
--- /dev/null
+++ b/spec/support/shared_examples/analytics/cycle_analytics/flow_metrics_examples.rb
@@ -0,0 +1,124 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'value stream analytics flow metrics issueCount examples' do
+ let_it_be(:milestone) { create(:milestone, group: group) }
+ let_it_be(:label) { create(:group_label, group: group) }
+
+ let_it_be(:author) { create(:user) }
+ let_it_be(:assignee) { create(:user) }
+
+ let_it_be(:issue1) { create(:issue, project: project1, author: author, created_at: 12.days.ago) }
+ let_it_be(:issue2) { create(:issue, project: project2, author: author, created_at: 13.days.ago) }
+
+ let_it_be(:issue3) do
+ create(:labeled_issue,
+ project: project1,
+ labels: [label],
+ author: author,
+ milestone: milestone,
+ assignees: [assignee],
+ created_at: 14.days.ago)
+ end
+
+ let_it_be(:issue4) do
+ create(:labeled_issue,
+ project: project2,
+ labels: [label],
+ assignees: [assignee],
+ created_at: 15.days.ago)
+ end
+
+ let_it_be(:issue_outside_of_the_range) { create(:issue, project: project2, author: author, created_at: 50.days.ago) }
+
+ let(:query) do
+ <<~QUERY
+ query($path: ID!, $assigneeUsernames: [String!], $authorUsername: String, $milestoneTitle: String, $labelNames: [String!], $from: Time!, $to: Time!) {
+ #{context}(fullPath: $path) {
+ flowMetrics {
+ issueCount(assigneeUsernames: $assigneeUsernames, authorUsername: $authorUsername, milestoneTitle: $milestoneTitle, labelNames: $labelNames, from: $from, to: $to) {
+ value
+ unit
+ identifier
+ title
+ }
+ }
+ }
+ }
+ QUERY
+ end
+
+ let(:variables) do
+ {
+ path: full_path,
+ from: 20.days.ago.iso8601,
+ to: 10.days.ago.iso8601
+ }
+ end
+
+ subject(:result) do
+ post_graphql(query, current_user: current_user, variables: variables)
+
+ graphql_data.dig(context.to_s, 'flowMetrics', 'issueCount')
+ end
+
+ it 'returns the correct count' do
+ expect(result).to eq({
+ 'identifier' => 'issues',
+ 'unit' => nil,
+ 'value' => 4,
+ 'title' => n_('New Issue', 'New Issues', 4)
+ })
+ end
+
+ context 'with partial filters' do
+ let(:variables) do
+ {
+ path: full_path,
+ assigneeUsernames: [assignee.username],
+ labelNames: [label.title],
+ from: 20.days.ago.iso8601,
+ to: 10.days.ago.iso8601
+ }
+ end
+
+ it 'returns filtered count' do
+ expect(result).to eq({
+ 'identifier' => 'issues',
+ 'unit' => nil,
+ 'value' => 2,
+ 'title' => n_('New Issue', 'New Issues', 2)
+ })
+ end
+ end
+
+ context 'with all filters' do
+ let(:variables) do
+ {
+ path: full_path,
+ assigneeUsernames: [assignee.username],
+ labelNames: [label.title],
+ authorUsername: author.username,
+ milestoneTitle: milestone.title,
+ from: 20.days.ago.iso8601,
+ to: 10.days.ago.iso8601
+ }
+ end
+
+ it 'returns filtered count' do
+ expect(result).to eq({
+ 'identifier' => 'issues',
+ 'unit' => nil,
+ 'value' => 1,
+ 'title' => n_('New Issue', 'New Issues', 1)
+ })
+ end
+ end
+
+ context 'when the user is not authorized' do
+ let(:current_user) { create(:user) }
+
+ it 'returns nil' do
+ expect(result).to eq(nil)
+ end
+ end
+end