From 858d175c1527d650ea5d83e201777d0cf8ae84c9 Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Thu, 9 Mar 2023 17:30:09 +0000 Subject: Add latest changes from gitlab-org/gitlab@15-9-stable-ee --- .../issues/show/components/description_spec.js | 392 ++++++++++----------- .../ci/runner/runner_fleet_pipeline_seeder_spec.rb | 3 +- ...0_finalize_backfill_user_details_fields_spec.rb | 109 ++++++ spec/models/uploads/fog_spec.rb | 87 ++++- spec/support/helpers/stub_object_storage.rb | 4 +- 5 files changed, 382 insertions(+), 213 deletions(-) create mode 100644 spec/migrations/20221102231130_finalize_backfill_user_details_fields_spec.rb (limited to 'spec') diff --git a/spec/frontend/issues/show/components/description_spec.js b/spec/frontend/issues/show/components/description_spec.js index 3f4513e6bfa..da51372dd3d 100644 --- a/spec/frontend/issues/show/components/description_spec.js +++ b/spec/frontend/issues/show/components/description_spec.js @@ -310,69 +310,58 @@ describe('Description component', () => { }); }); - describe('with work_items_mvc feature flag enabled', () => { - describe('empty description', () => { - beforeEach(() => { - createComponent({ - props: { - descriptionHtml: '', - }, - provide: { - glFeatures: { - workItemsMvc: true, - }, - }, - }); - return nextTick(); + describe('empty description', () => { + beforeEach(() => { + createComponent({ + props: { + descriptionHtml: '', + }, }); + return nextTick(); + }); - it('renders without error', () => { - expect(findTaskActionButtons()).toHaveLength(0); - }); + it('renders without error', () => { + expect(findTaskActionButtons()).toHaveLength(0); }); + }); - describe('description with checkboxes', () => { - beforeEach(() => { - createComponent({ - props: { - descriptionHtml: descriptionHtmlWithCheckboxes, - }, - provide: { - glFeatures: { - workItemsMvc: true, - }, - }, - }); - return nextTick(); + describe('description with checkboxes', () => { + beforeEach(() => { + createComponent({ + props: { + descriptionHtml: descriptionHtmlWithCheckboxes, + }, }); + return nextTick(); + }); - it('renders a list of hidden buttons corresponding to checkboxes in description HTML', () => { - expect(findTaskActionButtons()).toHaveLength(3); - }); + it('renders a list of hidden buttons corresponding to checkboxes in description HTML', () => { + expect(findTaskActionButtons()).toHaveLength(3); + }); - it('does not show a modal by default', () => { - expect(findModal().exists()).toBe(false); - }); + it('does not show a modal by default', () => { + expect(findModal().exists()).toBe(false); + }); - it('shows toast after delete success', async () => { - const newDesc = 'description'; - findWorkItemDetailModal().vm.$emit('workItemDeleted', newDesc); + it('shows toast after delete success', async () => { + const newDesc = 'description'; + findWorkItemDetailModal().vm.$emit('workItemDeleted', newDesc); - expect(wrapper.emitted('updateDescription')).toEqual([[newDesc]]); - expect($toast.show).toHaveBeenCalledWith('Task deleted'); - }); + expect(wrapper.emitted('updateDescription')).toEqual([[newDesc]]); + expect($toast.show).toHaveBeenCalledWith('Task deleted'); }); + }); - describe('task list item actions', () => { - describe('converting the task list item to a task', () => { - describe('when successful', () => { - let createWorkItemMutationHandler; + describe('task list item actions', () => { + describe('converting the task list item to a task', () => { + describe('when successful', () => { + let createWorkItemMutationHandler; - beforeEach(async () => { - createWorkItemMutationHandler = jest - .fn() - .mockResolvedValue(createWorkItemMutationResponse); - const descriptionText = `Tasks + beforeEach(async () => { + createWorkItemMutationHandler = jest + .fn() + .mockResolvedValue(createWorkItemMutationResponse); + const descriptionText = `Tasks 1. [ ] item 1 1. [ ] item 2 @@ -381,218 +370,207 @@ describe('Description component', () => { 1. [ ] item 3 1. [ ] item 4;`; - createComponent({ - props: { descriptionText }, - provide: { glFeatures: { workItemsMvc: true } }, - createWorkItemMutationHandler, - }); - await waitForPromises(); - - eventHub.$emit('convert-task-list-item', '4:4-8:19'); - await waitForPromises(); + createComponent({ + props: { descriptionText }, + createWorkItemMutationHandler, }); + await waitForPromises(); - it('emits an event to update the description with the deleted task list item omitted', () => { - const newDescriptionText = `Tasks + eventHub.$emit('convert-task-list-item', '4:4-8:19'); + await waitForPromises(); + }); + + it('emits an event to update the description with the deleted task list item omitted', () => { + const newDescriptionText = `Tasks 1. [ ] item 1 1. [ ] item 3 1. [ ] item 4;`; - expect(wrapper.emitted('saveDescription')).toEqual([[newDescriptionText]]); - }); + expect(wrapper.emitted('saveDescription')).toEqual([[newDescriptionText]]); + }); - it('calls a mutation to create a task', () => { - const { + it('calls a mutation to create a task', () => { + const { + confidential, + iteration, + milestone, + } = issueDetailsResponse.data.workspace.issuable; + expect(createWorkItemMutationHandler).toHaveBeenCalledWith({ + input: { confidential, - iteration, - milestone, - } = issueDetailsResponse.data.workspace.issuable; - expect(createWorkItemMutationHandler).toHaveBeenCalledWith({ - input: { - confidential, - description: '\nparagraph text\n', - hierarchyWidget: { - parentId: 'gid://gitlab/WorkItem/1', - }, - iterationWidget: { - iterationId: IS_EE ? iteration.id : null, - }, - milestoneWidget: { - milestoneId: milestone.id, - }, - projectPath: 'gitlab-org/gitlab-test', - title: 'item 2', - workItemTypeId: 'gid://gitlab/WorkItems::Type/3', + description: '\nparagraph text\n', + hierarchyWidget: { + parentId: 'gid://gitlab/WorkItem/1', + }, + iterationWidget: { + iterationId: IS_EE ? iteration.id : null, + }, + milestoneWidget: { + milestoneId: milestone.id, }, - }); + projectPath: 'gitlab-org/gitlab-test', + title: 'item 2', + workItemTypeId: 'gid://gitlab/WorkItems::Type/3', + }, }); + }); - it('shows a toast to confirm the creation of the task', () => { - expect($toast.show).toHaveBeenCalledWith('Converted to task', expect.any(Object)); - }); + it('shows a toast to confirm the creation of the task', () => { + expect($toast.show).toHaveBeenCalledWith('Converted to task', expect.any(Object)); }); + }); - describe('when unsuccessful', () => { - beforeEach(async () => { - createComponent({ - props: { descriptionText: 'description' }, - provide: { glFeatures: { workItemsMvc: true } }, - createWorkItemMutationHandler: jest - .fn() - .mockResolvedValue(createWorkItemMutationErrorResponse), - }); - await waitForPromises(); - - eventHub.$emit('convert-task-list-item', '1:1-1:11'); - await waitForPromises(); + describe('when unsuccessful', () => { + beforeEach(async () => { + createComponent({ + props: { descriptionText: 'description' }, + createWorkItemMutationHandler: jest + .fn() + .mockResolvedValue(createWorkItemMutationErrorResponse), }); + await waitForPromises(); - it('shows an alert with an error message', () => { - expect(createAlert).toHaveBeenCalledWith({ - message: 'Something went wrong when creating task. Please try again.', - error: new Error('an error'), - captureError: true, - }); + eventHub.$emit('convert-task-list-item', '1:1-1:11'); + await waitForPromises(); + }); + + it('shows an alert with an error message', () => { + expect(createAlert).toHaveBeenCalledWith({ + message: 'Something went wrong when creating task. Please try again.', + error: new Error('an error'), + captureError: true, }); }); }); + }); - describe('deleting the task list item', () => { - it('emits an event to update the description with the deleted task list item', () => { - const descriptionText = `Tasks + describe('deleting the task list item', () => { + it('emits an event to update the description with the deleted task list item', () => { + const descriptionText = `Tasks 1. [ ] item 1 1. [ ] item 2 1. [ ] item 3 1. [ ] item 4;`; - const newDescriptionText = `Tasks + const newDescriptionText = `Tasks 1. [ ] item 1 1. [ ] item 3 1. [ ] item 4;`; - createComponent({ - props: { descriptionText }, - provide: { glFeatures: { workItemsMvc: true } }, - }); + createComponent({ + props: { descriptionText }, + }); - eventHub.$emit('delete-task-list-item', '4:4-5:19'); + eventHub.$emit('delete-task-list-item', '4:4-5:19'); - expect(wrapper.emitted('saveDescription')).toEqual([[newDescriptionText]]); - }); + expect(wrapper.emitted('saveDescription')).toEqual([[newDescriptionText]]); }); }); + }); - describe('work items detail', () => { - describe('when opening and closing', () => { - beforeEach(() => { - createComponent({ - props: { - descriptionHtml: descriptionHtmlWithTask, - }, - provide: { - glFeatures: { workItemsMvc: true }, - }, - }); - return nextTick(); + describe('work items detail', () => { + describe('when opening and closing', () => { + beforeEach(() => { + createComponent({ + props: { + descriptionHtml: descriptionHtmlWithTask, + }, }); + return nextTick(); + }); - it('opens when task button is clicked', async () => { - await findTaskLink().trigger('click'); + it('opens when task button is clicked', async () => { + await findTaskLink().trigger('click'); - expect(showDetailsModal).toHaveBeenCalled(); - expect(updateHistory).toHaveBeenCalledWith({ - url: `${TEST_HOST}/?work_item_id=2`, - replace: true, - }); + expect(showDetailsModal).toHaveBeenCalled(); + expect(updateHistory).toHaveBeenCalledWith({ + url: `${TEST_HOST}/?work_item_id=2`, + replace: true, }); + }); - it('closes from an open state', async () => { - await findTaskLink().trigger('click'); + it('closes from an open state', async () => { + await findTaskLink().trigger('click'); - findWorkItemDetailModal().vm.$emit('close'); - await nextTick(); + findWorkItemDetailModal().vm.$emit('close'); + await nextTick(); - expect(updateHistory).toHaveBeenLastCalledWith({ - url: `${TEST_HOST}/`, - replace: true, - }); + expect(updateHistory).toHaveBeenLastCalledWith({ + url: `${TEST_HOST}/`, + replace: true, }); + }); - it('tracks when opened', async () => { - const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - - await findTaskLink().trigger('click'); + it('tracks when opened', async () => { + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); - expect(trackingSpy).toHaveBeenCalledWith( - TRACKING_CATEGORY_SHOW, - 'viewed_work_item_from_modal', - { - category: TRACKING_CATEGORY_SHOW, - label: 'work_item_view', - property: 'type_task', - }, - ); - }); - }); + await findTaskLink().trigger('click'); - describe('when url query `work_item_id` exists', () => { - it.each` - behavior | workItemId | modalOpened - ${'opens'} | ${'2'} | ${1} - ${'does not open'} | ${'123'} | ${0} - ${'does not open'} | ${'123e'} | ${0} - ${'does not open'} | ${'12e3'} | ${0} - ${'does not open'} | ${'1e23'} | ${0} - ${'does not open'} | ${'x'} | ${0} - ${'does not open'} | ${'undefined'} | ${0} - `( - '$behavior when url contains `work_item_id=$workItemId`', - async ({ workItemId, modalOpened }) => { - setWindowLocation(`?work_item_id=${workItemId}`); - - createComponent({ - props: { descriptionHtml: descriptionHtmlWithTask }, - provide: { glFeatures: { workItemsMvc: true } }, - }); - - expect(showDetailsModal).toHaveBeenCalledTimes(modalOpened); + expect(trackingSpy).toHaveBeenCalledWith( + TRACKING_CATEGORY_SHOW, + 'viewed_work_item_from_modal', + { + category: TRACKING_CATEGORY_SHOW, + label: 'work_item_view', + property: 'type_task', }, ); }); }); - describe('when hovering task links', () => { - beforeEach(() => { - createComponent({ - props: { - descriptionHtml: descriptionHtmlWithTask, - }, - provide: { - glFeatures: { workItemsMvc: true }, - }, - }); - return nextTick(); - }); + describe('when url query `work_item_id` exists', () => { + it.each` + behavior | workItemId | modalOpened + ${'opens'} | ${'2'} | ${1} + ${'does not open'} | ${'123'} | ${0} + ${'does not open'} | ${'123e'} | ${0} + ${'does not open'} | ${'12e3'} | ${0} + ${'does not open'} | ${'1e23'} | ${0} + ${'does not open'} | ${'x'} | ${0} + ${'does not open'} | ${'undefined'} | ${0} + `( + '$behavior when url contains `work_item_id=$workItemId`', + async ({ workItemId, modalOpened }) => { + setWindowLocation(`?work_item_id=${workItemId}`); - it('prefetches work item detail after work item link is hovered for 150ms', async () => { - await findTaskLink().trigger('mouseover'); - jest.advanceTimersByTime(150); - await waitForPromises(); + createComponent({ + props: { descriptionHtml: descriptionHtmlWithTask }, + }); - expect(queryHandler).toHaveBeenCalledWith({ - id: 'gid://gitlab/WorkItem/2', - }); + expect(showDetailsModal).toHaveBeenCalledTimes(modalOpened); + }, + ); + }); + }); + + describe('when hovering task links', () => { + beforeEach(() => { + createComponent({ + props: { + descriptionHtml: descriptionHtmlWithTask, + }, }); + return nextTick(); + }); - it('does not work item detail after work item link is hovered for less than 150ms', async () => { - await findTaskLink().trigger('mouseover'); - await findTaskLink().trigger('mouseout'); - jest.advanceTimersByTime(150); - await waitForPromises(); + it('prefetches work item detail after work item link is hovered for 150ms', async () => { + await findTaskLink().trigger('mouseover'); + jest.advanceTimersByTime(150); + await waitForPromises(); - expect(queryHandler).not.toHaveBeenCalled(); + expect(queryHandler).toHaveBeenCalledWith({ + id: 'gid://gitlab/WorkItem/2', }); }); + + it('does not work item detail after work item link is hovered for less than 150ms', async () => { + await findTaskLink().trigger('mouseover'); + await findTaskLink().trigger('mouseout'); + jest.advanceTimersByTime(150); + await waitForPromises(); + + expect(queryHandler).not.toHaveBeenCalled(); + }); }); }); diff --git a/spec/lib/gitlab/seeders/ci/runner/runner_fleet_pipeline_seeder_spec.rb b/spec/lib/gitlab/seeders/ci/runner/runner_fleet_pipeline_seeder_spec.rb index 2862bcc9719..a15dbccc80c 100644 --- a/spec/lib/gitlab/seeders/ci/runner/runner_fleet_pipeline_seeder_spec.rb +++ b/spec/lib/gitlab/seeders/ci/runner/runner_fleet_pipeline_seeder_spec.rb @@ -28,7 +28,8 @@ RSpec.describe ::Gitlab::Seeders::Ci::Runner::RunnerFleetPipelineSeeder, feature context 'with job_count specified' do let(:job_count) { 20 } - it 'creates expected jobs', :aggregate_failures do + it 'creates expected jobs', :aggregate_failures, + quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/394721' do expect { seeder.seed }.to change { Ci::Build.count }.by(job_count) .and change { Ci::Pipeline.count }.by(4) diff --git a/spec/migrations/20221102231130_finalize_backfill_user_details_fields_spec.rb b/spec/migrations/20221102231130_finalize_backfill_user_details_fields_spec.rb new file mode 100644 index 00000000000..37bff128edd --- /dev/null +++ b/spec/migrations/20221102231130_finalize_backfill_user_details_fields_spec.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require_migration! + +RSpec.describe FinalizeBackfillUserDetailsFields, :migration, feature_category: :user_management do + let(:batched_migrations) { table(:batched_background_migrations) } + let(:batch_failed_status) { 2 } + let(:batch_finalized_status) { 3 } + + let!(:migration) { described_class::BACKFILL_MIGRATION } + + describe '#up' do + shared_examples 'finalizes the migration' do + it 'finalizes the migration' do + expect do + migrate! + + migration_record.reload + failed_job.reload + end.to change { migration_record.status }.from(migration_record.status).to(3).and( + change { failed_job.status }.from(batch_failed_status).to(batch_finalized_status) + ) + end + end + + context 'when migration is missing' do + it 'warns migration not found' do + expect(Gitlab::AppLogger) + .to receive(:warn).with(/Could not find batched background migration for the given configuration:/) + + migrate! + end + end + + context 'with migration present' do + let!(:migration_record) do + batched_migrations.create!( + job_class_name: migration, + table_name: :users, + column_name: :id, + job_arguments: [], + interval: 2.minutes, + min_value: 1, + max_value: 2, + batch_size: 1000, + sub_batch_size: 500, + max_batch_size: 5000, + gitlab_schema: :gitlab_main, + status: 3 # finished + ) + end + + context 'when migration finished successfully' do + it 'does not raise exception' do + expect { migrate! }.not_to raise_error + end + end + + context 'when users.linkedin column has already been dropped' do + before do + table(:users).create!(id: 1, email: 'author@example.com', username: 'author', projects_limit: 10) + ActiveRecord::Base.connection.execute("ALTER TABLE users DROP COLUMN linkedin") + migration_record.update_column(:status, 1) + end + + after do + ActiveRecord::Base.connection.execute("ALTER TABLE users ADD COLUMN linkedin text DEFAULT '' NOT NULL") + end + + it 'does not raise exception' do + expect { migrate! }.not_to raise_error + end + end + + context 'with different migration statuses', :redis do + using RSpec::Parameterized::TableSyntax + + where(:status, :description) do + 0 | 'paused' + 1 | 'active' + 4 | 'failed' + 5 | 'finalizing' + end + + with_them do + let!(:failed_job) do + table(:batched_background_migration_jobs).create!( + batched_background_migration_id: migration_record.id, + status: batch_failed_status, + min_value: 1, + max_value: 10, + attempts: 2, + batch_size: 100, + sub_batch_size: 10 + ) + end + + before do + migration_record.update!(status: status) + end + + it_behaves_like 'finalizes the migration' + end + end + end + end +end diff --git a/spec/models/uploads/fog_spec.rb b/spec/models/uploads/fog_spec.rb index 1ffe7c6c43b..a1b0bcf95e0 100644 --- a/spec/models/uploads/fog_spec.rb +++ b/spec/models/uploads/fog_spec.rb @@ -3,10 +3,21 @@ require 'spec_helper' RSpec.describe Uploads::Fog do + let(:credentials) do + { + provider: "AWS", + aws_access_key_id: "AWS_ACCESS_KEY_ID", + aws_secret_access_key: "AWS_SECRET_ACCESS_KEY", + region: "eu-central-1" + } + end + + let(:bucket_prefix) { nil } let(:data_store) { described_class.new } + let(:config) { { connection: credentials, bucket_prefix: bucket_prefix, remote_directory: 'uploads' } } before do - stub_uploads_object_storage(FileUploader) + stub_uploads_object_storage(FileUploader, config: config) end describe '#available?' do @@ -18,7 +29,7 @@ RSpec.describe Uploads::Fog do context 'when object storage is disabled' do before do - stub_uploads_object_storage(FileUploader, enabled: false) + stub_uploads_object_storage(FileUploader, config: config, enabled: false) end it { is_expected.to be_falsy } @@ -28,6 +39,60 @@ RSpec.describe Uploads::Fog do context 'model with uploads' do let(:project) { create(:project) } let(:relation) { project.uploads } + let(:connection) { ::Fog::Storage.new(credentials) } + let(:paths) { relation.pluck(:path) } + + # Only fog-aws simulates mocking of deleting an object properly. + # We'll just test that the various providers implement the require methods. + describe 'Fog provider acceptance tests' do + let!(:uploads) { create_list(:upload, 2, :with_file, :issuable_upload, model: project) } + + shared_examples 'Fog provider' do + describe '#get_object' do + it 'returns a Hash with a body' do + expect(connection.get_object('uploads', paths.first)[:body]).not_to be_nil + end + end + + describe '#delete_object' do + it 'returns true' do + expect(connection.delete_object('uploads', paths.first)).to be_truthy + end + end + end + + before do + uploads.each { |upload| upload.retrieve_uploader.migrate!(2) } + end + + context 'with AWS provider' do + it_behaves_like 'Fog provider' + end + + context 'with Google provider' do + let(:credentials) do + { + provider: "Google", + google_storage_access_key_id: 'ACCESS_KEY_ID', + google_storage_secret_access_key: 'SECRET_ACCESS_KEY' + } + end + + it_behaves_like 'Fog provider' + end + + context 'with AzureRM provider' do + let(:credentials) do + { + provider: 'AzureRM', + azure_storage_account_name: 'test-access-id', + azure_storage_access_key: 'secret' + } + end + + it_behaves_like 'Fog provider' + end + end describe '#keys' do let!(:uploads) { create_list(:upload, 2, :object_storage, uploader: FileUploader, model: project) } @@ -40,7 +105,7 @@ RSpec.describe Uploads::Fog do end describe '#delete_keys' do - let(:connection) { ::Fog::Storage.new(FileUploader.object_store_credentials) } + let(:connection) { ::Fog::Storage.new(credentials) } let(:keys) { data_store.keys(relation) } let(:paths) { relation.pluck(:path) } let!(:uploads) { create_list(:upload, 2, :with_file, :issuable_upload, model: project) } @@ -63,6 +128,22 @@ RSpec.describe Uploads::Fog do end end + context 'with bucket prefix' do + let(:bucket_prefix) { 'test-prefix' } + + it 'deletes multiple data' do + paths.each do |path| + expect(connection.get_object('uploads', File.join(bucket_prefix, path))[:body]).not_to be_nil + end + + subject + + paths.each do |path| + expect { connection.get_object('uploads', File.join(bucket_prefix, path))[:body] }.to raise_error(Excon::Error::NotFound) + end + end + end + context 'when one of keys is missing' do let(:keys) { ['unknown'] + super() } diff --git a/spec/support/helpers/stub_object_storage.rb b/spec/support/helpers/stub_object_storage.rb index c163ce1d880..6b633856228 100644 --- a/spec/support/helpers/stub_object_storage.rb +++ b/spec/support/helpers/stub_object_storage.rb @@ -15,7 +15,7 @@ module StubObjectStorage direct_upload: false, cdn: {} ) - + old_config = Settingslogic.new(config.deep_stringify_keys) new_config = config.to_h.deep_symbolize_keys.merge({ enabled: enabled, proxy_download: proxy_download, @@ -37,7 +37,7 @@ module StubObjectStorage return unless enabled stub_object_storage(connection_params: uploader.object_store_credentials, - remote_directory: config.remote_directory) + remote_directory: old_config.remote_directory) end def stub_object_storage(connection_params:, remote_directory:) -- cgit v1.2.1