Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2025-01-28 00:38:14 +00:00
parent 08b69d2326
commit bc5c433ff1
39 changed files with 1157 additions and 61 deletions

View File

@ -2,7 +2,7 @@
# project here: https://gitlab.com/gitlab-org/gitlab/-/project_members
# As described in https://docs.gitlab.com/ee/user/project/codeowners/index.html
* @gitlab-org/maintainers/rails-backend @gitlab-org/maintainers/frontend @gitlab-org/maintainers/database @gl-dx/qe-maintainers @gl-dx/tooling-maintainers @gitlab-org/delivery @gitlab-org/maintainers/cicd-templates @gitlab-org/tw-leadership @gitlab-org/maintainers/kas-version-maintainers
* @gitlab-org/maintainers/rails-backend @gitlab-org/maintainers/frontend @gitlab-org/maintainers/database @gl-dx/maintainers @gl-dx/tooling-maintainers @gitlab-org/delivery @gitlab-org/maintainers/cicd-templates @gitlab-org/tw-leadership @gitlab-org/maintainers/kas-version-maintainers
.gitlab/CODEOWNERS @gitlab-org/development-leaders @gitlab-org/tw-leadership
@ -110,13 +110,13 @@ config/bounded_contexts.yml @fabiopitino @grzesiek @stanhu @cwoolley-gitlab @tku
/.gitlab/ci/
/.gitlab/ci/docs.gitlab-ci.yml @gl-pipeline-maintainers @gl-docsteam
/.gitlab/ci/frontend.gitlab-ci.yml @gl-pipeline-maintainers @gitlab-org/maintainers/frontend
/.gitlab/ci/test-on-omnibus/ @gl-pipeline-maintainers @gl-dx/qe-maintainers
/.gitlab/ci/qa.gitlab-ci.yml @gl-pipeline-maintainers @gl-dx/qe-maintainers
/.gitlab/ci/qa-common/ @gl-pipeline-maintainers @gl-dx/qe-maintainers
/.gitlab/ci/test-on-omnibus/ @gl-pipeline-maintainers @gl-dx/maintainers
/.gitlab/ci/qa.gitlab-ci.yml @gl-pipeline-maintainers @gl-dx/maintainers
/.gitlab/ci/qa-common/ @gl-pipeline-maintainers @gl-dx/maintainers
/.gitlab/ci/releases.gitlab-ci.yml @gl-pipeline-maintainers @gitlab-org/delivery
/.gitlab/ci/reports.gitlab-ci.yml @gl-pipeline-maintainers @gitlab-com/gl-security/appsec
/.gitlab/ci/review-apps/qa.gitlab-ci.yml @gl-pipeline-maintainers @gl-dx/qe-maintainers
/.gitlab/ci/test-on-gdk/ @gl-pipeline-maintainers @gl-dx/qe-maintainers
/.gitlab/ci/review-apps/qa.gitlab-ci.yml @gl-pipeline-maintainers @gl-dx/maintainers
/.gitlab/ci/test-on-gdk/ @gl-pipeline-maintainers @gl-dx/maintainers
/gems/gem.gitlab-ci.yml
[Tooling] @gl-dx/eng-prod
@ -1749,7 +1749,7 @@ ee/app/workers/ai/repository_xray/
/ee/spec/fixtures/remote_development/
/ee/spec/controllers/remote_development/
/ee/spec/services/remote_development/
/qa/qa/specs/features/**/remote_development/ @gitlab-org/maintainers/workspaces/backend @gl-dx/qe-maintainers
/qa/qa/specs/features/**/remote_development/ @gitlab-org/maintainers/workspaces/backend @gl-dx/maintainers
^[Create::IDE - Remote Development Frontend] @gitlab-org/maintainers/workspaces/frontend
/app/assets/javascripts/ide/components/shared/tokened_input.vue

View File

@ -14,6 +14,8 @@ import {
WIDGET_TYPE_CUSTOM_FIELDS,
CUSTOM_FIELDS_TYPE_NUMBER,
CUSTOM_FIELDS_TYPE_TEXT,
CUSTOM_FIELDS_TYPE_SINGLE_SELECT,
CUSTOM_FIELDS_TYPE_MULTI_SELECT,
} from '~/work_items/constants';
import isExpandedHierarchyTreeChildQuery from '~/work_items/graphql/client/is_expanded_hierarchy_tree_child.query.graphql';
@ -234,6 +236,182 @@ export const config = {
value: '',
__typename: 'LocalWorkItemTextFieldValue',
},
{
id: 'gid://gitlab/CustomFieldValue/8',
customField: {
id: '1-select',
fieldType: CUSTOM_FIELDS_TYPE_SINGLE_SELECT,
// eslint-disable-next-line @gitlab/require-i18n-strings
name: 'Single select custom field label',
selectOptions: [
{
id: 'select-1',
value:
// eslint-disable-next-line @gitlab/require-i18n-strings
'Option 1 is longlonglongonglonglonglonglonglong',
},
{
id: 'select-2',
// eslint-disable-next-line @gitlab/require-i18n-strings
value: 'Option 2',
},
{
id: 'select-3',
// eslint-disable-next-line @gitlab/require-i18n-strings
value: 'Option 3',
},
],
__typename: 'LocalWorkItemCustomFieldSelect',
},
selectedOptions: [
{
id: 'select-1',
value:
// eslint-disable-next-line @gitlab/require-i18n-strings
'Option 1 is longlonglongonglonglonglonglonglong',
},
],
__typename: 'LocalWorkItemSelectFieldValue',
},
{
id: 'gid://gitlab/CustomFieldValue/9',
customField: {
id: '2-select',
fieldType: CUSTOM_FIELDS_TYPE_SINGLE_SELECT,
// eslint-disable-next-line @gitlab/require-i18n-strings
name: 'Null Single select custom field label',
selectOptions: [
{
id: 'select-1',
value:
// eslint-disable-next-line @gitlab/require-i18n-strings
'Option 1 is long long lo ng long long long long long long',
},
{
id: 'select-2',
// eslint-disable-next-line @gitlab/require-i18n-strings
value: 'Option 2',
},
{
id: 'select-3',
// eslint-disable-next-line @gitlab/require-i18n-strings
value: 'Option 3',
},
],
__typename: 'LocalWorkItemCustomFieldSelect',
},
selectedOptions: null,
__typename: 'LocalWorkItemSelectFieldValue',
},
{
id: 'gid://gitlab/CustomFieldValue/10',
customField: {
id: '1-multi-select',
fieldType: CUSTOM_FIELDS_TYPE_MULTI_SELECT,
// eslint-disable-next-line @gitlab/require-i18n-strings
name: 'Multi select custom field label',
selectOptions: [
{
id: 'select-1',
value:
// eslint-disable-next-line @gitlab/require-i18n-strings
'Option 1 is long long lo ng long long long long long long',
},
{
id: 'select-2',
// eslint-disable-next-line @gitlab/require-i18n-strings
value: 'Option 2',
},
{
id: 'select-3',
// eslint-disable-next-line @gitlab/require-i18n-strings
value: 'Option 3',
},
],
__typename: 'LocalWorkItemCustomFieldSelect',
},
selectedOptions: [
{
id: 'select-1',
value:
// eslint-disable-next-line @gitlab/require-i18n-strings
'Option 1 is long long lo ng long long long long long long',
},
{
id: 'select-2',
// eslint-disable-next-line @gitlab/require-i18n-strings
value: 'Option 2',
},
],
__typename: 'LocalWorkItemSelectFieldValue',
},
{
id: 'gid://gitlab/CustomFieldValue/11',
customField: {
id: '2-multi-select',
fieldType: CUSTOM_FIELDS_TYPE_MULTI_SELECT,
// eslint-disable-next-line @gitlab/require-i18n-strings
name: 'Null Multi select custom field label',
selectOptions: [
{
id: 'select-1',
value:
// eslint-disable-next-line @gitlab/require-i18n-strings
'Option 1 is long long lo ng long long long long long long',
},
{
id: 'select-2',
// eslint-disable-next-line @gitlab/require-i18n-strings
value: 'Option 2',
},
{
id: 'select-3',
// eslint-disable-next-line @gitlab/require-i18n-strings
value: 'Option 3',
},
],
__typename: 'LocalWorkItemCustomFieldSelect',
},
selectedOptions: null,
__typename: 'LocalWorkItemSelectFieldValue',
},
{
id: 'gid://gitlab/CustomFieldValue/12',
customField: {
id: '3-multi-select',
fieldType: CUSTOM_FIELDS_TYPE_MULTI_SELECT,
// eslint-disable-next-line @gitlab/require-i18n-strings
name: 'One selected Multi select custom field label',
selectOptions: [
{
id: 'select-1',
value:
// eslint-disable-next-line @gitlab/require-i18n-strings
'Option 1 is long long lo ng long long long long long long',
},
{
id: 'select-2',
// eslint-disable-next-line @gitlab/require-i18n-strings
value: 'Option 2',
},
{
id: 'select-3',
// eslint-disable-next-line @gitlab/require-i18n-strings
value: 'Option 3',
},
],
__typename: 'LocalWorkItemCustomFieldSelect',
},
selectedOptions: [
{
id: 'select-1',
value:
// eslint-disable-next-line @gitlab/require-i18n-strings
'Option 1 is long long lo ng long long long long long long',
},
],
__typename: 'LocalWorkItemSelectFieldValue',
},
],
},
];

View File

@ -0,0 +1,10 @@
query getBlobSearchCountQuery(
$search: String!
$groupId: GroupID
$projectId: ProjectID
$regex: Boolean
) {
blobSearch(search: $search, groupId: $groupId, projectId: $projectId, regex: $regex) {
matchCount
}
}

View File

@ -1,10 +1,16 @@
<script>
// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters } from 'vuex';
import { mapActions, mapGetters, mapState, mapMutations } from 'vuex';
import * as Sentry from '~/sentry/sentry_browser_wrapper';
import { s__ } from '~/locale';
import { parseBoolean } from '~/lib/utils/common_utils';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { TYPENAME_GROUP, TYPENAME_PROJECT } from '~/graphql_shared/constants';
import NavItem from '~/super_sidebar/components/nav_item.vue';
import MenuSection from '~/super_sidebar/components/menu_section.vue';
import getBlobSearchCountQuery from '~/search/graphql/blob_search_zoekt_count_only.query.graphql';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { RECEIVE_NAVIGATION_COUNT } from '../../store/mutation_types';
import { NAV_LINK_DEFAULT_CLASSES, NAV_LINK_COUNT_DEFAULT_CLASSES } from '../constants';
export default {
@ -17,19 +23,52 @@ export default {
MenuSection,
},
mixins: [glFeatureFlagsMixin()],
apollo: {
blobSearchCount: {
query: getBlobSearchCountQuery,
variables() {
return {
search: this.query.search,
groupId: this.query?.group_id && convertToGraphQLId(TYPENAME_GROUP, this.query.group_id),
projectId:
this.query?.project_id && convertToGraphQLId(TYPENAME_PROJECT, this.query.project_id),
regex: parseBoolean(this.query?.regex),
};
},
skip() {
return !(
(this.query?.group_id || this.query?.project_id) &&
this.glFeatures.zoektMultimatchFrontend &&
this.zoektAvailable
);
},
update(data) {
this.receiveNavigationCount({
key: 'blobs',
count: data?.blobSearch?.matchCount.toString(),
});
},
error(error) {
Sentry.captureException(error);
},
},
},
data() {
return {
showFlyoutMenus: false,
blobSearchCount: null,
};
},
computed: {
...mapGetters(['navigationItems']),
...mapState(['zoektAvailable', 'query']),
},
created() {
this.fetchSidebarCount();
},
methods: {
...mapActions(['fetchSidebarCount']),
...mapMutations({ receiveNavigationCount: RECEIVE_NAVIGATION_COUNT }),
showWorkItems(subitems = []) {
return this.glFeatures.workItemScopeFrontend && subitems.length > 0;
},
@ -52,7 +91,7 @@ export default {
:expanded="true"
tag="li"
/>
<nav-item v-else :key="item.id" :item="item" />
<nav-item v-else :key="`navItem-${item.id}`" :item="item" />
</template>
</ul>
</nav>

View File

@ -1,8 +1,15 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { defaultClient } from '~/graphql_shared/issuable_client';
import Translate from '~/vue_shared/translate';
import GlobalSearchSidebar from './components/app.vue';
Vue.use(Translate);
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient,
});
export const initSidebar = (store) => {
const el = document.getElementById('js-search-sidebar');
@ -13,6 +20,7 @@ export const initSidebar = (store) => {
el,
name: 'GlobalSearchSidebar',
store,
apolloProvider,
render(createElement) {
return createElement(GlobalSearchSidebar);
},

View File

@ -22,6 +22,7 @@ import {
getAggregationsUrl,
prepareSearchAggregations,
setDataToLS,
skipBlobESCount,
} from './utils';
export const fetchGroups = ({ commit }, search) => {
@ -158,7 +159,12 @@ export const setLabelFilterSearch = ({ commit }, { value }) => {
export const fetchSidebarCount = ({ commit, state }) => {
const items = Object.values(state.navigation)
.filter((navigationItem) => !navigationItem.active && navigationItem.count_link)
.filter(
(navigationItem) =>
!navigationItem.active &&
navigationItem.count_link &&
skipBlobESCount(state, navigationItem.scope),
)
.map((navItem) => {
const navigationItem = { ...navItem };
const modifications = {

View File

@ -2,7 +2,11 @@ import { isEqual, orderBy, isEmpty } from 'lodash';
import AccessorUtilities from '~/lib/utils/accessor';
import { formatNumber } from '~/locale';
import { joinPaths, queryToObject, objectToQuery, getBaseURL } from '~/lib/utils/url_utility';
import { LABEL_AGREGATION_NAME, LANGUAGE_FILTER_PARAM } from '~/search/sidebar/constants';
import {
LABEL_AGREGATION_NAME,
LANGUAGE_FILTER_PARAM,
SCOPE_BLOB,
} from '~/search/sidebar/constants';
import {
MAX_FREQUENT_ITEMS,
MAX_FREQUENCY,
@ -207,3 +211,11 @@ export const scopeCrawler = (navigation, parentScope = null) => {
return null;
};
export const skipBlobESCount = (state, itemScope) =>
!(
(state.query?.group_id || state.query?.project_id) &&
window.gon.features?.zoektMultimatchFrontend &&
state.zoektAvailable &&
itemScope === SCOPE_BLOB
);

View File

@ -78,6 +78,7 @@ export default {
key: FIELD_NAME,
label: __('Name'),
thClass: this.columnWidths[FIELD_NAME],
isRowHeader: true,
},
{
key: FIELD_ORGANIZATION_ROLE,
@ -128,7 +129,7 @@ export default {
:tbody-tr-attr="{ 'data-testid': 'user-row-content' }"
>
<template #cell(name)="{ item: user }">
<user-avatar :user="user" :admin-user-path="adminUserPath" />
<user-avatar :user="user" :admin-user-path="adminUserPath" class="gl-font-normal" />
</template>
<template v-if="$scopedSlots['organization-role']" #cell(organizationRole)="{ item: user }">

View File

@ -19,7 +19,6 @@ import {
WIDGET_TYPE_COLOR,
WIDGET_TYPE_CRM_CONTACTS,
WORK_ITEM_TYPE_VALUE_EPIC,
WIDGET_TYPE_CUSTOM_FIELDS,
} from '../constants';
import workItemParticipantsQuery from '../graphql/work_item_participants.query.graphql';
@ -50,8 +49,6 @@ export default {
WorkItemColor: () => import('ee_component/work_items/components/work_item_color.vue'),
WorkItemRolledupDates: () =>
import('ee_component/work_items/components/work_item_rolledup_dates.vue'),
WorkItemCustomFields: () =>
import('ee_component/work_items/components/work_item_custom_fields.vue'),
},
mixins: [glFeatureFlagMixin()],
inject: ['hasSubepicsFeature'],
@ -168,13 +165,6 @@ export default {
workItemCrmContacts() {
return this.isWidgetPresent(WIDGET_TYPE_CRM_CONTACTS) && this.glFeatures.workItemsAlpha;
},
workItemCustomFields() {
// @todo: Added flag and mocked CUSTOM_FIELDS widget while not suported by backend
return (
this.glFeatures.customFieldsFeature &&
this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_CUSTOM_FIELDS)
);
},
},
methods: {
isWidgetPresent(type, workItem = this.workItem) {
@ -316,13 +306,6 @@ export default {
@error="$emit('error', $event)"
/>
</template>
<work-item-custom-fields
v-if="workItemCustomFields"
:custom-field-values="workItemCustomFields.customFieldValues"
:work-item-type="workItemType"
:full-path="fullPath"
:can-update="canUpdate"
/>
<template v-if="workItemHierarchy && showParent">
<work-item-parent
class="work-item-attributes-item"

View File

@ -40,8 +40,8 @@ class ProjectCiCdSetting < ApplicationRecord
allow_nil: true,
numericality: {
only_integer: true,
greater_than_or_equal_to: 1.day.seconds,
less_than_or_equal_to: 1.year.seconds,
greater_than_or_equal_to: ChronicDuration.parse('1 day'),
less_than_or_equal_to: ChronicDuration.parse('1 year'),
message: N_('must be between 1 day and 1 year')
}

View File

@ -82,7 +82,7 @@ module WorkItems
# TODO: review validation rules
# https://gitlab.com/gitlab-org/gitlab/-/issues/336919
validates :name, presence: true
validates :name, uniqueness: { case_sensitive: false }
validates :name, custom_uniqueness: { unique_sql: 'TRIM(BOTH FROM lower(?))' }
validates :name, length: { maximum: 255 }
validates :icon_name, length: { maximum: 255 }

View File

@ -7,7 +7,7 @@ module WorkItems
belongs_to :work_item_type, class_name: 'WorkItems::Type', inverse_of: :widget_definitions
validates :name, presence: true
validates :name, uniqueness: { case_sensitive: false, scope: :work_item_type_id }
validates :name, custom_uniqueness: { unique_sql: 'TRIM(BOTH FROM lower(?))', scope: :work_item_type_id }
validates :name, length: { maximum: 255 }
validates :widget_options, if: :weight?,

View File

@ -0,0 +1,10 @@
# frozen_string_literal: true
module FeatureFlags
class ClientConfigurationEntity < Grape::Entity
include RequestAwareEntity
expose :id
expose :project_id
end
end

View File

@ -0,0 +1,130 @@
# frozen_string_literal: true
# CustomUniquenessValidator
#
# Custom validator for unique values in the DB. Allows specifying custom SQL to compare existing values.
# Validator doesn't support uniqueness on associations as the default ActiveRecord uniqueness validator does.
#
# ActiveRecord's default uniqueness validator only supports uniqueness queries like the following:
#
# Case insensitive without scope:
# SELECT 1 AS one FROM "work_item_types" WHERE LOWER("work_item_types"."name") = LOWER('Test') LIMIT 1
#
# Case insensitive scoped:
#
# SELECT
# 1 AS one
# FROM
# "work_item_widget_definitions"
# WHERE
# LOWER(
# "work_item_widget_definitions"."name"
# ) = LOWER('different')
# AND "work_item_widget_definitions"."work_item_type_id" = 5
# LIMIT
# 1
# With this validator you can replace parts of the query with custom sql. Examples:
# class WorkItems::Type < ActiveRecord::Base
# validates :name, custom_uniqueness: { unique_sql: 'TRIM(BOTH FROM lower(?))' }
# end
#
# This will generate a query like:
# SELECT
# 1 AS one
# FROM "work_item_types" WHERE (TRIM(BOTH FROM lower(work_item_types.name)) = TRIM(BOTH FROM lower('Test'))) LIMIT 1
#
#
# class WorkItems::WidgetDefinition < ActiveRecord::Base
# validates :name, custom_uniqueness: { unique_sql: 'TRIM(BOTH FROM lower(?))', scope: :work_item_type_id }
# end
# This will generate a query like:
#
# SELECT
# 1 AS one
# FROM
# "work_item_widget_definitions"
# WHERE
# (
# TRIM(
# BOTH
# FROM
# lower(
# work_item_widget_definitions.name
# )
# ) = TRIM(
# BOTH
# FROM
# lower('test')
# )
# )
# AND "work_item_widget_definitions"."work_item_type_id" = 5
# LIMIT
# 1
#
# rubocop:disable CodeReuse/ActiveRecord -- Validator used in models
class CustomUniquenessValidator < ActiveModel::EachValidator # rubocop:disable Gitlab/BoundedContexts,Gitlab/NamespacedClass -- Validators can belong to multiple bounded contexts
include ActiveRecord::ConnectionAdapters::Quoting
include Gitlab::Utils::StrongMemoize
def initialize(options)
@unique_sql = options[:unique_sql]
@scope_values = Array(options[:scope])
@table_name = options[:class].table_name
super
end
def validate_each(record, attribute, value)
return unless validation_needed?(record, attribute) && record_exists?(record, attribute, value)
record.errors.add(attribute, :taken)
end
def check_validity!
super
return unless unique_sql.blank?
raise ArgumentError, '`unique_sql` option must be provided to the `custom_uniqueness` validator'
end
private
attr_reader :unique_sql, :table_name, :scope_values
def parsed_sql(attribute)
strong_memoize_with(:parsed_sql, attribute) do
"#{column_sql(attribute)} = #{unique_sql}"
end
end
def column_sql(attribute)
unique_sql.gsub('?', "#{quote_table_name(table_name)}.#{quote_column_name(attribute)}")
end
def record_exists?(record, attribute, value)
relation = record.class.where(
parsed_sql(attribute),
value
)
relation = relation.where.not(record.class.primary_key => record.to_key.first) if record.persisted?
scope_relation(record, relation).exists?
end
def scope_relation(record, relation)
scope_values.each do |scope_item|
scope_value = record.read_attribute(scope_item)
relation = relation.where(scope_item => scope_value)
end
relation
end
def validation_needed?(record, attribute)
attributes = scope_values + [attribute]
attributes.any? { |attr| record.attribute_changed?(attr) || record.read_attribute(attr).nil? }
end
end
# rubocop:enable CodeReuse/ActiveRecord

View File

@ -32,6 +32,7 @@ Prerequisites:
> - [Runner authentication tokens added](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/173987) in GitLab 17.7.
> - [Pipeline trigger tokens added](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/174030) in GitLab 17.7.
> - [CI/CD Job Tokens added](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/175234) in GitLab 17.9.
> - [Feature flags client tokens added](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/177431) in GitLab 17.9.
Gets information for a given token. This endpoint supports the following tokens:
@ -44,6 +45,7 @@ Gets information for a given token. This endpoint supports the following tokens:
- [Runner authentication tokens](../../security/tokens/index.md#runner-authentication-tokens)
- [Pipeline trigger tokens](../../ci/triggers/index.md#create-a-pipeline-trigger-token)
- [CI/CD Job Tokens](../../security/tokens/index.md#cicd-job-tokens)
- [Feature flags client tokens](../../operations/feature_flags.md#get-access-credentials)
```plaintext
POST /api/v4/admin/token

View File

@ -1952,6 +1952,67 @@ NOTE:
This endpoint is subject to [Merge requests diff limits](../administration/instance_limits.md#diff-limits).
Merge requests that exceed the diff limits return limited results.
## Show merge request raw diffs
Show raw diffs of the files changed in a merge request.
```plaintext
GET /projects/:id/merge_requests/:merge_request_iid/raw_diffs
```
Supported attributes:
| Attribute | Type | Required | Description |
|---------------------|-------------------|----------|-------------|
| `id` | integer or string | Yes | The ID or [URL-encoded path of the project](rest/index.md#namespaced-paths). |
| `merge_request_iid` | integer | Yes | The internal ID of the merge request. |
If successful, returns [`200 OK`](rest/troubleshooting.md#status-codes) and a raw diff response to use programmatically:
Example request:
```shell
curl --header "PRIVATE-TOKEN: <your_access_token>" \
--url "https://gitlab.example.com/api/v4/projects/1/merge_requests/1/raw_diffs"
```
Example response:
```diff
diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb
index 31525ad523553c8d7eff163db3e539058efd6d3a..f30e36d6fdf4cd4fa25f62e08ecdbf4a7b169681 100644
--- a/lib/api/helpers.rb
+++ b/lib/api/helpers.rb
@@ -944,6 +944,10 @@ def send_git_blob(repository, blob)
body ''
end
+ def send_git_diff(repository, diff_refs)
+ header(*Gitlab::Workhorse.send_git_diff(repository, diff_refs))
+ end
+
def send_git_archive(repository, **kwargs)
header(*Gitlab::Workhorse.send_git_archive(repository, **kwargs))
diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb
index e02d9eea1852f19fe5311acda6aa17465eeb422e..f32b38585398a18fea75c11d7b8ebb730eeb3fab 100644
--- a/lib/api/merge_requests.rb
+++ b/lib/api/merge_requests.rb
@@ -6,6 +6,8 @@ class MergeRequests < ::API::Base
include PaginationParams
include Helpers::Unidiff
+ helpers ::API::Helpers::HeadersHelpers
+
CONTEXT_COMMITS_POST_LIMIT = 20
before { authenticate_non_get! }
```
NOTE:
This endpoint is subject to [Merge requests diff limits](../administration/instance_limits.md#diff-limits).
Merge requests that exceed the diff limits return limited results.
## List merge request pipelines
Get a list of merge request pipelines.

View File

@ -944,6 +944,14 @@ module API
body ''
end
def send_git_diff(repository, diff_refs)
header(*Gitlab::Workhorse.send_git_diff(repository, diff_refs))
headers['Content-Disposition'] = 'inline'
body ''
end
def send_git_archive(repository, **kwargs)
header(*Gitlab::Workhorse.send_git_archive(repository, **kwargs))

View File

@ -6,6 +6,8 @@ module API
include PaginationParams
include Helpers::Unidiff
helpers ::API::Helpers::HeadersHelpers
CONTEXT_COMMITS_POST_LIMIT = 20
before { authenticate_non_get! }
@ -588,6 +590,22 @@ module API
present paginate(merge_request.merge_request_diff.paginated_diffs(params[:page], params[:per_page])).diffs, with: Entities::Diff, enable_unidiff: declared_params[:unidiff]
end
desc 'Get the merge request raw diffs' do
detail 'Get the raw diffs of a merge request that can used programmatically.'
failure [
{ code: 403, message: 'Forbidden' },
{ code: 404, message: 'Not found' }
]
tags %w[merge_requests]
end
get ':id/merge_requests/:merge_request_iid/raw_diffs', feature_category: :code_review_workflow, urgency: :low do
merge_request = find_merge_request_with_access(params[:merge_request_iid])
no_cache_headers
send_git_diff(merge_request.project.repository, merge_request.diff_refs)
end
desc 'Get single merge request pipelines' do
detail 'Get a list of merge request pipelines.'
success Entities::Ci::PipelineBasic

View File

@ -12,7 +12,8 @@ module Authn
::Authn::Tokens::ClusterAgentToken,
::Authn::Tokens::RunnerAuthenticationToken,
::Authn::Tokens::CiTriggerToken,
::Authn::Tokens::CiJobToken
::Authn::Tokens::CiJobToken,
::Authn::Tokens::FeatureFlagsClientToken
].freeze
def self.token_for(plaintext, source)

View File

@ -0,0 +1,28 @@
# frozen_string_literal:true
module Authn
module Tokens
class FeatureFlagsClientToken
def self.prefix?(plaintext)
plaintext.start_with?(::Operations::FeatureFlagsClient::FEATURE_FLAGS_CLIENT_TOKEN_PREFIX)
end
attr_reader :revocable, :source
def initialize(plaintext, source)
@revocable = ::Operations::FeatureFlagsClient.find_by_token(plaintext)
@source = source
end
def present_with
::FeatureFlags::ClientConfigurationEntity
end
def revoke!(_current_user)
raise ::Authn::AgnosticTokenIdentifier::NotFoundError, 'Not Found' if revocable.blank?
raise ::Authn::AgnosticTokenIdentifier::UnsupportedTokenError, 'Unsupported token type'
end
end
end
end

View File

@ -199,11 +199,6 @@ msgid_plural "%d characters remaining"
msgstr[0] ""
msgstr[1] ""
msgid "%d character remaining."
msgid_plural "%d characters remaining."
msgstr[0] ""
msgstr[1] ""
msgid "%d child epic"
msgid_plural "%d child epics"
msgstr[0] ""
@ -22258,9 +22253,6 @@ msgstr ""
msgid "Enter one or more user ID separated by commas"
msgstr ""
msgid "Enter text"
msgstr ""
msgid "Enter the %{name} description"
msgstr ""
@ -47234,9 +47226,6 @@ msgstr ""
msgid "Remove user from project"
msgstr ""
msgid "Remove value"
msgstr ""
msgid "Remove variable"
msgstr ""

View File

@ -3,7 +3,7 @@
source 'https://rubygems.org'
gem 'gitlab-qa', '~> 15', '>= 15.2.0', require: 'gitlab/qa'
gem 'gitlab_quality-test_tooling', '~> 2.4.0', require: false
gem 'gitlab_quality-test_tooling', '~> 2.6.0', require: false
gem 'gitlab-utils', path: '../gems/gitlab-utils'
gem 'activesupport', '~> 7.0.8.7' # This should stay in sync with the root's Gemfile
gem 'allure-rspec', '~> 2.25.0'

View File

@ -47,7 +47,7 @@ GEM
tins (~> 1.0)
ast (2.4.2)
base64 (0.2.0)
bigdecimal (3.1.8)
bigdecimal (3.1.9)
binding_of_caller (1.0.0)
debug_inspector (>= 0.0.1)
builder (3.3.0)
@ -128,7 +128,7 @@ GEM
rainbow (>= 3, < 4)
table_print (= 1.5.7)
zeitwerk (>= 2, < 3)
gitlab_quality-test_tooling (2.4.0)
gitlab_quality-test_tooling (2.6.0)
activesupport (>= 7.0, < 7.2)
amatch (~> 0.4.1)
fog-google (~> 1.24, >= 1.24.1)
@ -319,7 +319,7 @@ GEM
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
thor (1.3.1)
tins (1.37.0)
tins (1.38.0)
bigdecimal
sync
trailblazer-option (0.1.2)
@ -368,7 +368,7 @@ DEPENDENCIES
gitlab-cng!
gitlab-qa (~> 15, >= 15.2.0)
gitlab-utils!
gitlab_quality-test_tooling (~> 2.4.0)
gitlab_quality-test_tooling (~> 2.6.0)
googleauth (~> 1.9.0)
influxdb-client (~> 3.2)
junit_merge (~> 0.1.2)

View File

@ -560,7 +560,7 @@ module QA
def wait_for_gitlab_to_respond
wait_until(sleep_interval: 5, message: '502 - GitLab is taking too much time to respond') do
Capybara.page.has_no_text?('GitLab is taking too much time to respond')
Capybara.page.has_no_text?(/GitLab is taking too much time to respond|Waiting for GitLab to boot/)
end
end
end

View File

@ -0,0 +1,20 @@
{
"type": "object",
"required": [
"id",
"project_id"
],
"properties": {
"id": {
"type": [
"integer"
]
},
"project_id": {
"type": [
"integer"
]
}
},
"additionalProperties": false
}

View File

@ -1791,3 +1791,13 @@ export const mockAuthorsAxiosResponse = [
path: '/jane',
},
];
export const mockgetBlobSearchCountQuery = {
data: {
blobSearch: {
fileCount: 10,
matchCount: 123,
__typename: 'BlobSearch',
},
},
};

View File

@ -2,15 +2,28 @@ import { mount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import VueApollo from 'vue-apollo';
import waitForPromises from 'helpers/wait_for_promises';
import createMockApollo from 'helpers/mock_apollo_helper';
import sidebarEventHub from '~/super_sidebar/event_hub';
import ScopeSidebarNavigation from '~/search/sidebar/components/scope_sidebar_navigation.vue';
import NavItem from '~/super_sidebar/components/nav_item.vue';
import { MOCK_QUERY, MOCK_NAVIGATION, MOCK_NAVIGATION_ITEMS } from '../../mock_data';
import * as Sentry from '~/sentry/sentry_browser_wrapper';
import { RECEIVE_NAVIGATION_COUNT } from '~/search/store/mutation_types';
import getBlobSearchCountQuery from '~/search/graphql/blob_search_zoekt_count_only.query.graphql';
import {
MOCK_QUERY,
MOCK_NAVIGATION,
MOCK_NAVIGATION_ITEMS,
mockgetBlobSearchCountQuery,
} from '../../mock_data';
Vue.use(Vuex);
Vue.use(VueApollo);
describe('ScopeSidebarNavigation', () => {
let wrapper;
const mockError = new Error('Network error');
const actionSpies = {
fetchSidebarCount: jest.fn(),
@ -20,10 +33,20 @@ describe('ScopeSidebarNavigation', () => {
navigationItems: jest.fn(() => MOCK_NAVIGATION_ITEMS),
};
const mutationSpies = {
[RECEIVE_NAVIGATION_COUNT]: jest.fn(),
};
const blobCountHandler = jest.fn().mockResolvedValue(mockgetBlobSearchCountQuery);
const mockQueryError = jest.fn().mockRejectedValue(mockError);
const createComponent = (
initialState,
provide = { glFeatures: { workItemScopeFrontend: true } },
gqlHandler = blobCountHandler,
) => {
const requestHandlers = [[getBlobSearchCountQuery, gqlHandler]];
const apolloProvider = createMockApollo(requestHandlers);
const state = {
urlQuery: MOCK_QUERY,
navigation: MOCK_NAVIGATION,
@ -34,9 +57,11 @@ describe('ScopeSidebarNavigation', () => {
state,
actions: actionSpies,
getters: getterSpies,
mutations: mutationSpies,
});
wrapper = mount(ScopeSidebarNavigation, {
apolloProvider,
store,
stubs: {
NavItem,
@ -111,4 +136,128 @@ describe('ScopeSidebarNavigation', () => {
});
});
});
describe('Zoekt graphql count', () => {
beforeEach(() => {
createComponent(
{
zoektAvailable: true,
query: {
search: 'test search',
group_id: '123',
regex: 'false',
},
},
{ glFeatures: { zoektMultimatchFrontend: true } },
);
});
describe('when conditions are met', () => {
it('makes graphql query with correct variables for group search', () => {
expect(blobCountHandler).toHaveBeenCalledWith({
search: 'test search',
groupId: 'gid://gitlab/Group/123',
projectId: undefined,
regex: false,
});
});
it('commits the count to store on successful response', async () => {
await blobCountHandler();
jest.runOnlyPendingTimers();
await waitForPromises();
expect(mutationSpies[RECEIVE_NAVIGATION_COUNT]).toHaveBeenCalledWith(expect.anything(), {
key: 'blobs',
count: '123',
});
});
});
describe('when conditions are not met', () => {
describe('when group_id and project_id are missing', () => {
beforeEach(() => {
blobCountHandler.mockClear();
createComponent(
{
zoektAvailable: true,
query: {
search: 'test',
regex: 'false',
},
},
{ glFeatures: { zoektMultimatchFrontend: true } },
);
});
it('does not make query', () => {
expect(blobCountHandler).not.toHaveBeenCalled();
});
});
describe('when zoektMultimatchFrontend feature is disabled', () => {
beforeEach(() => {
blobCountHandler.mockClear();
createComponent(
{
zoektAvailable: true,
query: {
search: 'test',
regex: 'false',
},
},
{ glFeatures: { zoektMultimatchFrontend: false } },
);
});
it('does not make query', () => {
expect(blobCountHandler).not.toHaveBeenCalled();
});
});
describe('when zoektAvailable is false', () => {
beforeEach(() => {
blobCountHandler.mockClear();
createComponent(
{
zoektAvailable: false,
query: {
search: 'test',
regex: 'false',
},
},
{ glFeatures: { zoektMultimatchFrontend: true } },
);
});
it('does not make query', () => {
expect(blobCountHandler).not.toHaveBeenCalled();
});
});
});
describe('error handling', () => {
beforeEach(async () => {
jest.spyOn(Sentry, 'captureException').mockImplementation();
createComponent(
{
zoektAvailable: true,
query: {
search: 'test search',
group_id: '123',
regex: 'false',
},
},
{ glFeatures: { zoektMultimatchFrontend: true } },
mockQueryError,
);
jest.runOnlyPendingTimers();
await waitForPromises();
});
it('captures exception in Sentry when query fails', () => {
expect(Sentry.captureException).toHaveBeenCalledWith(mockError);
});
});
});
});

View File

@ -16,6 +16,7 @@ import {
addCountOverLimit,
injectRegexSearch,
scopeCrawler,
skipBlobESCount,
} from '~/search/store/utils';
import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
import { TEST_HOST } from 'helpers/test_constants';
@ -386,4 +387,64 @@ describe('Global Search Store Utils', () => {
expect(result).toBe(parentScope);
});
});
describe('skipBlobESCount', () => {
const SCOPE_BLOB = 'blobs';
let state;
beforeEach(() => {
state = {
query: {},
zoektAvailable: false,
};
window.gon = {
features: {
zoektMultimatchFrontend: false,
},
};
});
it('returns true when no group_id or project_id is present', () => {
expect(skipBlobESCount(state, SCOPE_BLOB)).toBe(true);
});
it('returns true when zoektMultimatchFrontend feature flag is off', () => {
state.query.group_id = '1';
state.zoektAvailable = true;
expect(skipBlobESCount(state, SCOPE_BLOB)).toBe(true);
});
it('returns true when zoekt is not available', () => {
state.query.group_id = '1';
window.gon.features.zoektMultimatchFrontend = true;
expect(skipBlobESCount(state, SCOPE_BLOB)).toBe(true);
});
it('returns true when scope is not blob', () => {
state.query.group_id = '1';
state.zoektAvailable = true;
window.gon.features.zoektMultimatchFrontend = true;
expect(skipBlobESCount(state, 'not_blob')).toBe(true);
});
it('returns false when all conditions are met', () => {
state.query.group_id = '1';
state.zoektAvailable = true;
window.gon.features.zoektMultimatchFrontend = true;
expect(skipBlobESCount(state, SCOPE_BLOB)).toBe(false);
});
it('returns false when using project_id instead of group_id', () => {
state.query.project_id = '1';
state.zoektAvailable = true;
window.gon.features.zoektMultimatchFrontend = true;
expect(skipBlobESCount(state, SCOPE_BLOB)).toBe(false);
});
});
});

View File

@ -19,7 +19,7 @@ describe('UsersTable component', () => {
.find('tbody')
.findAll('tr')
.at(trIdx)
.find(`[data-label="${label}"][role="cell"]`);
.find(`[data-label="${label}"]`);
};
const initComponent = (props = {}, scopedSlots = {}) => {

View File

@ -1443,10 +1443,10 @@ export const workItemResponseFactory = ({
id: '1-number',
fieldType: CUSTOM_FIELDS_TYPE_NUMBER,
name: 'Number custom field label',
__typename: 'LocalWorkItemCustomField',
selectOptions: null,
__typename: 'CustomField',
},
value: 5,
__typename: 'LocalWorkItemNumberFieldValue',
},
],
},

View File

@ -22,6 +22,7 @@ RSpec.describe Authn::AgnosticTokenIdentifier, feature_category: :system_access
let_it_be(:cluster_agent_token) { create(:cluster_agent_token, token_encrypted: nil).token }
let_it_be(:runner_authentication_token) { create(:ci_runner, registration_type: :authenticated_user).token }
let_it_be(:ci_trigger_token) { create(:ci_trigger).token }
let_it_be(:feature_flags_client_token) { create(:operations_feature_flags_client).token }
subject(:token) { described_class.token_for(plaintext, :group_token_revocation_service) }
@ -35,6 +36,7 @@ RSpec.describe Authn::AgnosticTokenIdentifier, feature_category: :system_access
ref(:cluster_agent_token) | ::Authn::Tokens::ClusterAgentToken
ref(:runner_authentication_token) | ::Authn::Tokens::RunnerAuthenticationToken
ref(:ci_trigger_token) | ::Authn::Tokens::CiTriggerToken
ref(:feature_flags_client_token) | ::Authn::Tokens::FeatureFlagsClientToken
'unsupported' | NilClass
end

View File

@ -0,0 +1,28 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Authn::Tokens::FeatureFlagsClientToken, feature_category: :system_access do
let_it_be(:user) { create(:user) }
let(:feature_flags_client) { create(:operations_feature_flags_client) }
subject(:token) { described_class.new(plaintext, :api_admin_token) }
context 'with valid Feature Flags Client token' do
let(:plaintext) { feature_flags_client.token }
let(:valid_revocable) { feature_flags_client }
it_behaves_like 'finding the valid revocable'
describe '#revoke!' do
it 'does not support revocation yet' do
expect do
token.revoke!(user)
end.to raise_error(::Authn::AgnosticTokenIdentifier::UnsupportedTokenError, 'Unsupported token type')
end
end
end
it_behaves_like 'token handling with unsupported token type'
end

View File

@ -41,8 +41,8 @@ RSpec.describe ProjectCiCdSetting, feature_category: :continuous_integration do
it 'validates delete_pipelines_in_seconds' do
is_expected.to validate_numericality_of(:delete_pipelines_in_seconds)
.only_integer
.is_greater_than_or_equal_to(1.day.seconds.to_i)
.is_less_than_or_equal_to(1.year.seconds.to_i)
.is_greater_than_or_equal_to(ChronicDuration.parse('1 day'))
.is_less_than_or_equal_to(ChronicDuration.parse('1 year'))
.with_message('must be between 1 day and 1 year')
end
end

View File

@ -195,9 +195,13 @@ RSpec.describe WorkItems::Type, feature_category: :team_planning do
describe 'validation' do
describe 'name uniqueness' do
subject { create(:work_item_type) }
it 'validates uniqueness with a custom validator' do
create(:work_item_type, :non_default, name: 'Test Type')
it { is_expected.to validate_uniqueness_of(:name).case_insensitive }
new_type = build(:work_item_type, :non_default, name: ' TesT Type ')
expect(new_type).to be_invalid
expect(new_type.errors.full_messages).to include('Name has already been taken')
end
end
it { is_expected.not_to allow_value('s' * 256).for(:icon_name) }

View File

@ -47,9 +47,20 @@ RSpec.describe WorkItems::WidgetDefinition, feature_category: :team_planning do
describe 'validations' do
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_uniqueness_of(:name).case_insensitive.scoped_to([:work_item_type_id]) }
it { is_expected.to validate_length_of(:name).is_at_most(255) }
describe 'name uniqueness' do
let_it_be(:test_type) { create(:work_item_type, :non_default, name: 'Test Type') }
let_it_be(:existing_widget) { create(:widget_definition, name: 'TesT Widget', work_item_type: test_type) }
it 'validates uniqueness with a custom validator' do
new_widget = build(:widget_definition, name: ' TesT WIDGET ', work_item_type: test_type)
expect(new_widget).to be_invalid
expect(new_widget.errors.full_messages).to include('Name has already been taken')
end
end
describe 'widget_options' do
subject(:widget_definition) do
build(:widget_definition, widget_type: widget_type, widget_options: widget_options)

View File

@ -54,6 +54,7 @@ RSpec.describe API::Admin::Token, :aggregate_failures, feature_category: :system
let(:impersonation_token) { create(:personal_access_token, :impersonation, user: user) }
let(:ci_trigger) { create(:ci_trigger) }
let(:ci_build) { create(:ci_build, status: :running) }
let(:feature_flags_client) { create(:operations_feature_flags_client) }
let(:plaintext) { nil }
let(:params) { { token: plaintext } }
@ -73,7 +74,8 @@ RSpec.describe API::Admin::Token, :aggregate_failures, feature_category: :system
[ref(:cluster_agent_token), lazy { cluster_agent_token.token }],
[ref(:runner_authentication_token), lazy { runner_authentication_token.token }],
[ref(:impersonation_token), lazy { impersonation_token.token }],
[ref(:ci_trigger), lazy { ci_trigger.token }]
[ref(:ci_trigger), lazy { ci_trigger.token }],
[ref(:feature_flags_client), lazy { feature_flags_client.token }]
]
end

View File

@ -2027,6 +2027,46 @@ RSpec.describe API::MergeRequests, :aggregate_failures, feature_category: :sourc
end
end
describe 'GET /projects/:id/merge_requests/:merge_request_iid/raw_diffs' do
let_it_be(:merge_request) do
create(
:merge_request,
:simple,
author: user,
assignees: [user],
source_project: project,
target_project: project,
source_branch: 'markdown',
title: "Test",
created_at: base_time
)
end
it 'returns a 404 when merge_request_iid not found' do
get api("/projects/#{project.id}/merge_requests/0/raw_diffs", user)
expect(response).to have_gitlab_http_status(:not_found)
end
it 'returns a 404 when merge_request id is used instead of iid' do
get api("/projects/#{project.id}/merge_requests/#{merge_request.id}/raw_diffs", user)
expect(response).to have_gitlab_http_status(:not_found)
end
context 'when merge request author has only guest access' do
it_behaves_like 'rejects user from accessing merge request info' do
let(:url) { "/projects/#{project.id}/merge_requests/#{merge_request.iid}/raw_diffs" }
end
end
it 'returns the a workhorse git-diff url' do
get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/raw_diffs", user)
expect(response).to have_gitlab_http_status(:ok)
expect(response.headers[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with("git-diff:")
end
end
describe 'GET /projects/:id/merge_requests/:merge_request_iid/pipelines' do
let_it_be(:merge_request) { create(:merge_request, :simple, author: user, assignees: [user], source_project: project, target_project: project, source_branch: 'markdown', title: "Test", created_at: base_time) }

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe FeatureFlags::ClientConfigurationEntity, factory_default: :keep, feature_category: :feature_flags do
let_it_be(:project) { create_default(:project) }
let(:feature_flags_client) { project.create_operations_feature_flags_client! }
let(:entity) { described_class.new(feature_flags_client) }
describe '#to_json' do
subject(:json) { entity.to_json }
it 'matches schema' do
expect(json).to match_schema('feature_flags/client_configuration')
end
end
end

View File

@ -0,0 +1,268 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe CustomUniquenessValidator, feature_category: :shared do
before_all do
ApplicationRecord.connection.execute(
<<~SQL
CREATE TABLE IF NOT EXISTS _test_custom_uniqueness (
id smallint PRIMARY KEY,
name VARCHAR(500),
number smallint,
second_number smallint
);
SQL
)
end
let_it_be(:existing_record_name) { 'Some Name' }
shared_examples 'custom uniqueness validator' do
subject { test_model.new(**new_record_attributes) }
before do
test_model.create!(id: 1, **existing_record_attributes)
end
it 'uses TRIM(BOTH FROM lower()) to validate' do
new_record = test_model.new(**valid_new_attributes)
expect do
new_record.valid?
end.to make_queries_matching(
/TRIM\(BOTH FROM lower\(_test_custom_uniqueness.name\)\) = TRIM\(BOTH FROM lower\('/
)
end
it 'adds an error to the record' do
new_record = test_model.new(**existing_record_attributes)
new_record.valid?
expect(new_record.errors.full_messages).to include('Name has already been taken')
end
context 'when new record has leading whitespace' do
let(:new_record_name) { " #{existing_record_name}" }
it { is_expected.to be_invalid }
end
context 'when new record has trailing whitespace' do
let(:new_record_name) { "#{existing_record_name} " }
it { is_expected.to be_invalid }
end
context 'when new record does not match casing' do
let(:new_record_name) { existing_record_name.upcase }
it { is_expected.to be_invalid }
end
context 'when new record name does not match' do
let(:new_record_name) { "#{existing_record_name} something else" }
it { is_expected.to be_valid }
end
context 'when updating a record' do
let(:existing_record) { test_model.first }
subject(:updated) { existing_record.update(**new_record_attributes) } # rubocop:disable Rails/SaveBang -- Checking the return value in specs
context 'when new value is exactly the same' do
let(:new_record_name) { existing_record_name }
it { is_expected.to be_truthy }
end
context 'when new value is the same in a different casing' do
let(:new_record_name) { existing_record_name.upcase }
it { is_expected.to be_truthy }
it 'does update the record to the new casing' do
expect do
updated
end.to change { existing_record.reload.name }.from(existing_record_name).to(existing_record_name.upcase)
end
end
end
end
context 'when validation is not scoped to another column' do
let_it_be(:test_model) do
Class.new(ApplicationRecord) do
self.table_name = :_test_custom_uniqueness
def self.name
'TestCustomUniqueness'
end
validates :name, custom_uniqueness: { unique_sql: 'TRIM(BOTH FROM lower(?))' }
end
end
it_behaves_like 'custom uniqueness validator' do
let(:existing_record_attributes) { { name: existing_record_name } }
let(:valid_new_attributes) { { name: "#{existing_record_name} something else" } }
let(:new_record_attributes) { { name: new_record_name } }
end
end
context 'when validation is scoped to another column' do
let_it_be(:test_model) do
Class.new(ApplicationRecord) do
self.table_name = :_test_custom_uniqueness
def self.name
'TestCustomUniqueness'
end
validates :name, custom_uniqueness: { unique_sql: 'TRIM(BOTH FROM lower(?))', scope: :number }
end
end
it_behaves_like 'custom uniqueness validator' do
let(:existing_record_attributes) { { name: existing_record_name, number: 1 } }
let(:valid_new_attributes) { { name: "#{existing_record_name} something else", number: 2 } }
let(:new_record_attributes) { { name: new_record_name, number: 1 } }
end
context 'when name matches but scope value does not' do
it 'does not invalidate the record' do
test_model.create!(id: 1, name: existing_record_name, number: 1)
new_record = test_model.new(name: existing_record_name, number: 2)
expect(new_record).to be_valid
end
end
context 'when name and scope value matches an existing record' do
it 'adds an error to the record' do
test_model.create!(id: 1, name: existing_record_name, number: 1)
new_record = test_model.new(name: existing_record_name, number: 1)
new_record.valid?
expect(new_record.errors.full_messages).to include('Name has already been taken')
end
context 'when name and scope value matches an existing record' do
it 'adds an error to the record' do
test_model.create!(id: 1, name: existing_record_name, number: 1)
new_record = test_model.new(name: existing_record_name, number: 1)
new_record.valid?
expect(new_record.errors.full_messages).to include('Name has already been taken')
end
end
context 'when updating an existing record' do
before_all do
test_model.create!(id: 1, name: existing_record_name, number: 1)
end
context 'when changing the attribute value' do
it 'adds an error to the record' do
record = test_model.create!(id: 2, name: "#{existing_record_name} different", number: 1)
record.name = existing_record_name
expect(record).to be_invalid
end
end
context 'when changing the scope value' do
it 'adds an error to the record' do
record = test_model.create!(id: 2, name: existing_record_name, number: 2)
record.number = 1
expect(record).to be_invalid
end
end
context 'when making valid changes to the attribute and scope values' do
it 'does not invalidate the record' do
record = test_model.create!(id: 2, name: "#{existing_record_name} different", number: 1)
record.name = existing_record_name
record.number = 2
expect(record).to be_valid
end
end
context 'when not changing the validated attributes or scoped values' do
it 'does not issue queries to the DB' do
record = test_model.create!(id: 2, name: "#{existing_record_name} different", number: 1)
record.id = 100
expect do
record.valid?
end.not_to make_queries
expect(record).to be_valid
end
end
end
end
end
context 'when validation is scoped to multiple columns' do
let_it_be(:test_model) do
Class.new(ApplicationRecord) do
self.table_name = :_test_custom_uniqueness
def self.name
'TestCustomUniqueness'
end
validates :name, custom_uniqueness: { unique_sql: 'TRIM(BOTH FROM lower(?))', scope: [:number, :second_number] }
end
end
it_behaves_like 'custom uniqueness validator' do
let(:existing_record_attributes) { { name: existing_record_name, number: 1, second_number: 101 } }
let(:valid_new_attributes) { { name: "#{existing_record_name} something else", number: 2, second_number: 102 } }
let(:new_record_attributes) { { name: new_record_name, number: 1, second_number: 101 } }
end
context 'when name matches but scope value does not' do
it 'does not invalidate the record' do
test_model.create!(id: 1, name: existing_record_name, number: 1, second_number: 101)
new_record = test_model.new(name: existing_record_name, number: 1, second_number: 102)
expect(new_record).to be_valid
end
end
context 'when name and scope values matches an existing record' do
it 'adds an error to the record' do
test_model.create!(id: 1, name: existing_record_name, number: 1, second_number: 101)
new_record = test_model.new(name: existing_record_name, number: 1, second_number: 101)
new_record.valid?
expect(new_record.errors.full_messages).to include('Name has already been taken')
end
end
end
context 'when validation definition is missing the `unique_sql key`' do
it 'raises an ArgumentError' do
expect do
Class.new(ApplicationRecord) do
self.table_name = :_test_custom_uniqueness
def self.name
'TestCustomUniqueness'
end
validates :name, custom_uniqueness: true
end
end.to raise_error(ArgumentError, '`unique_sql` option must be provided to the `custom_uniqueness` validator')
end
end
end