Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
08b69d2326
commit
bc5c433ff1
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
query getBlobSearchCountQuery(
|
||||
$search: String!
|
||||
$groupId: GroupID
|
||||
$projectId: ProjectID
|
||||
$regex: Boolean
|
||||
) {
|
||||
blobSearch(search: $search, groupId: $groupId, projectId: $projectId, regex: $regex) {
|
||||
matchCount
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 }">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module FeatureFlags
|
||||
class ClientConfigurationEntity < Grape::Entity
|
||||
include RequestAwareEntity
|
||||
|
||||
expose :id
|
||||
expose :project_id
|
||||
end
|
||||
end
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 ""
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"project_id"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": [
|
||||
"integer"
|
||||
]
|
||||
},
|
||||
"project_id": {
|
||||
"type": [
|
||||
"integer"
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
|
|
@ -1791,3 +1791,13 @@ export const mockAuthorsAxiosResponse = [
|
|||
path: '/jane',
|
||||
},
|
||||
];
|
||||
|
||||
export const mockgetBlobSearchCountQuery = {
|
||||
data: {
|
||||
blobSearch: {
|
||||
fileCount: 10,
|
||||
matchCount: 123,
|
||||
__typename: 'BlobSearch',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 = {}) => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue