Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2024-12-13 06:32:55 +00:00
parent 69d7f138e4
commit 0ff07f6124
48 changed files with 1202 additions and 983 deletions

View File

@ -128,6 +128,7 @@ function mountBoardApp(el) {
hasIssuableHealthStatusFeature: parseBoolean(el.dataset.healthStatusFeatureAvailable),
hasSubepicsFeature: parseBoolean(el.dataset.subEpicsFeatureAvailable),
hasLinkedItemsEpicsFeature: parseBoolean(el.dataset.hasLinkedItemsEpicsFeature),
hasOkrsFeature: parseBoolean(el.dataset.hasOkrsFeature),
},
render: (createComponent) => createComponent(BoardApp),
});

View File

@ -92,7 +92,7 @@ export default {
},
{
orderBy: LIST_KEY_CREATED_AT,
label: s__('MlExperimentTracking|Created at'),
label: s__('MlExperimentTracking|Created'),
},
],
emptyState: {

View File

@ -27,6 +27,7 @@ import {
TEST_ID_NOTIFICATIONS_TOGGLE_FORM,
TEST_ID_DELETE_ACTION,
TEST_ID_PROMOTE_ACTION,
TEST_ID_CHANGE_TYPE_ACTION,
TEST_ID_COPY_CREATE_NOTE_EMAIL_ACTION,
TEST_ID_COPY_REFERENCE_ACTION,
TEST_ID_TOGGLE_ACTION,
@ -47,6 +48,7 @@ import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql
import updateWorkItemNotificationsMutation from '../graphql/update_work_item_notifications.mutation.graphql';
import convertWorkItemMutation from '../graphql/work_item_convert.mutation.graphql';
import namespaceWorkItemTypesQuery from '../graphql/namespace_work_item_types.query.graphql';
import WorkItemChangeTypeModal from './work_item_change_type_modal.vue';
import WorkItemStateToggle from './work_item_state_toggle.vue';
import CreateWorkItemModal from './create_work_item_modal.vue';
@ -67,6 +69,7 @@ export default {
emailAddressCopied: __('Email address copied'),
moreActions: __('More actions'),
reportAbuse: __('Report abuse'),
changeWorkItemType: s__('WorkItem|Change type'),
},
WORK_ITEM_TYPE_ENUM_EPIC,
components: {
@ -78,6 +81,7 @@ export default {
GlToggle,
WorkItemStateToggle,
CreateWorkItemModal,
WorkItemChangeTypeModal,
},
directives: {
GlModal: GlModalDirective,
@ -91,6 +95,7 @@ export default {
copyCreateNoteEmailTestId: TEST_ID_COPY_CREATE_NOTE_EMAIL_ACTION,
deleteActionTestId: TEST_ID_DELETE_ACTION,
promoteActionTestId: TEST_ID_PROMOTE_ACTION,
changeTypeTestId: TEST_ID_CHANGE_TYPE_ACTION,
lockDiscussionTestId: TEST_ID_LOCK_ACTION,
stateToggleTestId: TEST_ID_TOGGLE_ACTION,
reportAbuseActionTestId: TEST_ID_REPORT_ABUSE,
@ -170,6 +175,11 @@ export default {
required: false,
default: false,
},
isDrawer: {
type: Boolean,
required: false,
default: false,
},
hideSubscribe: {
type: Boolean,
required: false,
@ -180,6 +190,11 @@ export default {
required: false,
default: false,
},
hasParent: {
type: Boolean,
required: false,
default: false,
},
workItemAuthorId: {
type: Number,
required: false,
@ -194,6 +209,16 @@ export default {
type: Boolean,
required: true,
},
widgets: {
type: Array,
required: false,
default: () => [],
},
allowedChildTypes: {
type: Array,
required: false,
default: () => [],
},
},
data() {
return {
@ -287,6 +312,9 @@ export default {
? this.$options.i18n.confidentialityDisabled
: this.$options.i18n.confidentialityEnabled;
},
showChangeType() {
return !(this.isEpic || this.isDrawer) && this.glFeatures.workItemsBeta;
},
},
methods: {
copyToClipboard(text, message) {
@ -420,6 +448,9 @@ export default {
this.$emit('toggleReportAbuseModal', true);
this.closeDropdown();
},
showChangeTypeModal() {
this.$refs.workItemsChangeTypeModal.show();
},
},
};
</script>
@ -487,6 +518,14 @@ export default {
<template #list-item>{{ __('Promote to objective') }}</template>
</gl-disclosure-dropdown-item>
<gl-disclosure-dropdown-item
v-if="showChangeType"
:data-testid="$options.changeTypeTestId"
@action="showChangeTypeModal"
>
<template #list-item>{{ $options.i18n.changeWorkItemType }}</template>
</gl-disclosure-dropdown-item>
<gl-disclosure-dropdown-item
v-if="canLockWorkItem"
:data-testid="$options.lockDiscussionTestId"
@ -568,5 +607,17 @@ export default {
@workItemCreated="$emit('workItemCreated')"
@hideModal="isCreateWorkItemModalVisible = false"
/>
<work-item-change-type-modal
v-if="showChangeType"
ref="workItemsChangeTypeModal"
:work-item-id="workItemId"
:work-item-type="workItemType"
:full-path="fullPath"
:has-children="hasChildren"
:has-parent="hasParent"
:widgets="widgets"
:allowed-child-types="allowedChildTypes"
@workItemTypeChanged="$emit('workItemTypeChanged')"
/>
</div>
</template>

View File

@ -0,0 +1,384 @@
<script>
import { GlModal, GlFormGroup, GlFormSelect, GlAlert, GlButton } from '@gitlab/ui';
import { differenceBy } from 'lodash';
import * as Sentry from '~/sentry/sentry_browser_wrapper';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { __, s__, sprintf } from '~/locale';
import { findDesignWidget } from '~/work_items/utils';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import {
WIDGET_TYPE_HIERARCHY,
WORK_ITEMS_TYPE_MAP,
WORK_ITEM_ALLOWED_CHANGE_TYPE_MAP,
WORK_ITEM_TYPE_ENUM_OBJECTIVE,
WORK_ITEM_TYPE_ENUM_KEY_RESULT,
sprintfWorkItem,
I18N_WORK_ITEM_CHANGE_TYPE_PARENT_ERROR,
I18N_WORK_ITEM_CHANGE_TYPE_CHILD_ERROR,
I18N_WORK_ITEM_CHANGE_TYPE_MISSING_FIELDS_ERROR,
WORK_ITEM_WIDGETS_NAME_MAP,
WIDGET_TYPE_DESIGNS,
} from '../constants';
import namespaceWorkItemTypesQuery from '../graphql/namespace_work_item_types.query.graphql';
import convertWorkItemMutation from '../graphql/work_item_convert.mutation.graphql';
import getWorkItemDesignListQuery from './design_management/graphql/design_collection.query.graphql';
export default {
i18n: {
type: __('Type'),
subText: s__('WorkItem|Select which type you would like to change this item to.'),
},
components: {
GlModal,
GlFormGroup,
GlFormSelect,
GlAlert,
GlButton,
},
mixins: [glFeatureFlagMixin()],
inject: ['hasOkrsFeature'],
props: {
workItemId: {
type: String,
required: true,
},
workItemType: {
type: String,
required: false,
default: null,
},
fullPath: {
type: String,
required: true,
},
hasChildren: {
type: Boolean,
required: false,
default: false,
},
hasParent: {
type: Boolean,
required: false,
default: false,
},
widgets: {
type: Array,
required: false,
default: () => [],
},
allowedChildTypes: {
type: Array,
required: false,
default: () => [],
},
},
data() {
return {
selectedWorkItemType: null,
workItemTypes: [],
warningMessage: '',
errorMessage: '',
changeTypeDisabled: true,
hasDesigns: false,
};
},
apollo: {
workItemTypes: {
query: namespaceWorkItemTypesQuery,
variables() {
return {
fullPath: this.fullPath,
};
},
update(data) {
return data.workspace?.workItemTypes?.nodes;
},
error(e) {
this.showErrorMessage(e);
},
},
hasDesigns: {
query: getWorkItemDesignListQuery,
variables() {
return {
id: this.workItemId,
atVersion: null,
};
},
update(data) {
return findDesignWidget(data.workItem.widgets)?.designCollection?.designs.nodes?.length > 0;
},
skip() {
return !this.workItemId;
},
error(e) {
this.showErrorMessage(e);
},
},
},
computed: {
allowedConversionWorkItemTypes() {
// The logic will be simplified once we implement
// https://gitlab.com/gitlab-org/gitlab/-/issues/498656
return [
{ text: __('Select type'), value: null },
...Object.entries(WORK_ITEMS_TYPE_MAP)
.map(([key, value]) => ({
text: value.value,
value: key,
}))
.filter((item) => {
if (item.text === this.workItemType) {
return false;
}
// Keeping this separate for readability
if (
item.value === WORK_ITEM_TYPE_ENUM_OBJECTIVE ||
item.value === WORK_ITEM_TYPE_ENUM_KEY_RESULT
) {
return this.isOkrsEnabled;
}
return WORK_ITEM_ALLOWED_CHANGE_TYPE_MAP.includes(item.value);
}),
];
},
isOkrsEnabled() {
return this.hasOkrsFeature && this.glFeatures.okrsMvc;
},
selectedWorkItemTypeWidgetDefinitions() {
return this.getWidgetDefinitions(this.selectedWorkItemType?.text);
},
currentWorkItemTypeWidgetDefinitions() {
return this.getWidgetDefinitions(this.workItemType);
},
widgetDifference() {
return differenceBy(
this.currentWorkItemTypeWidgetDefinitions,
this.selectedWorkItemTypeWidgetDefinitions,
'type',
);
},
widgetsWithExistingData() {
// Filter the widgets based on the presence or absence of data
const widgetsWithExistingDataList = this.widgetDifference.filter((item) => {
// Find the widget object
const widgetObject = this.widgets?.find((widget) => widget.type === item.type);
// return false if the widget data is not found
if (!widgetObject) {
return false;
}
// Skip the type and __typename fields
// It will either have the actual widget object or none
const fieldName = Object.keys(widgetObject).find(
(key) => key !== 'type' && key !== '__typename',
);
// return false if the field name is undefined
if (!fieldName) {
return false;
}
// Check if the object has non-empty nodes array or
// non-empty object
return widgetObject[fieldName]?.nodes !== undefined
? widgetObject[fieldName]?.nodes?.length > 0
: Boolean(widgetObject[fieldName]);
});
if (this.hasDesigns) {
widgetsWithExistingDataList.push({
name: WORK_ITEM_WIDGETS_NAME_MAP[WIDGET_TYPE_DESIGNS],
type: WIDGET_TYPE_DESIGNS,
});
}
return widgetsWithExistingDataList.map((item) => ({
...item,
name: WORK_ITEM_WIDGETS_NAME_MAP[item.type],
}));
},
hasWidgetDifference() {
return this.widgetsWithExistingData.length > 0;
},
parentWorkItem() {
return this.widgets?.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY)?.parent;
},
parentWorkItemType() {
return this.parentWorkItem?.workItemType?.name;
},
workItemTypeId() {
return this.workItemTypes.find((type) => type.name === this.selectedWorkItemType.text).id;
},
selectedWorkItemTypeValue() {
return this.selectedWorkItemType?.value || null;
},
},
methods: {
async changeType() {
try {
const {
data: {
workItemConvert: { errors },
},
} = await this.$apollo.mutate({
mutation: convertWorkItemMutation,
variables: {
input: {
id: this.workItemId,
workItemTypeId: this.workItemTypeId,
},
},
});
if (errors.length > 0) {
this.showErrorMessage(errors[0]);
return;
}
this.$toast.show(s__('WorkItem|Type changed.'));
this.$emit('workItemTypeChanged');
this.hide();
} catch (error) {
this.showErrorMessage(error);
Sentry.captureException(error);
}
},
getWidgetDefinitions(type) {
if (!type) {
return [];
}
return this.workItemTypes.find((widget) => widget.name === type)?.widgetDefinitions;
},
validateWorkItemType(value) {
this.changeTypeDisabled = false;
this.warningMessage = '';
if (!value) {
this.resetModal();
return;
}
this.selectedWorkItemType = this.allowedConversionWorkItemTypes.find(
(item) => item.value === value,
);
if (this.hasParent) {
this.showWarningMessage(
sprintfWorkItem(
I18N_WORK_ITEM_CHANGE_TYPE_PARENT_ERROR,
this.selectedWorkItemType.text,
this.parentWorkItemType,
),
);
this.changeTypeDisabled = true;
return;
}
if (this.hasChildren) {
const msg = sprintf(I18N_WORK_ITEM_CHANGE_TYPE_CHILD_ERROR, {
workItemType: capitalizeFirstCharacter(
this.selectedWorkItemType.text.toLocaleLowerCase(),
),
childItemType: this.allowedChildTypes?.[0]?.name?.toLocaleLowerCase(),
});
this.showWarningMessage(msg);
this.changeTypeDisabled = true;
return;
}
// Compare the widget definitions of both types
if (this.hasWidgetDifference) {
this.warningMessage = sprintfWorkItem(
I18N_WORK_ITEM_CHANGE_TYPE_MISSING_FIELDS_ERROR,
this.selectedWorkItemType.text,
);
}
},
showWarningMessage(message) {
this.warningMessage = message;
},
showErrorMessage(message) {
this.errorMessage = message;
},
show() {
this.resetModal();
this.changeTypeDisabled = true;
this.$refs.modal.show();
},
hide() {
this.resetModal();
this.$refs.modal.hide();
},
resetModal() {
this.warningMessage = '';
this.errorMessage = '';
this.showDifferenceMessage = false;
this.selectedWorkItemType = null;
this.changeTypeDisabled = false;
},
},
};
</script>
<template>
<gl-modal
v-if="workItemId"
ref="modal"
modal-id="work-item-change-type"
:title="s__('WorkItem|Change type')"
size="sm"
>
<gl-alert
v-if="errorMessage"
data-testid="change-type-error-message"
class="gl-mb-3"
variant="danger"
@dismiss="errorMessage = undefined"
>
{{ errorMessage }}
</gl-alert>
<div>
<div class="gl-mb-4">{{ $options.i18n.subText }}</div>
<gl-form-group :label="$options.i18n.type" label-for="work-item-type-select">
<gl-form-select
id="work-item-type-select"
:value="selectedWorkItemTypeValue"
width="md"
:options="allowedConversionWorkItemTypes"
@change="validateWorkItemType"
/>
</gl-form-group>
<gl-alert
v-if="warningMessage"
data-testid="change-type-warning-message"
variant="warning"
:dismissible="false"
>
{{ warningMessage }}
<ul v-if="hasWidgetDifference" class="gl-mb-0">
<li v-for="widget in widgetsWithExistingData" :key="widget.type">
{{ widget.name }}
</li>
</ul>
</gl-alert>
</div>
<template #modal-footer>
<div class="gl-m-0 gl-flex gl-flex-row gl-flex-wrap gl-justify-end">
<gl-button @click="hide">
{{ __('Cancel') }}
</gl-button>
<div class="gl-mr-3"></div>
<gl-button
:disabled="changeTypeDisabled"
category="primary"
variant="confirm"
data-testid="change-type-confirmation-button"
@click="changeType"
>{{ s__('WorkItem|Change type') }}</gl-button
>
</div>
</template>
</gl-modal>
</template>

View File

@ -311,7 +311,7 @@ export default {
const { workItemType, parentWorkItem, hasSubepicsFeature } = this;
if (workItemType === WORK_ITEM_TYPE_VALUE_EPIC) {
return hasSubepicsFeature && parentWorkItem;
return Boolean(hasSubepicsFeature && parentWorkItem);
}
return Boolean(parentWorkItem);
@ -333,6 +333,9 @@ export default {
parentWorkItemConfidentiality() {
return this.parentWorkItem?.confidential;
},
parentWorkItemType() {
return this.parentWorkItem?.workItemType?.name;
},
workItemIconName() {
return this.workItem.workItemType?.iconName;
},
@ -433,6 +436,9 @@ export default {
iid() {
return this.workItemIid || this.workItem.iid;
},
widgets() {
return this.workItem.widgets;
},
isItemSelected() {
return !isEmpty(this.activeChildItem);
},
@ -448,7 +454,7 @@ export default {
this.editMode = true;
},
isWidgetPresent(type) {
return this.workItem.widgets?.find((widget) => widget.type === type);
return this.widgets?.find((widget) => widget.type === type);
},
toggleConfidentiality(confidentialStatus) {
this.updateInProgress = true;
@ -652,6 +658,9 @@ export default {
});
cache.gc();
},
workItemTypeChanged() {
this.$apollo.queries.workItem.refetch();
},
},
WORK_ITEM_TYPE_VALUE_OBJECTIVE,
WORKSPACE_PROJECT,
@ -674,12 +683,14 @@ export default {
:work-item-notifications-subscribed="workItemNotificationsSubscribed"
:work-item-author-id="workItemAuthorId"
:is-group="isGroupWorkItem"
:allowed-child-types="allowedChildTypes"
@hideStickyHeader="hideStickyHeader"
@showStickyHeader="showStickyHeader"
@deleteWorkItem="$emit('deleteWorkItem', { workItemType, workItemId: workItem.id })"
@toggleWorkItemConfidentiality="toggleConfidentiality"
@error="updateError = $event"
@promotedToObjective="$emit('promotedToObjective', iid)"
@workItemTypeChanged="workItemTypeChanged"
@toggleEditMode="enableEditMode"
@workItemStateUpdated="$emit('workItemStateUpdated')"
@toggleReportAbuseModal="toggleReportAbuseModal"
@ -767,16 +778,21 @@ export default {
:work-item-reference="workItem.reference"
:work-item-create-note-email="workItem.createNoteEmail"
:is-modal="isModal"
:is-drawer="isDrawer"
:work-item-state="workItem.state"
:has-children="hasChildren"
:has-parent="shouldShowAncestors"
:work-item-author-id="workItemAuthorId"
:can-create-related-item="workItemLinkedItems !== undefined"
:is-group="isGroupWorkItem"
:widgets="widgets"
:allowed-child-types="allowedChildTypes"
@deleteWorkItem="$emit('deleteWorkItem', { workItemType, workItemId: workItem.id })"
@toggleWorkItemConfidentiality="toggleConfidentiality"
@error="updateError = $event"
@promotedToObjective="$emit('promotedToObjective', iid)"
@workItemStateUpdated="$emit('workItemStateUpdated')"
@workItemTypeChanged="workItemTypeChanged"
@toggleReportAbuseModal="toggleReportAbuseModal"
@workItemCreated="handleWorkItemCreated"
/>

View File

@ -75,6 +75,11 @@ export default {
type: Boolean,
required: true,
},
allowedChildTypes: {
type: Array,
required: false,
default: () => [],
},
},
computed: {
canUpdate() {
@ -101,6 +106,9 @@ export default {
newTodoAndNotificationsEnabled() {
return this.glFeatures.notificationsTodosButtons;
},
widgets() {
return this.workItem.widgets;
},
},
WORKSPACE_PROJECT,
};
@ -180,12 +188,15 @@ export default {
:is-modal="isModal"
:work-item-author-id="workItemAuthorId"
:is-group="isGroup"
:widgets="widgets"
:allowed-child-types="allowedChildTypes"
@deleteWorkItem="$emit('deleteWorkItem')"
@toggleWorkItemConfidentiality="
$emit('toggleWorkItemConfidentiality', !workItem.confidential)
"
@error="$emit('error')"
@promotedToObjective="$emit('promotedToObjective')"
@workItemTypeChanged="$emit('workItemTypeChanged')"
@workItemStateUpdated="$emit('workItemStateUpdated')"
@toggleReportAbuseModal="$emit('toggleReportAbuseModal', true)"
/>

View File

@ -143,6 +143,18 @@ export const I18N_MAX_WORK_ITEMS_NOTE_LABEL = sprintf(
{ MAX_WORK_ITEMS },
);
export const I18N_WORK_ITEM_CHANGE_TYPE_PARENT_ERROR = s__(
'WorkItem|Parent item type %{parentWorkItemType} is not supported on %{workItemType}. Remove the parent item to change type.',
);
export const I18N_WORK_ITEM_CHANGE_TYPE_CHILD_ERROR = s__(
'WorkItem|%{workItemType} does not support the %{childItemType} child item types. Remove child items to change type.',
);
export const I18N_WORK_ITEM_CHANGE_TYPE_MISSING_FIELDS_ERROR = s__(
'WorkItem|Some fields are not present in %{workItemType}. If you change type now, this information will be lost.',
);
export const sprintfWorkItem = (msg, workItemTypeArg, parentWorkItemType = '') => {
const workItemType = workItemTypeArg || s__('WorkItem|item');
return capitalizeFirstCharacter(
@ -268,6 +280,7 @@ export const TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION = 'confidentiality-toggle-act
export const TEST_ID_NOTIFICATIONS_TOGGLE_FORM = 'notifications-toggle-form';
export const TEST_ID_DELETE_ACTION = 'delete-action';
export const TEST_ID_PROMOTE_ACTION = 'promote-action';
export const TEST_ID_CHANGE_TYPE_ACTION = 'change-type-action';
export const TEST_ID_LOCK_ACTION = 'lock-action';
export const TEST_ID_COPY_REFERENCE_ACTION = 'copy-reference-action';
export const TEST_ID_COPY_CREATE_NOTE_EMAIL_ACTION = 'copy-create-note-email-action';
@ -386,3 +399,35 @@ export const INJECTION_LINK_CHILD_PREVENT_ROUTER_NAVIGATION = Symbol(
export const WORK_ITEM_CREATE_ENTITY_MODAL_TARGET_SOURCE = 'source';
export const WORK_ITEM_CREATE_ENTITY_MODAL_TARGET_BRANCH = 'branch';
export const WORK_ITEM_ALLOWED_CHANGE_TYPE_MAP = [
WORK_ITEM_TYPE_ENUM_KEY_RESULT,
WORK_ITEM_TYPE_ENUM_OBJECTIVE,
WORK_ITEM_TYPE_ENUM_TASK,
WORK_ITEM_TYPE_ENUM_ISSUE,
];
export const WORK_ITEM_WIDGETS_NAME_MAP = {
[WIDGET_TYPE_ASSIGNEES]: s__('WorkItem|Assignees'),
[WIDGET_TYPE_DESCRIPTION]: s__('WorkItem|Description'),
[WIDGET_TYPE_AWARD_EMOJI]: s__('WorkItem|Emoji reactions'),
[WIDGET_TYPE_NOTIFICATIONS]: s__('WorkItem|Notifications'),
[WIDGET_TYPE_CURRENT_USER_TODOS]: s__('WorkItem|To-do item'),
[WIDGET_TYPE_LABELS]: s__('WorkItem|Labels'),
[WIDGET_TYPE_START_AND_DUE_DATE]: s__('WorkItem|Dates'),
[WIDGET_TYPE_TIME_TRACKING]: s__('WorkItem|Time tracking'),
[WIDGET_TYPE_WEIGHT]: s__('WorkItem|Weight'),
[WIDGET_TYPE_PARTICIPANTS]: s__('WorkItem|Participants'),
[WIDGET_TYPE_EMAIL_PARTICIPANTS]: s__('WorkItem|Email participants'),
[WIDGET_TYPE_PROGRESS]: s__('WorkItem|Progress'),
[WIDGET_TYPE_HIERARCHY]: s__('WorkItem|Child items'),
[WIDGET_TYPE_MILESTONE]: s__('WorkItem|Milestone'),
[WIDGET_TYPE_ITERATION]: s__('WorkItem|Iteration'),
[WIDGET_TYPE_NOTES]: s__('WorkItem|Comments and threads'),
[WIDGET_TYPE_HEALTH_STATUS]: s__('WorkItem|Health status'),
[WIDGET_TYPE_LINKED_ITEMS]: s__('WorkItem|Linked items'),
[WIDGET_TYPE_COLOR]: s__('WorkItem|Color'),
[WIDGET_TYPE_DESIGNS]: s__('WorkItem|Designs'),
[WIDGET_TYPE_DEVELOPMENT]: s__('WorkItem|Development'),
[WIDGET_TYPE_CRM_CONTACTS]: s__('WorkItem|Contacts'),
};

View File

@ -16,12 +16,7 @@ module Packages
def execute
return ::Packages::Package.none unless params[:package_name].present?
packages = if Feature.enabled?(:npm_extract_npm_package_model, Feature.current_request)
base.installable
else
base.npm.installable
end
packages = base.installable
packages = filter_by_exact_package_name(packages)
filter_by_package_version(packages)
end
@ -36,30 +31,16 @@ module Packages
elsif namespace
packages_for_namespace
else
packages_class.none
::Packages::Npm::Package.none
end
end
def packages_for_project
if Feature.enabled?(:npm_extract_npm_package_model, Feature.current_request)
::Packages::Npm::Package.for_projects(project)
else
project.packages
end
::Packages::Npm::Package.for_projects(project)
end
def packages_for_namespace
packages_class.for_projects(namespace.all_projects)
end
# TODO: Use the class directly with the rollout of the FF npm_extract_npm_package_model
# https://gitlab.com/gitlab-org/gitlab/-/issues/501469
def packages_class
if Feature.enabled?(:npm_extract_npm_package_model, Feature.current_request)
::Packages::Npm::Package
else
::Packages::Package
end
::Packages::Npm::Package.for_projects(namespace.all_projects)
end
end
end

View File

@ -12,12 +12,7 @@ module Packages
private
def packages
if Feature.enabled?(:npm_extract_npm_package_model, Feature.current_request)
base.with_name(@params[:package_name])
else
base.npm
.with_name(@params[:package_name])
end
base.with_name(@params[:package_name])
end
override :group_packages
@ -27,11 +22,7 @@ module Packages
override :packages_class
def packages_class
if Feature.enabled?(:npm_extract_npm_package_model, Feature.current_request)
::Packages::Npm::Package
else
super
end
::Packages::Npm::Package
end
end
end

View File

@ -1,40 +1,22 @@
# frozen_string_literal: true
class Packages::Npm::Metadatum < ApplicationRecord
include Gitlab::Utils::StrongMemoize
MAX_PACKAGE_JSON_SIZE = 20_000
MIN_PACKAGE_JSON_FIELD_SIZE_FOR_ERROR_TRACKING = 5_000
NUM_FIELDS_FOR_ERROR_TRACKING = 5
belongs_to :package, class_name: 'Packages::Npm::Package', inverse_of: :npm_metadatum
# TODO: Remove with the rollout of the FF npm_extract_npm_package_model
# https://gitlab.com/gitlab-org/gitlab/-/issues/501469
belongs_to :legacy_package, -> {
where(package_type: :npm)
}, inverse_of: :npm_metadatum, class_name: 'Packages::Package', foreign_key: :package_id
validates :package, presence: true
validates :package, presence: true, if: -> { npm_extract_npm_package_model_enabled? }
# TODO: Remove with the rollout of the FF npm_extract_npm_package_model
# https://gitlab.com/gitlab-org/gitlab/-/issues/501469
validates :legacy_package, presence: true, unless: -> { npm_extract_npm_package_model_enabled? }
# From https://github.com/npm/registry/blob/master/docs/responses/package-metadata.md#abbreviated-version-object
validates :package_json, json_schema: { filename: "npm_package_json" }
validate :ensure_npm_package_type, unless: -> { npm_extract_npm_package_model_enabled? }
validate :ensure_package_json_size
scope :package_id_in, ->(package_ids) { where(package_id: package_ids) }
private
def ensure_npm_package_type
return if legacy_package&.npm?
errors.add(:base, _('Package type must be NPM'))
end
def ensure_package_json_size
return if package_json.to_s.size < MAX_PACKAGE_JSON_SIZE
@ -45,9 +27,4 @@ class Packages::Npm::Metadatum < ApplicationRecord
)
)
end
def npm_extract_npm_package_model_enabled?
Feature.enabled?(:npm_extract_npm_package_model, Feature.current_request)
end
strong_memoize_attr :npm_extract_npm_package_model_enabled?
end

View File

@ -44,9 +44,6 @@ class Packages::Package < ApplicationRecord
has_many :tags, inverse_of: :package, class_name: 'Packages::Tag'
has_one :maven_metadatum, inverse_of: :package, class_name: 'Packages::Maven::Metadatum'
# TODO: Remove with the rollout of the FF npm_extract_npm_package_model
# https://gitlab.com/gitlab-org/gitlab/-/issues/501469
has_one :npm_metadatum, inverse_of: :package, class_name: 'Packages::Npm::Metadatum'
has_many :build_infos, inverse_of: :package
has_many :pipelines, through: :build_infos, disable_joins: true
@ -67,21 +64,8 @@ class Packages::Package < ApplicationRecord
},
unless: -> { pending_destruction? || conan? }
# TODO: Remove with the rollout of the FF npm_extract_npm_package_model
# https://gitlab.com/gitlab-org/gitlab/-/issues/501469
validate :npm_package_already_taken, if: :npm?
# TODO: Remove with the rollout of the FF npm_extract_npm_package_model
# https://gitlab.com/gitlab-org/gitlab/-/issues/501469
validates :name, format: { with: Gitlab::Regex.npm_package_name_regex, message: Gitlab::Regex.npm_package_name_regex_message }, if: :npm?
validates :version, format: { with: Gitlab::Regex.maven_version_regex }, if: -> { version? && maven? }
# TODO: Remove with the rollout of the FF npm_extract_npm_package_model
# https://gitlab.com/gitlab-org/gitlab/-/issues/501469
validates :version, format: { with: Gitlab::Regex.semver_regex, message: Gitlab::Regex.semver_regex_message },
if: -> { npm? }
scope :for_projects, ->(project_ids) { where(project_id: project_ids) }
scope :with_name, ->(name) { where(name: name) }
scope :with_name_like, ->(name) { where(arel_table[:name].matches(name)) }
@ -104,17 +88,6 @@ class Packages::Package < ApplicationRecord
scope :including_project_namespace_route, -> { includes(project: { namespace: :route }) }
scope :including_tags, -> { includes(:tags) }
scope :including_dependency_links, -> { includes(dependency_links: :dependency) }
# TODO: Remove with the rollout of the FF npm_extract_npm_package_model
# https://gitlab.com/gitlab-org/gitlab/-/issues/501469
scope :preload_npm_metadatum, -> { preload(:npm_metadatum) }
# TODO: Remove with the rollout of the FF npm_extract_npm_package_model
# https://gitlab.com/gitlab-org/gitlab/-/issues/501469
scope :with_npm_scope, ->(scope) do
npm.where("position('/' in packages_packages.name) > 0 AND split_part(packages_packages.name, '/', 1) = :package_scope", package_scope: "@#{sanitize_sql_like(scope)}")
end
scope :has_version, -> { where.not(version: nil) }
scope :preload_files, -> { preload(:installable_package_files) }
scope :preload_pipelines, -> { preload(pipelines: :user) }
@ -158,7 +131,7 @@ class Packages::Package < ApplicationRecord
def self.inheritance_column = 'package_type'
def self.inheritance_column_to_class_map
hash = {
{
ml_model: 'Packages::MlModel::Package',
golang: 'Packages::Go::Package',
rubygems: 'Packages::Rubygems::Package',
@ -170,14 +143,9 @@ class Packages::Package < ApplicationRecord
generic: 'Packages::Generic::Package',
pypi: 'Packages::Pypi::Package',
terraform_module: 'Packages::TerraformModule::Package',
nuget: 'Packages::Nuget::Package'
}
if Feature.enabled?(:npm_extract_npm_package_model, Feature.current_request)
hash[:npm] = 'Packages::Npm::Package'
end
hash
nuget: 'Packages::Nuget::Package',
npm: 'Packages::Npm::Package'
}.freeze
end
def self.only_maven_packages_with_path(path, use_cte: false)
@ -278,14 +246,6 @@ class Packages::Package < ApplicationRecord
::Packages::Maven::Metadata::SyncWorker.perform_async(user.id, project_id, name)
end
# TODO: Remove with the rollout of the FF npm_extract_npm_package_model
# https://gitlab.com/gitlab-org/gitlab/-/issues/501469
def sync_npm_metadata_cache
return unless npm?
::Packages::Npm::CreateMetadataCacheWorker.perform_async(project_id, name)
end
def create_build_infos!(build)
return unless build&.pipeline
@ -328,24 +288,4 @@ class Packages::Package < ApplicationRecord
connection.execute("SELECT pg_advisory_xact_lock(#{lock_expression})")
end
# TODO: Remove with the rollout of the FF npm_extract_npm_package_model
# https://gitlab.com/gitlab-org/gitlab/-/issues/501469
def npm_package_already_taken
return unless project
return unless follows_npm_naming_convention?
if project.package_already_taken?(name, version, package_type: :npm)
errors.add(:base, _('Package already exists'))
end
end
# TODO: Remove with the rollout of the FF npm_extract_npm_package_model
# https://gitlab.com/gitlab-org/gitlab/-/issues/501469
# https://docs.gitlab.com/ee/user/packages/npm_registry/#package-naming-convention
def follows_npm_naming_convention?
return false unless project&.root_namespace&.path
project.root_namespace.path == ::Packages::Npm.scope_of(name)
end
end

View File

@ -3112,14 +3112,6 @@ class Project < ApplicationRecord
).exists?
end
# TODO: Remove with the rollout of the FF npm_extract_npm_package_model
# https://gitlab.com/gitlab-org/gitlab/-/issues/501469
def has_namespaced_npm_packages?
packages.with_npm_scope(root_namespace.path)
.not_pending_destruction
.exists?
end
def default_branch_or_main
return default_branch if default_branch

View File

@ -170,22 +170,22 @@ class WikiPage
end
def insert_slugs(strings, is_new, canonical_slug)
creation = Time.current.utc
created_at = Time.current.utc
slug_attrs = strings.map do |slug|
slug_attributes(slug, canonical_slug, is_new, creation)
slug_attributes(slug, canonical_slug, is_new, created_at)
end
slugs.insert_all(slug_attrs) unless !is_new && slug_attrs.size == 1
@canonical_slug = canonical_slug if is_new || strings.size == 1
end
def slug_attributes(slug, canonical_slug, is_new, creation)
def slug_attributes(slug, canonical_slug, is_new, created_at)
{
slug: slug,
canonical: is_new && slug == canonical_slug,
created_at: creation,
updated_at: creation
created_at: created_at,
updated_at: created_at
}.merge(slug_meta_attributes)
end

View File

@ -29,7 +29,8 @@ module Git
push_changes.take(MAX_CHANGES).each do |change| # rubocop:disable CodeReuse/ActiveRecord
next unless change.page.present?
response = create_event_for(change)
wiki_page_meta = WikiPage::Meta.find_or_create(change.last_known_slug, change.page)
response = create_event_for(change, wiki_page_meta)
log_error(response.message) if response.error?
end
end
@ -50,10 +51,9 @@ module Git
wiki.repository.raw.raw_changes_between(change[:oldrev], change[:newrev])
end
def create_event_for(change)
def create_event_for(change, wiki_page_meta)
event_service.execute(
change.last_known_slug,
change.page,
wiki_page_meta,
change.event_action,
change.sha
)

View File

@ -123,15 +123,9 @@ module Groups
def group_with_namespaced_npm_packages?
return false unless group.packages_feature_enabled?
npm_packages = if Feature.enabled?(:npm_extract_npm_package_model, Feature.current_request)
::Packages::GroupPackagesFinder
.new(current_user, group, packages_class: ::Packages::Npm::Package, preload_pipelines: false)
.execute
else
::Packages::GroupPackagesFinder
.new(current_user, group, package_type: :npm, preload_pipelines: false)
.execute
end
npm_packages = ::Packages::GroupPackagesFinder
.new(current_user, group, packages_class: ::Packages::Npm::Package, preload_pipelines: false)
.execute
npm_packages = npm_packages.with_npm_scope(group.root_ancestor.path)

View File

@ -78,17 +78,10 @@ module Groups
# we have a path change on a root group:
# check that we don't have any npm package with a scope set to the group path
npm_packages = if Feature.enabled?(:npm_extract_npm_package_model, Feature.current_request)
::Packages::GroupPackagesFinder
.new(current_user, group, packages_class: ::Packages::Npm::Package, preload_pipelines: false)
.execute
.with_npm_scope(group.path)
else
::Packages::GroupPackagesFinder
.new(current_user, group, package_type: :npm, preload_pipelines: false)
.execute
.with_npm_scope(group.path)
end
npm_packages = ::Packages::GroupPackagesFinder
.new(current_user, group, packages_class: ::Packages::Npm::Package, preload_pipelines: false)
.execute
.with_npm_scope(group.path)
return true unless npm_packages.exists?

View File

@ -51,16 +51,16 @@ module LooseForeignKeys
handle_over_limit
break
end
break if modification_tracker.over_limit?
# At this point, all associations are cleaned up, we can update the status of the parent records
update_count = Gitlab::Database::SharedModel.using_connection(connection) do
LooseForeignKeys::DeletedRecord.mark_records_processed(deleted_parent_records)
end
deleted_records_counter.increment({ table: parent_table, db_config_name: db_config_name }, update_count)
end
return if modification_tracker.over_limit?
# At this point, all associations are cleaned up, we can update the status of the parent records
update_count = Gitlab::Database::SharedModel.using_connection(connection) do
LooseForeignKeys::DeletedRecord.mark_records_processed(deleted_parent_records)
end
deleted_records_counter.increment({ table: parent_table, db_config_name: db_config_name }, update_count)
end
private

View File

@ -67,20 +67,11 @@ module Packages
end
def current_package_exists?
if Feature.enabled?(:npm_extract_npm_package_model, Feature.current_request)
::Packages::Npm::Package.for_projects(project)
.with_name(name)
.with_version(version)
.not_pending_destruction
.exists?
else
project.packages
.npm
.with_name(name)
.with_version(version)
.not_pending_destruction
.exists?
end
::Packages::Npm::Package.for_projects(project)
.with_name(name)
.with_version(version)
.not_pending_destruction
.exists?
end
def current_package_protected?

View File

@ -34,10 +34,8 @@ module Packages
attr_reader :package_file
# TODO: Remove `package&.npm?` with the rollout of the FF npm_extract_npm_package_model
# https://gitlab.com/gitlab-org/gitlab/-/issues/501469
def valid_package_file?
package_file && !package_file.file.empty_size? && package&.npm? && package&.processing?
package_file && !package_file.file.empty_size? && package&.processing?
end
def with_package_json_entry

View File

@ -333,14 +333,10 @@ module Projects
end
def project_has_namespaced_npm_packages?
if Feature.enabled?(:npm_extract_npm_package_model, Feature.current_request)
::Packages::Npm::Package.for_projects(project)
.with_npm_scope(project.root_namespace.path)
.not_pending_destruction
.exists?
else
project.has_namespaced_npm_packages?
end
::Packages::Npm::Package.for_projects(project)
.with_npm_scope(project.root_namespace.path)
.not_pending_destruction
.exists?
end
end
end

View File

@ -14,7 +14,8 @@ module WikiPages
container.execute_hooks(page_data, :wiki_page_hooks)
container.execute_integrations(page_data, :wiki_page_hooks)
increment_usage(page)
create_wiki_event(page)
wiki_page_meta = wiki_page_meta_for(page)
create_wiki_event(wiki_page_meta, page)
end
# Passed to web-hooks, and send to external consumers.
@ -57,10 +58,14 @@ module WikiPages
)
end
def create_wiki_event(page)
def wiki_page_meta_for(page)
WikiPage::Meta.find_or_create(slug_for_page(page), page)
end
def create_wiki_event(wiki_page_meta, page)
response = WikiPages::EventCreateService
.new(current_user)
.execute(slug_for_page(page), page, event_action, fingerprint(page))
.execute(wiki_page_meta, event_action, fingerprint(page))
log_error(response.message) if response.error?
end

View File

@ -9,9 +9,7 @@ module WikiPages
@author = author
end
def execute(slug, page, action, event_fingerprint)
wiki_page_meta = WikiPage::Meta.find_or_create(slug, page)
def execute(wiki_page_meta, action, event_fingerprint)
event = ::EventCreateService.new.wiki_event(wiki_page_meta, author, action, event_fingerprint)
ServiceResponse.success(payload: { event: event })

View File

@ -1,9 +0,0 @@
---
name: npm_extract_npm_package_model
feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/435823
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/171285
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/501469
milestone: '17.6'
group: group::package registry
type: gitlab_com_derisk
default_enabled: false

View File

@ -163,11 +163,12 @@ The code flow information is shown the **Code flow** tab and includes:
The code flow view is integrated into each view where vulnerability details are shown.
On GitLab self-managed, you can activate the view by [enabling the required feature flags](../../../administration/feature_flags.md#how-to-enable-and-disable-features-behind-flags) starting in the minimum version shown.
| Location | Availability on GitLab.com | Availability on GitLab self-managed | Feature flags required |
|-----------------------------------------------------------------|-----------------------------------|-----------------------------------------------------------------------|-----------------------------------------------------------------------|
| [Vulnerability Report](../vulnerability_report/index.md) | Enabled by default in GitLab 17.3 | Enabled by default in GitLab 17.6. Available in GitLab 17.3 or later. | `vulnerability_code_flow` |
| [Merge request widget](index.md#merge-request-widget) | Enabled by default in GitLab 17.6 | Enabled by default in GitLab 17.6. Available in GitLab 17.5 or later. | Both `vulnerability_code_flow` and `pipeline_vulnerability_code_flow` |
| [Pipeline security report](../vulnerability_report/pipeline.md) | Enabled by default in GitLab 17.6 | Enabled by default in GitLab 17.6. Available in GitLab 17.5 or later. | Both `vulnerability_code_flow` and `pipeline_vulnerability_code_flow` |
| Location | Availability on GitLab.com | Availability on GitLab self-managed | Feature flags required |
|-------------------------------------------------------------------|-----------------------------------|-----------------------------------------------------------------------|-----------------------------------------------------------------------|
| [Vulnerability Report](../vulnerability_report/index.md) | Enabled by default in GitLab 17.3 | Enabled by default in GitLab 17.6. Available in GitLab 17.3 or later. | `vulnerability_code_flow` |
| [Merge request widget](index.md#merge-request-widget) | Enabled by default in GitLab 17.6 | Enabled by default in GitLab 17.6. Available in GitLab 17.5 or later. | Both `vulnerability_code_flow` and `pipeline_vulnerability_code_flow` |
| [Pipeline security report](../vulnerability_report/pipeline.md) | Enabled by default in GitLab 17.6 | Enabled by default in GitLab 17.6. Available in GitLab 17.5 or later. | Both `vulnerability_code_flow` and `pipeline_vulnerability_code_flow` |
| [Merge request changes view](index.md#merge-request-changes-view) | Enabled by default in GitLab 17.7 | Enabled by default in GitLab 17.7. Available in GitLab 17.7 or later. | Both `vulnerability_code_flow` and `mr_vulnerability_code_flow` |
## Troubleshooting

View File

@ -64,14 +64,9 @@ module API
get '*package_name/-/*file_name', format: false do
authorize_read_package!(project)
package = if Feature.enabled?(:npm_extract_npm_package_model, Feature.current_request)
::Packages::Npm::Package
.for_projects(project)
.by_name_and_file_name(params[:package_name], params[:file_name])
else
project.packages.npm
.by_name_and_file_name(params[:package_name], params[:file_name])
end
package = ::Packages::Npm::Package
.for_projects(project)
.by_name_and_file_name(params[:package_name], params[:file_name])
not_found!('Package') unless package

View File

@ -39194,9 +39194,6 @@ msgstr ""
msgid "Package type must be Maven"
msgstr ""
msgid "Package type must be NPM"
msgstr ""
msgid "Package type must be NuGet"
msgstr ""
@ -63139,6 +63136,9 @@ msgstr ""
msgid "WorkItem|%{workItemType} deleted"
msgstr ""
msgid "WorkItem|%{workItemType} does not support the %{childItemType} child item types. Remove child items to change type."
msgstr ""
msgid "WorkItem|%{workItemType} has 1 linked item"
msgid_plural "WorkItem|%{workItemType} has %{itemCount} linked items"
msgstr[0] ""
@ -63211,6 +63211,9 @@ msgstr ""
msgid "WorkItem|Are you sure you want to delete the %{workItemType}? This action cannot be reversed."
msgstr ""
msgid "WorkItem|Assignees"
msgstr ""
msgid "WorkItem|Blocked by"
msgstr ""
@ -63223,6 +63226,9 @@ msgstr ""
msgid "WorkItem|Cancel"
msgstr ""
msgid "WorkItem|Change type"
msgstr ""
msgid "WorkItem|Child items"
msgstr ""
@ -63250,12 +63256,18 @@ msgstr ""
msgid "WorkItem|Coffee"
msgstr ""
msgid "WorkItem|Color"
msgstr ""
msgid "WorkItem|Comment & close %{workItemType}"
msgstr ""
msgid "WorkItem|Comment & reopen %{workItemType}"
msgstr ""
msgid "WorkItem|Comments and threads"
msgstr ""
msgid "WorkItem|Comments only"
msgstr ""
@ -63268,6 +63280,9 @@ msgstr ""
msgid "WorkItem|Configure work items such as epics, issues, and tasks to represent how your team works."
msgstr ""
msgid "WorkItem|Contacts"
msgstr ""
msgid "WorkItem|Convert to child item"
msgstr ""
@ -63304,6 +63319,12 @@ msgstr ""
msgid "WorkItem|Delete this %{workItemType} and release all child items? This action cannot be reversed."
msgstr ""
msgid "WorkItem|Description"
msgstr ""
msgid "WorkItem|Designs"
msgstr ""
msgid "WorkItem|Development"
msgstr ""
@ -63313,6 +63334,12 @@ msgstr ""
msgid "WorkItem|Due"
msgstr ""
msgid "WorkItem|Email participants"
msgstr ""
msgid "WorkItem|Emoji reactions"
msgstr ""
msgid "WorkItem|Epic"
msgstr ""
@ -63364,6 +63391,9 @@ msgstr ""
msgid "WorkItem|Key result"
msgstr ""
msgid "WorkItem|Labels"
msgstr ""
msgid "WorkItem|Last updated %{timeago}"
msgstr ""
@ -63481,6 +63511,12 @@ msgstr ""
msgid "WorkItem|Parent"
msgstr ""
msgid "WorkItem|Parent item type %{parentWorkItemType} is not supported on %{workItemType}. Remove the parent item to change type."
msgstr ""
msgid "WorkItem|Participants"
msgstr ""
msgid "WorkItem|Pink"
msgstr ""
@ -63532,6 +63568,9 @@ msgstr ""
msgid "WorkItem|Select type"
msgstr ""
msgid "WorkItem|Select which type you would like to change this item to."
msgstr ""
msgid "WorkItem|Show all ancestors"
msgstr ""
@ -63544,6 +63583,9 @@ msgstr ""
msgid "WorkItem|Single select"
msgstr ""
msgid "WorkItem|Some fields are not present in %{workItemType}. If you change type now, this information will be lost."
msgstr ""
msgid "WorkItem|Someone edited the description at the same time you did. If you save it will overwrite their changes. Please confirm you'd like to save your edits."
msgstr ""
@ -63679,6 +63721,12 @@ msgstr ""
msgid "WorkItem|This work item is not available. It either doesn't exist or you don't have permission to view it."
msgstr ""
msgid "WorkItem|Time tracking"
msgstr ""
msgid "WorkItem|To-do item"
msgstr ""
msgid "WorkItem|Toggle details"
msgstr ""
@ -63691,6 +63739,9 @@ msgstr ""
msgid "WorkItem|Type"
msgstr ""
msgid "WorkItem|Type changed."
msgstr ""
msgid "WorkItem|Undo"
msgstr ""
@ -63706,6 +63757,9 @@ msgstr ""
msgid "WorkItem|View on a roadmap"
msgstr ""
msgid "WorkItem|Weight"
msgstr ""
msgid "WorkItem|Work item"
msgstr ""

View File

@ -4,12 +4,10 @@ FactoryBot.define do
factory :npm_metadatum, class: 'Packages::Npm::Metadatum' do
package { association(:npm_package) }
# TODO: Remove `legacy_package) with the rollout of the FF npm_extract_npm_package_model
# https://gitlab.com/gitlab-org/gitlab/-/issues/501469
package_json do
{
name: (package || legacy_package).name,
version: (package || legacy_package).version,
name: package.name,
version: package.version,
dist: {
tarball: 'http://localhost/tarball.tgz',
shasum: '1234567890'

View File

@ -117,43 +117,6 @@ RSpec.describe ::Packages::Npm::PackageFinder, feature_category: :package_regist
it_behaves_like 'avoids N+1 database queries in the package registry'
end
end
context 'when npm_extract_npm_package_model is disabled' do
let_it_be_with_refind(:package) do
create(:npm_package_legacy, project: project, name: "#{FFaker::Lorem.word}-#{SecureRandom.hex(4)}")
end
before do
stub_feature_flags(npm_extract_npm_package_model: false)
end
context 'with a project' do
let(:finder) { described_class.new(project: project, params: params) }
it_behaves_like 'finding packages by name'
it_behaves_like 'avoids N+1 database queries in the package registry', :npm_package_legacy
context 'set to nil' do
let(:project) { nil }
it { is_expected.to be_empty }
end
end
context 'with a namespace' do
let(:finder) { described_class.new(namespace: namespace, params: params) }
it_behaves_like 'accepting a namespace for', 'finding packages by name', :npm_package_legacy
context 'set to nil' do
let_it_be(:namespace) { nil }
it { is_expected.to be_empty }
it_behaves_like 'avoids N+1 database queries in the package registry', :npm_package_legacy
end
end
end
end
describe '#last' do
@ -191,42 +154,5 @@ RSpec.describe ::Packages::Npm::PackageFinder, feature_category: :package_regist
it { is_expected.to eq(package2) }
end
end
context 'when npm_extract_npm_package_model is disabled' do
let_it_be_with_refind(:package) do
create(:npm_package_legacy, project: project, name: "#{FFaker::Lorem.word}-#{SecureRandom.hex(4)}")
end
before do
stub_feature_flags(npm_extract_npm_package_model: false)
end
context 'with a project' do
let(:finder) { described_class.new(project: project, params: params) }
it_behaves_like 'finding package by last'
end
context 'with a namespace' do
let(:finder) { described_class.new(namespace: namespace, params: params) }
it_behaves_like 'accepting a namespace for', 'finding package by last', :npm_package_legacy
context 'with duplicate packages' do
let_it_be(:namespace) { create(:group) }
let_it_be(:subgroup1) { create(:group, parent: namespace) }
let_it_be(:subgroup2) { create(:group, parent: namespace) }
let_it_be(:project2) { create(:project, namespace: subgroup2) }
let_it_be(:package2) { create(:npm_package_legacy, name: package.name, project: project2) }
before do
project.update!(namespace: subgroup1)
end
# the most recent one is returned
it { is_expected.to eq(package2) }
end
end
end
end
end

View File

@ -57,56 +57,5 @@ RSpec.describe ::Packages::Npm::PackagesForUserFinder, feature_category: :packag
end
end
end
context 'when npm_extract_npm_package_model is disabled' do
let_it_be(:package_name) { "#{FFaker::Lorem.word}-#{SecureRandom.hex(4)}" }
let_it_be(:package) { create(:npm_package_legacy, project: project, name: package_name) }
let_it_be(:package_with_diff_name) do
create(:npm_package_legacy, project: project, name: "#{FFaker::Lorem.word}-#{SecureRandom.hex(4)}")
end
let_it_be(:package_with_diff_project) { create(:npm_package_legacy, name: package_name, project: project2) }
before do
stub_feature_flags(npm_extract_npm_package_model: false)
end
context 'with a project' do
let(:project_or_group) { project }
it_behaves_like 'searches for packages'
it_behaves_like 'avoids N+1 database queries in the package registry', :npm_package_legacy
end
context 'with a group' do
let(:project_or_group) { group }
before_all do
project.add_reporter(user)
end
it_behaves_like 'searches for packages'
it_behaves_like 'avoids N+1 database queries in the package registry', :npm_package_legacy
context 'when an user is a reporter of both projects' do
before_all do
project2.add_reporter(user)
end
it { is_expected.to contain_exactly(package, package_with_diff_project) }
context 'when the second project has the package registry disabled' do
before_all do
project.reload.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
project2.reload.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC,
package_registry_access_level: 'disabled', packages_enabled: false)
end
it_behaves_like 'searches for packages'
end
end
end
end
end
end

View File

@ -79,7 +79,19 @@ describeSkipVue3(skipReason, () => {
});
it('search is hidden', () => {
expect(findSearchableTable().props('showSearch')).toBe(false);
expect(findSearchableTable().props()).toMatchObject({
showSearch: false,
sortableFields: [
{
label: 'Version',
orderBy: 'version',
},
{
label: 'Created',
orderBy: 'created_at',
},
],
});
});
});

View File

@ -16,6 +16,7 @@ import WorkItemActions from '~/work_items/components/work_item_actions.vue';
import WorkItemAbuseModal from '~/work_items/components/work_item_abuse_modal.vue';
import WorkItemStateToggle from '~/work_items/components/work_item_state_toggle.vue';
import CreateWorkItemModal from '~/work_items/components/create_work_item_modal.vue';
import WorkItemChangeTypeModal from '~/work_items/components/work_item_change_type_modal.vue';
import {
STATE_OPEN,
TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION,
@ -25,6 +26,7 @@ import {
TEST_ID_LOCK_ACTION,
TEST_ID_NOTIFICATIONS_TOGGLE_FORM,
TEST_ID_PROMOTE_ACTION,
TEST_ID_CHANGE_TYPE_ACTION,
TEST_ID_TOGGLE_ACTION,
TEST_ID_REPORT_ABUSE,
TEST_ID_NEW_RELATED_WORK_ITEM,
@ -64,8 +66,10 @@ describe('WorkItemActions component', () => {
wrapper.findByTestId(TEST_ID_COPY_CREATE_NOTE_EMAIL_ACTION);
const findReportAbuseButton = () => wrapper.findByTestId(TEST_ID_REPORT_ABUSE);
const findNewRelatedItemButton = () => wrapper.findByTestId(TEST_ID_NEW_RELATED_WORK_ITEM);
const findChangeTypeButton = () => wrapper.findByTestId(TEST_ID_CHANGE_TYPE_ACTION);
const findReportAbuseModal = () => wrapper.findComponent(WorkItemAbuseModal);
const findCreateWorkItemModal = () => wrapper.findComponent(CreateWorkItemModal);
const findWorkItemChangeTypeModal = () => wrapper.findComponent(WorkItemChangeTypeModal);
const findMoreDropdown = () => wrapper.findByTestId('work-item-actions-dropdown');
const findMoreDropdownTooltip = () => getBinding(findMoreDropdown().element, 'gl-tooltip');
const findDropdownItems = () => wrapper.findAll('[data-testid="work-item-actions-dropdown"] > *');
@ -124,6 +128,8 @@ describe('WorkItemActions component', () => {
hideSubscribe = undefined,
hasChildren = false,
canCreateRelatedItem = true,
workItemsBeta = true,
isDrawer = false,
} = {}) => {
wrapper = shallowMountExtended(WorkItemActions, {
isLoggedIn: isLoggedIn(),
@ -154,12 +160,16 @@ describe('WorkItemActions component', () => {
hideSubscribe,
hasChildren,
canCreateRelatedItem,
isDrawer,
},
mocks: {
$toast,
},
provide: {
fullPath: 'gitlab-org/gitlab-test',
glFeatures: {
workItemsBeta,
},
},
stubs: {
GlModal: stubComponent(GlModal, {
@ -173,6 +183,11 @@ describe('WorkItemActions component', () => {
close: modalShowSpy,
},
}),
WorkItemChangeTypeModal: stubComponent(WorkItemChangeTypeModal, {
methods: {
show: jest.fn(),
},
}),
},
});
};
@ -206,6 +221,10 @@ describe('WorkItemActions component', () => {
testId: TEST_ID_NEW_RELATED_WORK_ITEM,
text: 'New related task',
},
{
testId: TEST_ID_CHANGE_TYPE_ACTION,
text: 'Change type',
},
{
testId: TEST_ID_LOCK_ACTION,
text: 'Lock discussion',
@ -583,4 +602,32 @@ describe('WorkItemActions component', () => {
expect(wrapper.emitted('workItemCreated')).toHaveLength(1);
});
});
describe('change type action', () => {
it('opens the change type modal', () => {
createComponent({ workItemType: 'Task' });
findChangeTypeButton().vm.$emit('action');
expect(findWorkItemChangeTypeModal().exists()).toBe(true);
});
it('hides the action in case of `workItemBeta` is disabled', () => {
createComponent({ workItemType: 'Task', workItemsBeta: false });
expect(findChangeTypeButton().exists()).toBe(false);
});
it('hides the action in case of Epic type', () => {
createComponent({ workItemType: 'Epic' });
expect(findChangeTypeButton().exists()).toBe(false);
});
it('hides the action in case of drawer', () => {
createComponent({ isDrawer: true });
expect(findChangeTypeButton().exists()).toBe(false);
});
});
});

View File

@ -0,0 +1,248 @@
import { GlModal, GlFormSelect } from '@gitlab/ui';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import namespaceWorkItemTypesQueryResponse from 'test_fixtures/graphql/work_items/namespace_work_item_types.query.graphql.json';
import createMockApollo from 'helpers/mock_apollo_helper';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { stubComponent } from 'helpers/stub_component';
import WorkItemChangeTypeModal from '~/work_items/components/work_item_change_type_modal.vue';
import namespaceWorkItemTypesQuery from '~/work_items/graphql/namespace_work_item_types.query.graphql';
import convertWorkItemMutation from '~/work_items/graphql/work_item_convert.mutation.graphql';
import getWorkItemDesignListQuery from '~/work_items/components/design_management/graphql/design_collection.query.graphql';
import {
WORK_ITEM_TYPE_ENUM_KEY_RESULT,
WORK_ITEM_TYPE_VALUE_KEY_RESULT,
WORK_ITEM_TYPE_VALUE_TASK,
WORK_ITEM_TYPE_VALUE_ISSUE,
WORK_ITEM_WIDGETS_NAME_MAP,
} from '~/work_items/constants';
import {
convertWorkItemMutationResponse,
workItemChangeTypeWidgets,
workItemQueryResponse,
} from '../mock_data';
import { designCollectionResponse, mockDesign } from './design_management/mock_data';
describe('WorkItemChangeTypeModal component', () => {
Vue.use(VueApollo);
let wrapper;
const typesQuerySuccessHandler = jest.fn().mockResolvedValue(namespaceWorkItemTypesQueryResponse);
const keyResultTypeId =
namespaceWorkItemTypesQueryResponse.data.workspace.workItemTypes.nodes.find(
(type) => type.name === WORK_ITEM_TYPE_VALUE_KEY_RESULT,
).id;
const convertWorkItemMutationSuccessHandler = jest
.fn()
.mockResolvedValue(convertWorkItemMutationResponse);
const graphqlError = 'GraphQL error';
const convertWorkItemMutationErrorResponse = {
errors: [
{
message: graphqlError,
},
],
data: {
workItemConvert: null,
},
};
const noDesignQueryHandler = jest.fn().mockResolvedValue(designCollectionResponse([]));
const oneDesignQueryHandler = jest.fn().mockResolvedValue(designCollectionResponse([mockDesign]));
const createComponent = ({
hasOkrsFeature = true,
okrsMvc = true,
hasParent = false,
hasChildren = false,
widgets = [],
workItemType = WORK_ITEM_TYPE_VALUE_TASK,
convertWorkItemMutationHandler = convertWorkItemMutationSuccessHandler,
designQueryHandler = noDesignQueryHandler,
} = {}) => {
wrapper = mountExtended(WorkItemChangeTypeModal, {
apolloProvider: createMockApollo([
[namespaceWorkItemTypesQuery, typesQuerySuccessHandler],
[convertWorkItemMutation, convertWorkItemMutationHandler],
[getWorkItemDesignListQuery, designQueryHandler],
]),
propsData: {
workItemId: 'gid://gitlab/WorkItem/1',
fullPath: 'gitlab-org/gitlab-test',
hasParent,
hasChildren,
widgets,
workItemType,
allowedChildTypes: [{ name: WORK_ITEM_TYPE_VALUE_TASK }],
},
provide: {
hasOkrsFeature,
glFeatures: {
okrsMvc,
},
},
stubs: {
GlModal: stubComponent(GlModal, {
template:
'<div><slot name="modal-title"></slot><slot></slot><slot name="modal-footer"></slot></div>',
}),
},
});
};
const findChangeTypeModal = () => wrapper.findComponent(GlModal);
const findGlFormSelect = () => wrapper.findComponent(GlFormSelect);
const findWarningAlert = () => wrapper.findByTestId('change-type-warning-message');
const findErrorAlert = () => wrapper.findByTestId('change-type-error-message');
const findConfirmationButton = () => wrapper.findByTestId('change-type-confirmation-button');
beforeEach(async () => {
createComponent();
await waitForPromises();
});
it('renders change type modal with the select', () => {
expect(findChangeTypeModal().exists()).toBe(true);
expect(findGlFormSelect().exists()).toBe(true);
expect(findConfirmationButton().props('disabled')).toBe(true);
});
it('calls the `namespaceWorkItemTypesQuery` to get the work item types', () => {
expect(typesQuerySuccessHandler).toHaveBeenCalled();
});
it('renders all types as select options', () => {
expect(findGlFormSelect().findAll('option')).toHaveLength(4);
});
it('does not render objective and key result if `okrsMvc` is disabled', () => {
createComponent({ okrsMvc: false });
expect(findGlFormSelect().findAll('option')).toHaveLength(2);
});
it('does not allow to change type and disables `Change type` button when the work item has a parent', async () => {
createComponent({ hasParent: true, widgets: workItemQueryResponse.data.workItem.widgets });
findGlFormSelect().vm.$emit('change', WORK_ITEM_TYPE_ENUM_KEY_RESULT);
await nextTick();
expect(findWarningAlert().text()).toBe(
'Parent item type issue is not supported on key result. Remove the parent item to change type.',
);
expect(findConfirmationButton().props('disabled')).toBe(true);
});
it('does not allow to change type and disables `Change type` button when the work item has child items', async () => {
createComponent({ workItemType: WORK_ITEM_TYPE_VALUE_ISSUE, hasChildren: true });
findGlFormSelect().vm.$emit('change', WORK_ITEM_TYPE_ENUM_KEY_RESULT);
await nextTick();
expect(findWarningAlert().text()).toBe(
'Key result does not support the task child item types. Remove child items to change type.',
);
expect(findConfirmationButton().props('disabled')).toBe(true);
});
describe('when widget data has difference', () => {
it('shows warning message in case of designs', async () => {
createComponent({
workItemType: WORK_ITEM_TYPE_VALUE_ISSUE,
designQueryHandler: oneDesignQueryHandler,
});
await waitForPromises();
findGlFormSelect().vm.$emit('change', WORK_ITEM_TYPE_ENUM_KEY_RESULT);
await nextTick();
expect(findWarningAlert().text()).toContain('Design');
expect(findConfirmationButton().props('disabled')).toBe(false);
});
// These are all possible use cases of conflicts among project level work items
// Other widgets are shared between all the work item types
it.each`
widgetType | widgetData | workItemType | typeTobeConverted | expectedString
${WORK_ITEM_WIDGETS_NAME_MAP.MILESTONE} | ${workItemChangeTypeWidgets.MILESTONE} | ${WORK_ITEM_TYPE_VALUE_TASK} | ${WORK_ITEM_TYPE_ENUM_KEY_RESULT} | ${'Milestone'}
${WORK_ITEM_WIDGETS_NAME_MAP.DEVELOPMENT} | ${workItemChangeTypeWidgets.DEVELOPMENT} | ${WORK_ITEM_TYPE_VALUE_ISSUE} | ${WORK_ITEM_TYPE_ENUM_KEY_RESULT} | ${'Development'}
${WORK_ITEM_WIDGETS_NAME_MAP.CRM_CONTACTS} | ${workItemChangeTypeWidgets.CRM_CONTACTS} | ${WORK_ITEM_TYPE_VALUE_ISSUE} | ${WORK_ITEM_TYPE_ENUM_KEY_RESULT} | ${'Contacts'}
${WORK_ITEM_WIDGETS_NAME_MAP.TIME_TRACKING} | ${workItemChangeTypeWidgets.TIME_TRACKING} | ${WORK_ITEM_TYPE_VALUE_ISSUE} | ${WORK_ITEM_TYPE_ENUM_KEY_RESULT} | ${'Time tracking'}
`(
'shows warning message in case of $widgetType widget',
async ({ workItemType, widgetData, typeTobeConverted, expectedString }) => {
createComponent({
workItemType,
widgets: [widgetData],
});
await waitForPromises();
findGlFormSelect().vm.$emit('change', typeTobeConverted);
await nextTick();
expect(findWarningAlert().text()).toContain(expectedString);
expect(findConfirmationButton().props('disabled')).toBe(false);
},
);
});
describe('convert work item mutation', () => {
it('successfully changes a work item type when conditions are met', async () => {
createComponent();
await waitForPromises();
findGlFormSelect().vm.$emit('change', WORK_ITEM_TYPE_ENUM_KEY_RESULT);
await nextTick();
findConfirmationButton().vm.$emit('click');
await waitForPromises();
expect(convertWorkItemMutationSuccessHandler).toHaveBeenCalledWith({
input: {
id: 'gid://gitlab/WorkItem/1',
workItemTypeId: keyResultTypeId,
},
});
});
it.each`
errorType | expectedErrorMessage | failureHandler
${'graphql error'} | ${graphqlError} | ${jest.fn().mockResolvedValue(convertWorkItemMutationErrorResponse)}
${'network error'} | ${'Error: Network error'} | ${jest.fn().mockRejectedValue(new Error('Network error'))}
`(
'emits an error when there is a $errorType',
async ({ expectedErrorMessage, failureHandler }) => {
createComponent({
convertWorkItemMutationHandler: failureHandler,
});
await waitForPromises();
findGlFormSelect().vm.$emit('change', WORK_ITEM_TYPE_ENUM_KEY_RESULT);
await nextTick();
findConfirmationButton().vm.$emit('click');
await waitForPromises();
expect(findErrorAlert().text()).toContain(expectedErrorMessage);
},
);
});
});

View File

@ -796,6 +796,20 @@ describe('WorkItemDetail component', () => {
});
});
describe('work item change type', () => {
beforeEach(async () => {
createComponent();
await waitForPromises();
});
it('should call work item query on type change', async () => {
findWorkItemActions().vm.$emit('workItemTypeChanged');
await nextTick();
expect(successHandler).toHaveBeenCalled();
});
});
describe('todos widget', () => {
beforeEach(async () => {
isLoggedIn.mockReturnValue(false);

View File

@ -1836,6 +1836,72 @@ export const workItemObjectiveMetadataWidgets = {
},
};
export const workItemChangeTypeWidgets = {
MILESTONE: {
type: 'MILESTONE',
__typename: 'WorkItemWidgetMilestone',
milestone: mockMilestone,
},
ITERATION: {
type: 'ITERATION',
iteration: {
id: 'gid://gitlab/Iteration/86312',
__typename: 'Iteration',
},
__typename: 'WorkItemWidgetIteration',
},
DEVELOPMENT: {
type: 'DEVELOPMENT',
relatedBranches: {
nodes: [
{
id: '1',
},
],
__typename: 'WorkItemRelatedBranchConnection',
},
},
WEIGHT: {
type: 'WEIGHT',
weight: 1,
__typename: 'WorkItemWidgetWeight',
},
CRM_CONTACTS: {
type: 'CRM_CONTACTS',
contacts: {
nodes: [
{
id: 'gid://gitlab/CustomerRelations::Contact/50',
__typename: 'CustomerRelationsContact',
},
],
__typename: 'CustomerRelationsContactConnection',
},
__typename: 'WorkItemWidgetCrmContacts',
},
TIME_TRACKING: {
type: 'TIME_TRACKING',
timeEstimate: 10,
timelogs: {
nodes: [
{
__typename: 'WorkItemTimelog',
id: 'gid://gitlab/Timelog/2',
},
],
__typename: 'WorkItemTimelogConnection',
},
totalTimeSpent: 10800,
__typename: 'WorkItemWidgetTimeTracking',
},
PROGRESS: {
type: 'PROGRESS',
progress: 33,
updatedAt: '2024-12-05T16:24:56Z',
__typename: 'WorkItemWidgetProgress',
},
};
export const confidentialWorkItemTask = {
id: 'gid://gitlab/WorkItem/2',
iid: '2',

View File

@ -81,6 +81,7 @@ describe('Work items router', () => {
WorkItemAncestors: true,
WorkItemCreateBranchMergeRequestModal: true,
WorkItemDevelopment: true,
WorkItemChangeTypeModal: true,
},
});
};

View File

@ -2,175 +2,6 @@
require 'spec_helper'
RSpec.shared_examples "a measurable object" do
let(:part_type) { :range }
context 'when the table is not allowed' do
let(:source_table) { :_test_this_table_is_not_allowed }
it 'raises an error' do
expect(migration).to receive(:assert_table_is_allowed).with(source_table).and_call_original
expect do
subject
end.to raise_error(/#{source_table} is not allowed for use/)
end
end
context 'when run inside a transaction block' do
it 'raises an error' do
expect(migration).to receive(:transaction_open?).and_return(true)
expect do
subject
end.to raise_error(/can not be run inside a transaction/)
end
end
context 'when the given table does not have a primary key' do
it 'raises an error' do
migration.execute(<<~SQL)
ALTER TABLE #{source_table}
DROP CONSTRAINT #{source_table}_pkey
SQL
expect do
subject
end.to raise_error(/primary key not defined for #{source_table}/)
end
end
it 'creates the partitioned table with the same non-key columns' do
subject
copied_columns = filter_columns_by_name(connection.columns(partitioned_table), new_primary_key)
original_columns = filter_columns_by_name(connection.columns(source_table), new_primary_key)
expect(copied_columns).to match_array(original_columns)
end
it 'removes the default from the primary key column' do
subject
pk_column = connection.columns(partitioned_table).find { |c| c.name == old_primary_key }
expect(pk_column.default_function).to be_nil
end
describe 'constructing the partitioned table' do
it 'creates a table partitioned by the proper column' do
subject
expect(connection.table_exists?(partitioned_table)).to be(true)
expect(connection.primary_key(partitioned_table)).to eq(new_primary_key)
expect_table_partitioned_by(partitioned_table, [partition_column_name], part_type: part_type)
end
it 'requires the migration helper to be run in DDL mode' do
expect(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas).to receive(:require_ddl_mode!)
subject
expect(connection.table_exists?(partitioned_table)).to be(true)
expect(connection.primary_key(partitioned_table)).to eq(new_primary_key)
expect_table_partitioned_by(partitioned_table, [partition_column_name], part_type: part_type)
end
it 'changes the primary key datatype to bigint' do
subject
pk_column = connection.columns(partitioned_table).find { |c| c.name == old_primary_key }
expect(pk_column.sql_type).to eq('bigint')
end
it 'removes the default from the primary key column' do
subject
pk_column = connection.columns(partitioned_table).find { |c| c.name == old_primary_key }
expect(pk_column.default_function).to be_nil
end
it 'creates the partitioned table with the same non-key columns' do
subject
copied_columns = filter_columns_by_name(connection.columns(partitioned_table), new_primary_key)
original_columns = filter_columns_by_name(connection.columns(source_table), new_primary_key)
expect(copied_columns).to match_array(original_columns)
end
end
describe 'keeping data in sync with the partitioned table' do
before do
partitioned_model.primary_key = :id
partitioned_model.table_name = partitioned_table
end
it 'creates a trigger function on the original table' do
expect_function_not_to_exist(function_name)
expect_trigger_not_to_exist(source_table, trigger_name)
subject
expect_function_to_exist(function_name)
expect_valid_function_trigger(source_table, trigger_name, function_name, after: %w[delete insert update])
end
it 'syncs inserts to the partitioned tables' do
subject
expect(partitioned_model.count).to eq(0)
first_record = source_model.create!(name: 'Bob', age: 20, created_at: timestamp, external_id: 1, updated_at: timestamp)
second_record = source_model.create!(name: 'Alice', age: 30, created_at: timestamp, external_id: 2, updated_at: timestamp)
expect(partitioned_model.count).to eq(2)
expect(partitioned_model.find(first_record.id).attributes).to eq(first_record.attributes)
expect(partitioned_model.find(second_record.id).attributes).to eq(second_record.attributes)
end
it 'syncs updates to the partitioned tables' do
subject
first_record = source_model.create!(name: 'Bob', age: 20, created_at: timestamp, external_id: 1, updated_at: timestamp)
second_record = source_model.create!(name: 'Alice', age: 30, created_at: timestamp, external_id: 2, updated_at: timestamp)
expect(partitioned_model.count).to eq(2)
first_copy = partitioned_model.find(first_record.id)
second_copy = partitioned_model.find(second_record.id)
expect(first_copy.attributes).to eq(first_record.attributes)
expect(second_copy.attributes).to eq(second_record.attributes)
first_record.update!(age: 21, updated_at: timestamp + 1.hour, external_id: 3)
expect(partitioned_model.count).to eq(2)
expect(first_copy.reload.attributes).to eq(first_record.attributes)
expect(second_copy.reload.attributes).to eq(second_record.attributes)
end
it 'syncs deletes to the partitioned tables' do
subject
first_record = source_model.create!(name: 'Bob', age: 20, created_at: timestamp, external_id: 1, updated_at: timestamp)
second_record = source_model.create!(name: 'Alice', age: 30, created_at: timestamp, external_id: 2, updated_at: timestamp)
expect(partitioned_model.count).to eq(2)
first_record.destroy!
expect(partitioned_model.count).to eq(1)
expect(partitioned_model.find_by_id(first_record.id)).to be_nil
expect(partitioned_model.find(second_record.id).attributes).to eq(second_record.attributes)
end
end
end
RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHelpers, feature_category: :database do
include Database::PartitioningHelpers
include Database::TriggerHelpers

View File

@ -5,32 +5,12 @@ require 'spec_helper'
RSpec.describe Packages::Npm::Metadatum, type: :model, feature_category: :package_registry do
describe 'relationships' do
it { is_expected.to belong_to(:package).class_name('Packages::Npm::Package').inverse_of(:npm_metadatum) }
# TODO: Remove with the rollout of the FF npm_extract_npm_package_model
# https://gitlab.com/gitlab-org/gitlab/-/issues/501469
it 'belongs to `legacy_package`' do
is_expected.to belong_to(:legacy_package).conditions(package_type: :npm).class_name('Packages::Package')
.inverse_of(:npm_metadatum).with_foreign_key(:package_id)
end
end
describe 'validations' do
describe 'package', :aggregate_failures do
it { is_expected.to validate_presence_of(:package) }
# TODO: Remove with the rollout of the FF npm_extract_npm_package_model
# https://gitlab.com/gitlab-org/gitlab/-/issues/501469
it { is_expected.not_to validate_presence_of(:legacy_package) }
context 'when npm_extract_npm_package_model is disabled' do
before do
stub_feature_flags(npm_extract_npm_package_model: false)
end
it { is_expected.to validate_presence_of(:legacy_package) }
it { is_expected.not_to validate_presence_of(:package) }
end
describe '#ensure_npm_package_type', :aggregate_failures do
subject(:npm_metadatum) { build(:npm_metadatum) }
@ -45,19 +25,6 @@ RSpec.describe Packages::Npm::Metadatum, type: :model, feature_category: :packag
it 'raises the error' do
expect { build(:npm_metadatum, package: package) }.to raise_error(ActiveRecord::AssociationTypeMismatch)
end
context 'when npm_extract_npm_package_model is disabled' do
before do
stub_feature_flags(npm_extract_npm_package_model: false)
end
it 'adds the validation error' do
npm_metadatum = build(:npm_metadatum, legacy_package: package, package: nil)
expect(npm_metadatum).not_to be_valid
expect(npm_metadatum.errors.to_a).to include('Package type must be NPM')
end
end
end
end
end

View File

@ -18,9 +18,6 @@ RSpec.describe Packages::Package, type: :model, feature_category: :package_regis
it { is_expected.to have_many(:tags).inverse_of(:package) }
it { is_expected.to have_many(:build_infos).inverse_of(:package) }
it { is_expected.to have_one(:maven_metadatum).inverse_of(:package) }
# TODO: Remove with the rollout of the FF npm_extract_npm_package_model
# https://gitlab.com/gitlab-org/gitlab/-/issues/501469
it { is_expected.to have_one(:npm_metadatum).inverse_of(:package) }
end
describe '.sort_by_attribute' do
@ -108,20 +105,6 @@ RSpec.describe Packages::Package, type: :model, feature_category: :package_regis
it { is_expected.to allow_value("my/domain/com/my-app").for(:name) }
it { is_expected.to allow_value("my.app-11.07.2018").for(:name) }
it { is_expected.not_to allow_value("my(dom$$$ain)com.my-app").for(:name) }
# TODO: Remove with the rollout of the FF npm_extract_npm_package_model
# https://gitlab.com/gitlab-org/gitlab/-/issues/501469
context 'npm package' do
subject { build_stubbed(:npm_package_legacy) }
it { is_expected.to allow_value("@group-1/package").for(:name) }
it { is_expected.to allow_value("@any-scope/package").for(:name) }
it { is_expected.to allow_value("unscoped-package").for(:name) }
it { is_expected.not_to allow_value("@inv@lid-scope/package").for(:name) }
it { is_expected.not_to allow_value("@scope/../../package").for(:name) }
it { is_expected.not_to allow_value("@scope%2e%2e%fpackage").for(:name) }
it { is_expected.not_to allow_value("@scope/sub/package").for(:name) }
end
end
describe '#version' do
@ -152,195 +135,6 @@ RSpec.describe Packages::Package, type: :model, feature_category: :package_regis
it { is_expected.not_to allow_value('../../../../../1.2.3').for(:version) }
it { is_expected.not_to allow_value('%2e%2e%2f1.2.3').for(:version) }
end
# TODO: Remove with the rollout of the FF npm_extract_npm_package_model
# https://gitlab.com/gitlab-org/gitlab/-/issues/501469
it_behaves_like 'validating version to be SemVer compliant for', :npm_package_legacy
end
# TODO: Remove with the rollout of the FF npm_extract_npm_package_model
# https://gitlab.com/gitlab-org/gitlab/-/issues/501469
describe '#npm_package_already_taken' do
context 'maven package' do
let!(:package) { create(:maven_package) }
it 'will allow a package of the same name' do
new_package = build(:maven_package, name: package.name)
expect(new_package).to be_valid
end
end
context 'npm package' do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, namespace: group) }
let_it_be(:second_project) { create(:project, namespace: group) }
let(:package) { build(:npm_package_legacy, project: project, name: name) }
shared_examples 'validating the first package' do
it 'validates the first package' do
expect(package).to be_valid
end
end
shared_examples 'validating the second package' do
it 'validates the second package' do
package.save!
expect(second_package).to be_valid
end
end
shared_examples 'not validating the second package' do |field_with_error:|
it 'does not validate the second package' do
package.save!
expect(second_package).not_to be_valid
case field_with_error
when :base
expect(second_package.errors.messages[:base]).to eq ['Package already exists']
when :name
expect(second_package.errors.messages[:name]).to eq ['has already been taken']
else
raise ArgumentError, "field #{field_with_error} not expected"
end
end
end
shared_examples 'validating both if the first package is pending destruction' do
before do
package.status = :pending_destruction
end
it_behaves_like 'validating the first package'
it_behaves_like 'validating the second package'
end
context 'following the naming convention' do
let(:name) { "@#{group.path}/test" }
context 'with the second package in the project of the first package' do
let(:second_package) { build(:npm_package_legacy, project: project, name: second_package_name, version: second_package_version) }
context 'with no duplicated name' do
let(:second_package_name) { "@#{group.path}/test2" }
let(:second_package_version) { '5.0.0' }
it_behaves_like 'validating the first package'
it_behaves_like 'validating the second package'
end
context 'with duplicated name' do
let(:second_package_name) { package.name }
let(:second_package_version) { '5.0.0' }
it_behaves_like 'validating the first package'
it_behaves_like 'validating the second package'
end
context 'with duplicate name and duplicated version' do
let(:second_package_name) { package.name }
let(:second_package_version) { package.version }
it_behaves_like 'validating the first package'
it_behaves_like 'not validating the second package', field_with_error: :name
it_behaves_like 'validating both if the first package is pending destruction'
end
end
context 'with the second package in a different project than the first package' do
let(:second_package) { build(:npm_package_legacy, project: second_project, name: second_package_name, version: second_package_version) }
context 'with no duplicated name' do
let(:second_package_name) { "@#{group.path}/test2" }
let(:second_package_version) { '5.0.0' }
it_behaves_like 'validating the first package'
it_behaves_like 'validating the second package'
end
context 'with duplicated name' do
let(:second_package_name) { package.name }
let(:second_package_version) { '5.0.0' }
it_behaves_like 'validating the first package'
it_behaves_like 'validating the second package'
end
context 'with duplicate name and duplicated version' do
let(:second_package_name) { package.name }
let(:second_package_version) { package.version }
it_behaves_like 'validating the first package'
it_behaves_like 'not validating the second package', field_with_error: :base
it_behaves_like 'validating both if the first package is pending destruction'
end
end
end
context 'not following the naming convention' do
let(:name) { '@foobar/test' }
context 'with the second package in the project of the first package' do
let(:second_package) { build(:npm_package_legacy, project: project, name: second_package_name, version: second_package_version) }
context 'with no duplicated name' do
let(:second_package_name) { "@foobar/test2" }
let(:second_package_version) { '5.0.0' }
it_behaves_like 'validating the first package'
it_behaves_like 'validating the second package'
end
context 'with duplicated name' do
let(:second_package_name) { package.name }
let(:second_package_version) { '5.0.0' }
it_behaves_like 'validating the first package'
it_behaves_like 'validating the second package'
end
context 'with duplicate name and duplicated version' do
let(:second_package_name) { package.name }
let(:second_package_version) { package.version }
it_behaves_like 'validating the first package'
it_behaves_like 'not validating the second package', field_with_error: :name
it_behaves_like 'validating both if the first package is pending destruction'
end
end
context 'with the second package in a different project than the first package' do
let(:second_package) { build(:npm_package_legacy, project: second_project, name: second_package_name, version: second_package_version) }
context 'with no duplicated name' do
let(:second_package_name) { "@foobar/test2" }
let(:second_package_version) { '5.0.0' }
it_behaves_like 'validating the first package'
it_behaves_like 'validating the second package'
end
context 'with duplicated name' do
let(:second_package_name) { package.name }
let(:second_package_version) { '5.0.0' }
it_behaves_like 'validating the first package'
it_behaves_like 'validating the second package'
end
context 'with duplicate name and duplicated version' do
let(:second_package_name) { package.name }
let(:second_package_version) { package.version }
it_behaves_like 'validating the first package'
it_behaves_like 'validating the second package'
it_behaves_like 'validating both if the first package is pending destruction'
end
end
end
end
end
describe '#prevent_concurrent_inserts' do
@ -465,18 +259,6 @@ RSpec.describe Packages::Package, type: :model, feature_category: :package_regis
end
end
# TODO: Remove with the rollout of the FF npm_extract_npm_package_model
# https://gitlab.com/gitlab-org/gitlab/-/issues/501469
describe '.with_npm_scope' do
let_it_be(:package1) { create(:npm_package, name: '@test/foobar') }
let_it_be(:package2) { create(:npm_package, name: '@test2/foobar') }
let_it_be(:package3) { create(:npm_package, name: 'foobar') }
subject { described_class.with_npm_scope('test') }
it { is_expected.to contain_exactly(package1) }
end
describe '.limit_recent' do
let!(:package1) { create(:nuget_package) }
let!(:package2) { create(:nuget_package) }
@ -828,32 +610,6 @@ RSpec.describe Packages::Package, type: :model, feature_category: :package_regis
end
end
# TODO: Remove with the rollout of the FF npm_extract_npm_package_model
# https://gitlab.com/gitlab-org/gitlab/-/issues/501469
describe '#sync_npm_metadata_cache' do
let_it_be(:package) { create(:npm_package) }
subject { package.sync_npm_metadata_cache }
it 'enqueues a sync worker job' do
expect(::Packages::Npm::CreateMetadataCacheWorker)
.to receive(:perform_async).with(package.project_id, package.name)
subject
end
context 'with a non npm package' do
let_it_be(:package) { create(:maven_package) }
it 'does not enqueue a sync worker job' do
expect(::Packages::Npm::CreateMetadataCacheWorker)
.not_to receive(:perform_async)
subject
end
end
end
describe '#mark_package_files_for_destruction' do
let_it_be(:package) { create(:npm_package, :pending_destruction) }
@ -983,19 +739,5 @@ RSpec.describe Packages::Package, type: :model, feature_category: :package_regis
end
end
end
context 'when npm_extract_npm_package_model is disabled' do
before do
stub_feature_flags(npm_extract_npm_package_model: false)
end
context 'for package format npm' do
let(:format) { :npm }
it 'maps to Packages::Package' do
is_expected.to eq(described_class)
end
end
end
end
end

View File

@ -8262,31 +8262,6 @@ RSpec.describe Project, factory_default: :keep, feature_category: :groups_and_pr
end
end
# TODO: Remove with the rollout of the FF npm_extract_npm_package_model
# https://gitlab.com/gitlab-org/gitlab/-/issues/501469
describe '#has_namespaced_npm_packages?' do
let_it_be(:namespace) { create(:namespace, path: 'test') }
let_it_be(:project) { create(:project, :public, namespace: namespace) }
subject { project.has_namespaced_npm_packages? }
context 'with scope of the namespace path' do
let_it_be(:package) { create(:npm_package, project: project, name: "@#{namespace.path}/foo") }
it { is_expected.to be true }
end
context 'without scope of the namespace path' do
let_it_be(:package) { create(:npm_package, project: project, name: "@someotherscope/foo") }
it { is_expected.to be false }
end
context 'without packages' do
it { is_expected.to be false }
end
end
describe '#package_already_taken?' do
let_it_be(:namespace) { create(:namespace, path: 'test') }
let_it_be(:project) { create(:project, :public, namespace: namespace) }

View File

@ -194,16 +194,6 @@ RSpec.describe API::NpmProjectPackages, feature_category: :package_registry do
it_behaves_like 'successfully downloads the file'
end
context 'when npm_extract_npm_package_model is disabled' do
let_it_be(:package, reload: true) { create(:npm_package_legacy, project: project, name: FFaker::Lorem.word, version: '1.2.3') }
before do
stub_feature_flags(npm_extract_npm_package_model: false)
end
it_behaves_like 'successfully downloads the file'
end
end
context 'private project' do

View File

@ -251,7 +251,7 @@ RSpec.describe Git::WikiPushService, :services, feature_category: :wiki do
message = 'something went very very wrong'
allow_next_instance_of(WikiPages::EventCreateService, current_user) do |service|
allow(service).to receive(:execute)
.with(String, WikiPage, Symbol, String)
.with(WikiPage::Meta, Symbol, String)
.and_return(ServiceResponse.error(message: message))
end

View File

@ -95,29 +95,6 @@ RSpec.describe Groups::TransferService, :sidekiq_inline, feature_category: :grou
it_behaves_like 'transfer allowed'
end
context 'when npm_extract_npm_package_model is disabled' do
before do
stub_feature_flags(npm_extract_npm_package_model: false)
end
it 'does not allow transfer' do
transfer_service.execute(new_group)
expect(transfer_service.error).to eq('Transfer failed: Group contains projects with NPM packages scoped to the current root level group.')
expect(group.parent).not_to eq(new_group)
end
context 'namespaced package is pending destruction' do
let!(:group) { create(:group) }
before do
package.pending_destruction!
end
it_behaves_like 'transfer allowed'
end
end
end
context 'when transferring a group into a root group' do

View File

@ -334,15 +334,6 @@ RSpec.describe Groups::UpdateService, feature_category: :groups_and_projects do
it_behaves_like 'not allowing a path update'
it_behaves_like 'allowing an update', on: :name
context 'when npm_extract_npm_package_model is disabled' do
before do
stub_feature_flags(npm_extract_npm_package_model: false)
end
it_behaves_like 'not allowing a path update'
it_behaves_like 'allowing an update', on: :name
end
end
context 'updating the subgroup' do
@ -361,15 +352,6 @@ RSpec.describe Groups::UpdateService, feature_category: :groups_and_projects do
it_behaves_like 'allowing an update', on: :path
it_behaves_like 'allowing an update', on: :name
context 'when npm_extract_npm_package_model is disabled' do
before do
stub_feature_flags(npm_extract_npm_package_model: false)
end
it_behaves_like 'allowing an update', on: :path
it_behaves_like 'allowing an update', on: :name
end
end
context 'updating the subgroup' do
@ -388,15 +370,6 @@ RSpec.describe Groups::UpdateService, feature_category: :groups_and_projects do
it_behaves_like 'allowing an update', on: :path
it_behaves_like 'allowing an update', on: :name
context 'when npm_extract_npm_package_model is disabled' do
before do
stub_feature_flags(npm_extract_npm_package_model: false)
end
it_behaves_like 'allowing an update', on: :path
it_behaves_like 'allowing an update', on: :name
end
end
context 'updating the subgroup' do

View File

@ -213,16 +213,6 @@ RSpec.describe Packages::Npm::CreatePackageService, feature_category: :package_r
it { is_expected.to have_attributes reason: :package_already_exists }
end
context 'when npm_extract_npm_package_model is disabled' do
before do
stub_feature_flags(npm_extract_npm_package_model: false)
end
it_behaves_like 'returning an error service response', message: 'Package already exists.' do
it { is_expected.to have_attributes reason: :package_already_exists }
end
end
context 'when marked as pending_destruction' do
before do
existing_package.pending_destruction!

View File

@ -39,14 +39,6 @@ RSpec.describe ::Packages::Npm::ProcessPackageFileService, feature_category: :pa
it_behaves_like 'raising an error', 'invalid package file'
end
context 'when linked to a non npm package' do
before do
allow(package).to receive(:npm?).and_return(false)
end
it_behaves_like 'raising an error', 'invalid package file'
end
context 'with a 0 byte package file' do
before do
allow(package_file.file).to receive(:size).and_return(0)

View File

@ -37,16 +37,6 @@ RSpec.describe Projects::TransferService, feature_category: :groups_and_projects
it_behaves_like 'allow the transfer' do
let(:namespace) { group }
end
context 'when npm_extract_npm_package_model is disabled' do
before do
stub_feature_flags(npm_extract_npm_package_model: false)
end
it_behaves_like 'allow the transfer' do
let(:namespace) { group }
end
end
end
context 'with pending destruction package' do
@ -57,16 +47,6 @@ RSpec.describe Projects::TransferService, feature_category: :groups_and_projects
it_behaves_like 'allow the transfer' do
let(:namespace) { group }
end
context 'when npm_extract_npm_package_model is disabled' do
before do
stub_feature_flags(npm_extract_npm_package_model: false)
end
it_behaves_like 'allow the transfer' do
let(:namespace) { group }
end
end
end
context 'with namespaced packages present' do
@ -76,17 +56,6 @@ RSpec.describe Projects::TransferService, feature_category: :groups_and_projects
expect(transfer_service.execute(group)).to be false
expect(project.errors[:new_namespace]).to include("Root namespace can't be updated if the project has NPM packages scoped to the current root level namespace.")
end
context 'when npm_extract_npm_package_model is disabled' do
before do
stub_feature_flags(npm_extract_npm_package_model: false)
end
it 'does not allow the transfer' do
expect(transfer_service.execute(group)).to be false
expect(project.errors[:new_namespace]).to include("Root namespace can't be updated if the project has NPM packages scoped to the current root level namespace.")
end
end
end
context 'without a root namespace change' do
@ -102,16 +71,6 @@ RSpec.describe Projects::TransferService, feature_category: :groups_and_projects
it_behaves_like 'allow the transfer' do
let(:namespace) { other_group }
end
context 'when npm_extract_npm_package_model is disabled' do
before do
stub_feature_flags(npm_extract_npm_package_model: false)
end
it_behaves_like 'allow the transfer' do
let(:namespace) { other_group }
end
end
end
end

View File

@ -11,10 +11,10 @@ RSpec.describe WikiPages::EventCreateService, feature_category: :wiki do
describe '#execute' do
let_it_be(:page) { create(:wiki_page, project: project) }
let(:slug) { generate(:sluggified_title) }
let(:action) { :created }
let(:fingerprint) { page.sha }
let(:response) { subject.execute(slug, page, action, fingerprint) }
let(:wiki_page_meta) { create(:wiki_page_meta) }
let(:response) { subject.execute(wiki_page_meta, action, fingerprint) }
context 'the user is nil' do
subject { described_class.new(nil) }
@ -40,18 +40,6 @@ RSpec.describe WikiPages::EventCreateService, feature_category: :wiki do
expect(response).to be_success
end
context 'the action is a deletion' do
let(:action) { :destroyed }
it 'does not synchronize the wiki metadata timestamps with the git commit' do
expect_next_instance_of(WikiPage::Meta) do |instance|
expect(instance).not_to receive(:synch_times_with_page)
end
response
end
end
it 'creates a wiki page event' do
expect { response }.to change(Event, :count).by(1)
end
@ -59,12 +47,5 @@ RSpec.describe WikiPages::EventCreateService, feature_category: :wiki do
it 'returns an event in the payload' do
expect(response.payload).to include(event: have_attributes(author: user, wiki_page?: true, action: 'created'))
end
it 'records the slug for the page' do
response
meta = WikiPage::Meta.find_or_create(page.slug, page)
expect(meta.slugs.pluck(:slug)).to include(slug)
end
end
end

View File

@ -0,0 +1,176 @@
# frozen_string_literal: true
RSpec.shared_examples "a measurable object" do
let(:part_type) { :range }
context 'when the table is not allowed' do
let(:source_table) { :_test_this_table_is_not_allowed }
it 'raises an error' do
expect(migration).to receive(:assert_table_is_allowed).with(source_table).and_call_original
expect do
subject
end.to raise_error(/#{source_table} is not allowed for use/)
end
end
context 'when run inside a transaction block' do
it 'raises an error' do
expect(migration).to receive(:transaction_open?).and_return(true)
expect do
subject
end.to raise_error(/can not be run inside a transaction/)
end
end
context 'when the given table does not have a primary key' do
it 'raises an error' do
migration.execute(<<~SQL)
ALTER TABLE #{source_table}
DROP CONSTRAINT #{source_table}_pkey
SQL
expect do
subject
end.to raise_error(/primary key not defined for #{source_table}/)
end
end
it 'creates the partitioned table with the same non-key columns' do
subject
copied_columns = filter_columns_by_name(connection.columns(partitioned_table), new_primary_key)
original_columns = filter_columns_by_name(connection.columns(source_table), new_primary_key)
expect(copied_columns).to match_array(original_columns)
end
it 'removes the default from the primary key column' do
subject
pk_column = connection.columns(partitioned_table).find { |c| c.name == old_primary_key }
expect(pk_column.default_function).to be_nil
end
describe 'constructing the partitioned table' do
it 'creates a table partitioned by the proper column' do
subject
expect(connection.table_exists?(partitioned_table)).to be(true)
expect(connection.primary_key(partitioned_table)).to eq(new_primary_key)
expect_table_partitioned_by(partitioned_table, [partition_column_name], part_type: part_type)
end
it 'requires the migration helper to be run in DDL mode' do
expect(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas).to receive(:require_ddl_mode!)
subject
expect(connection.table_exists?(partitioned_table)).to be(true)
expect(connection.primary_key(partitioned_table)).to eq(new_primary_key)
expect_table_partitioned_by(partitioned_table, [partition_column_name], part_type: part_type)
end
it 'changes the primary key datatype to bigint' do
subject
pk_column = connection.columns(partitioned_table).find { |c| c.name == old_primary_key }
expect(pk_column.sql_type).to eq('bigint')
end
it 'removes the default from the primary key column' do
subject
pk_column = connection.columns(partitioned_table).find { |c| c.name == old_primary_key }
expect(pk_column.default_function).to be_nil
end
it 'creates the partitioned table with the same non-key columns' do
subject
copied_columns = filter_columns_by_name(connection.columns(partitioned_table), new_primary_key)
original_columns = filter_columns_by_name(connection.columns(source_table), new_primary_key)
expect(copied_columns).to match_array(original_columns)
end
end
describe 'keeping data in sync with the partitioned table' do
before do
partitioned_model.primary_key = :id
partitioned_model.table_name = partitioned_table
end
it 'creates a trigger function on the original table' do
expect_function_not_to_exist(function_name)
expect_trigger_not_to_exist(source_table, trigger_name)
subject
expect_function_to_exist(function_name)
expect_valid_function_trigger(source_table, trigger_name, function_name, after: %w[delete insert update])
end
it 'syncs inserts to the partitioned tables' do
subject
expect(partitioned_model.count).to eq(0)
first_record = source_model.create!(name: 'Bob', age: 20, created_at: timestamp, external_id: 1,
updated_at: timestamp)
second_record = source_model.create!(name: 'Alice', age: 30, created_at: timestamp, external_id: 2,
updated_at: timestamp)
expect(partitioned_model.count).to eq(2)
expect(partitioned_model.find(first_record.id).attributes).to eq(first_record.attributes)
expect(partitioned_model.find(second_record.id).attributes).to eq(second_record.attributes)
end
it 'syncs updates to the partitioned tables' do
subject
first_record = source_model.create!(name: 'Bob', age: 20, created_at: timestamp, external_id: 1,
updated_at: timestamp)
second_record = source_model.create!(name: 'Alice', age: 30, created_at: timestamp, external_id: 2,
updated_at: timestamp)
expect(partitioned_model.count).to eq(2)
first_copy = partitioned_model.find(first_record.id)
second_copy = partitioned_model.find(second_record.id)
expect(first_copy.attributes).to eq(first_record.attributes)
expect(second_copy.attributes).to eq(second_record.attributes)
first_record.update!(age: 21, updated_at: timestamp + 1.hour, external_id: 3)
expect(partitioned_model.count).to eq(2)
expect(first_copy.reload.attributes).to eq(first_record.attributes)
expect(second_copy.reload.attributes).to eq(second_record.attributes)
end
it 'syncs deletes to the partitioned tables' do
subject
first_record = source_model.create!(name: 'Bob', age: 20, created_at: timestamp, external_id: 1,
updated_at: timestamp)
second_record = source_model.create!(name: 'Alice', age: 30, created_at: timestamp, external_id: 2,
updated_at: timestamp)
expect(partitioned_model.count).to eq(2)
first_record.destroy!
expect(partitioned_model.count).to eq(1)
expect(partitioned_model.find_by_id(first_record.id)).to be_nil
expect(partitioned_model.find(second_record.id).attributes).to eq(second_record.attributes)
end
end
end