From 8a03b5424b73679d852fbf43781d9422c83869f7 Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Tue, 8 Jun 2021 18:10:23 +0000 Subject: Add latest changes from gitlab-org/gitlab@master --- spec/docs_screenshots/wiki_docs.rb | 47 ++++ .../content_editor/components/top_toolbar_spec.js | 1 + .../extensions/code_block_highlight_spec.js | 37 +++ .../shared/wikis/components/wiki_form_spec.js | 56 +++- .../graph/graph_component_wrapper_spec.js | 2 +- .../components/user_callout_dismisser_mock_data.js | 30 ++ .../components/user_callout_dismisser_spec.js | 306 +++++++++++++++++++++ spec/lib/gitlab/import_export/shared_spec.rb | 22 ++ spec/models/import_export_upload_spec.rb | 19 ++ spec/models/pages/lookup_path_spec.rb | 9 +- .../import_export_clean_up_service_spec.rb | 77 ++++-- spec/workers/every_sidekiq_worker_spec.rb | 1 - 12 files changed, 567 insertions(+), 40 deletions(-) create mode 100644 spec/docs_screenshots/wiki_docs.rb create mode 100644 spec/frontend/content_editor/extensions/code_block_highlight_spec.js create mode 100644 spec/frontend/vue_shared/components/user_callout_dismisser_mock_data.js create mode 100644 spec/frontend/vue_shared/components/user_callout_dismisser_spec.js (limited to 'spec') diff --git a/spec/docs_screenshots/wiki_docs.rb b/spec/docs_screenshots/wiki_docs.rb new file mode 100644 index 00000000000..ce30b07182c --- /dev/null +++ b/spec/docs_screenshots/wiki_docs.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Wiki', :js do + include DocsScreenshotHelpers + include WikiHelpers + + let(:user) { create(:user) } + let(:project) { create(:project, namespace: user.namespace, creator: user) } + let(:wiki) { create(:project_wiki, user: user, project: project) } + + before do + page.driver.browser.manage.window.resize_to(1366, 1024) + + sign_in(user) + visit wiki_path(wiki) + + click_link "Create your first page" + end + + context 'switching to content editor' do + it 'user/project/wiki/img/use_new_editor_button' do + screenshot_area = find('.js-quick-submit') + scroll_to screenshot_area + expect(screenshot_area).to have_content 'Use new editor' + set_crop_data(screenshot_area, 10) + end + end + + context 'content editor' do + it 'user/project/wiki/img/content_editor' do + content_editor_testid = '[data-testid="content-editor"]' + + click_button 'Use new editor' + + expect(page).to have_css(content_editor_testid) + + screenshot_area = find(content_editor_testid) + scroll_to screenshot_area + + find("#{content_editor_testid} [contenteditable]").send_keys '## Using the Content Editor' + + set_crop_data(screenshot_area, 50) + end + end +end diff --git a/spec/frontend/content_editor/components/top_toolbar_spec.js b/spec/frontend/content_editor/components/top_toolbar_spec.js index a4d5c1ac606..0892bc1a4da 100644 --- a/spec/frontend/content_editor/components/top_toolbar_spec.js +++ b/spec/frontend/content_editor/components/top_toolbar_spec.js @@ -46,6 +46,7 @@ describe('content_editor/components/top_toolbar', () => { ${'blockquote'} | ${{ contentType: 'blockquote', iconName: 'quote', label: 'Insert a quote', editorCommand: 'toggleBlockquote' }} ${'bullet-list'} | ${{ contentType: 'bulletList', iconName: 'list-bulleted', label: 'Add a bullet list', editorCommand: 'toggleBulletList' }} ${'ordered-list'} | ${{ contentType: 'orderedList', iconName: 'list-numbered', label: 'Add a numbered list', editorCommand: 'toggleOrderedList' }} + ${'code-block'} | ${{ contentType: 'codeBlock', iconName: 'doc-code', label: 'Insert a code block', editorCommand: 'toggleCodeBlock' }} ${'text-styles'} | ${{}} `('given a $testId toolbar control', ({ testId, controlProps }) => { beforeEach(() => { diff --git a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js new file mode 100644 index 00000000000..cc695ffe241 --- /dev/null +++ b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js @@ -0,0 +1,37 @@ +import { tiptapExtension as CodeBlockHighlight } from '~/content_editor/extensions/code_block_highlight'; +import { loadMarkdownApiResult } from '../markdown_processing_examples'; +import { createTestEditor } from '../test_utils'; + +describe('content_editor/extensions/code_block_highlight', () => { + let codeBlockHtmlFixture; + let parsedCodeBlockHtmlFixture; + let tiptapEditor; + + const parseHTML = (html) => new DOMParser().parseFromString(html, 'text/html'); + const preElement = () => parsedCodeBlockHtmlFixture.querySelector('pre'); + + beforeEach(() => { + const { html } = loadMarkdownApiResult('code_block'); + + tiptapEditor = createTestEditor({ extensions: [CodeBlockHighlight] }); + codeBlockHtmlFixture = html; + parsedCodeBlockHtmlFixture = parseHTML(codeBlockHtmlFixture); + + tiptapEditor.commands.setContent(codeBlockHtmlFixture); + }); + + it('extracts language and params attributes from Markdown API output', () => { + const language = preElement().getAttribute('lang'); + + expect(tiptapEditor.getJSON().content[0].attrs).toMatchObject({ + language, + params: language, + }); + }); + + it('adds code, highlight, and js-syntax-highlight to code block element', () => { + const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre'); + + expect(editorHtmlOutput.classList.toString()).toContain('code highlight js-syntax-highlight'); + }); +}); diff --git a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js index 1cac8ef8ee2..1d210edb6ac 100644 --- a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js +++ b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js @@ -2,15 +2,23 @@ import { GlLoadingIcon, GlModal } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; +import { mockTracking } from 'helpers/tracking_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import ContentEditor from '~/content_editor/components/content_editor.vue'; import WikiForm from '~/pages/shared/wikis/components/wiki_form.vue'; +import { + WIKI_CONTENT_EDITOR_TRACKING_LABEL, + CONTENT_EDITOR_LOADED_ACTION, + SAVED_USING_CONTENT_EDITOR_ACTION, +} from '~/pages/shared/wikis/constants'; + import MarkdownField from '~/vue_shared/components/markdown/field.vue'; describe('WikiForm', () => { let wrapper; let mock; + let trackingSpy; const findForm = () => wrapper.find('form'); const findTitle = () => wrapper.find('#wiki_title'); @@ -60,10 +68,7 @@ describe('WikiForm', () => { path: '/project/path/-/wikis/home', }; - function createWrapper( - persisted = false, - { pageInfo, glFeatures } = { glFeatures: { wikiContentEditor: false } }, - ) { + function createWrapper(persisted = false, { pageInfo } = {}) { wrapper = extendedWrapper( mount( WikiForm, @@ -79,7 +84,6 @@ describe('WikiForm', () => { ...(persisted ? pageInfoPersisted : pageInfoNew), ...pageInfo, }, - glFeatures, }, }, { attachToDocument: true }, @@ -88,6 +92,7 @@ describe('WikiForm', () => { } beforeEach(() => { + trackingSpy = mockTracking(undefined, null, jest.spyOn); mock = new MockAdapter(axios); }); @@ -193,13 +198,21 @@ describe('WikiForm', () => { expect(e.preventDefault).toHaveBeenCalledTimes(1); }); - it('when form submitted, unsets before unload warning', async () => { - triggerFormSubmit(); + describe('form submit', () => { + beforeEach(async () => { + triggerFormSubmit(); - await wrapper.vm.$nextTick(); + await wrapper.vm.$nextTick(); + }); - const e = dispatchBeforeUnload(); - expect(e.preventDefault).not.toHaveBeenCalled(); + it('when form submitted, unsets before unload warning', async () => { + const e = dispatchBeforeUnload(); + expect(e.preventDefault).not.toHaveBeenCalled(); + }); + + it('does not trigger tracking event', async () => { + expect(trackingSpy).not.toHaveBeenCalled(); + }); }); }); @@ -251,9 +264,9 @@ describe('WikiForm', () => { ); }); - describe('when feature flag wikiContentEditor is enabled', () => { + describe('wiki content editor', () => { beforeEach(() => { - createWrapper(true, { glFeatures: { wikiContentEditor: true } }); + createWrapper(true); }); it.each` @@ -368,6 +381,15 @@ describe('WikiForm', () => { expect(wrapper.findComponent(ContentEditor).exists()).toBe(true); }); + it('sends tracking event when editor loads', async () => { + // wait for content editor to load + await waitForPromises(); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, CONTENT_EDITOR_LOADED_ACTION, { + label: WIKI_CONTENT_EDITOR_TRACKING_LABEL, + }); + }); + it('disables the format dropdown', () => { expect(findFormat().element.getAttribute('disabled')).toBeDefined(); }); @@ -400,6 +422,16 @@ describe('WikiForm', () => { }); }); + it('triggers tracking event on form submit', async () => { + triggerFormSubmit(); + + await wrapper.vm.$nextTick(); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, SAVED_USING_CONTENT_EDITOR_ACTION, { + label: WIKI_CONTENT_EDITOR_TRACKING_LABEL, + }); + }); + it('updates content from content editor on form submit', async () => { // old value expect(findContent().element.value).toBe('My page content'); diff --git a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js index 4914a9a1ced..bb7e27b5ec2 100644 --- a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js @@ -5,6 +5,7 @@ import VueApollo from 'vue-apollo'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql'; +import getUserCallouts from '~/graphql_shared/queries/get_user_callouts.query.graphql'; import { IID_FAILURE, LAYER_VIEW, @@ -17,7 +18,6 @@ import GraphViewSelector from '~/pipelines/components/graph/graph_view_selector. import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue'; import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue'; import * as parsingUtils from '~/pipelines/components/parsing_utils'; -import getUserCallouts from '~/pipelines/graphql/queries/get_user_callouts.query.graphql'; import { mapCallouts, mockCalloutsResponse, mockPipelineResponse } from './mock_data'; const defaultProvide = { diff --git a/spec/frontend/vue_shared/components/user_callout_dismisser_mock_data.js b/spec/frontend/vue_shared/components/user_callout_dismisser_mock_data.js new file mode 100644 index 00000000000..7ca8c619ffc --- /dev/null +++ b/spec/frontend/vue_shared/components/user_callout_dismisser_mock_data.js @@ -0,0 +1,30 @@ +export const userCalloutsResponse = (callouts = []) => ({ + data: { + currentUser: { + id: 'gid://gitlab/User/46', + __typename: 'UserCore', + callouts: { + __typename: 'UserCalloutConnection', + nodes: callouts.map((callout) => ({ + __typename: 'UserCallout', + featureName: callout.toUpperCase(), + dismissedAt: '2021-02-12T11:10:01Z', + })), + }, + }, + }, +}); + +export const anonUserCalloutsResponse = () => ({ data: { currentUser: null } }); + +export const userCalloutMutationResponse = (variables, errors = []) => ({ + data: { + userCalloutCreate: { + errors, + userCallout: { + featureName: variables.input.featureName.toUpperCase(), + dismissedAt: '2021-02-12T11:10:01Z', + }, + }, + }, +}); diff --git a/spec/frontend/vue_shared/components/user_callout_dismisser_spec.js b/spec/frontend/vue_shared/components/user_callout_dismisser_spec.js new file mode 100644 index 00000000000..70dec42ab32 --- /dev/null +++ b/spec/frontend/vue_shared/components/user_callout_dismisser_spec.js @@ -0,0 +1,306 @@ +import { mount } from '@vue/test-utils'; +import { merge } from 'lodash'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import dismissUserCalloutMutation from '~/graphql_shared/mutations/dismiss_user_callout.mutation.graphql'; +import getUserCalloutsQuery from '~/graphql_shared/queries/get_user_callouts.query.graphql'; +import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; +import { + anonUserCalloutsResponse, + userCalloutMutationResponse, + userCalloutsResponse, +} from './user_callout_dismisser_mock_data'; + +Vue.use(VueApollo); + +const initialSlotProps = (changes = {}) => ({ + dismiss: expect.any(Function), + isAnonUser: false, + isDismissed: false, + isLoadingQuery: true, + isLoadingMutation: false, + mutationError: null, + queryError: null, + shouldShowCallout: false, + ...changes, +}); + +describe('UserCalloutDismisser', () => { + let wrapper; + + const MOCK_FEATURE_NAME = 'mock_feature_name'; + + // Query handlers + const successHandlerFactory = (dismissedCallouts = []) => async () => + userCalloutsResponse(dismissedCallouts); + const anonUserHandler = async () => anonUserCalloutsResponse(); + const errorHandler = () => Promise.reject(new Error('query error')); + const pendingHandler = () => new Promise(() => {}); + + // Mutation handlers + const mutationSuccessHandlerSpy = jest.fn(async (variables) => + userCalloutMutationResponse(variables), + ); + const mutationErrorHandlerSpy = jest.fn(async (variables) => + userCalloutMutationResponse(variables, ['mutation error']), + ); + + const defaultScopedSlotSpy = jest.fn(); + + const callDismissSlotProp = () => defaultScopedSlotSpy.mock.calls[0][0].dismiss(); + + const createComponent = ({ queryHandler, mutationHandler, ...options }) => { + wrapper = mount( + UserCalloutDismisser, + merge( + { + propsData: { + featureName: MOCK_FEATURE_NAME, + }, + scopedSlots: { + default: defaultScopedSlotSpy, + }, + apolloProvider: createMockApollo([ + [getUserCalloutsQuery, queryHandler], + [dismissUserCalloutMutation, mutationHandler], + ]), + }, + options, + ), + ); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when loading', () => { + beforeEach(() => { + createComponent({ + queryHandler: pendingHandler, + }); + }); + + it('passes expected slot props to child', () => { + expect(defaultScopedSlotSpy).lastCalledWith(initialSlotProps()); + }); + }); + + describe('when loaded and dismissed', () => { + beforeEach(() => { + createComponent({ + queryHandler: successHandlerFactory([MOCK_FEATURE_NAME]), + }); + + return waitForPromises(); + }); + + it('passes expected slot props to child', () => { + expect(defaultScopedSlotSpy).lastCalledWith( + initialSlotProps({ + isDismissed: true, + isLoadingQuery: false, + }), + ); + }); + }); + + describe('when loaded and not dismissed', () => { + beforeEach(() => { + createComponent({ + queryHandler: successHandlerFactory(), + }); + + return waitForPromises(); + }); + + it('passes expected slot props to child', () => { + expect(defaultScopedSlotSpy).lastCalledWith( + initialSlotProps({ + isLoadingQuery: false, + shouldShowCallout: true, + }), + ); + }); + }); + + describe('when loaded with errors', () => { + beforeEach(() => { + createComponent({ + queryHandler: errorHandler, + }); + + return waitForPromises(); + }); + + it('passes expected slot props to child', () => { + expect(defaultScopedSlotSpy).lastCalledWith( + initialSlotProps({ + isLoadingQuery: false, + queryError: expect.any(Error), + }), + ); + }); + }); + + describe('when loaded and the user is anonymous', () => { + beforeEach(() => { + createComponent({ + queryHandler: anonUserHandler, + }); + + return waitForPromises(); + }); + + it('passes expected slot props to child', () => { + expect(defaultScopedSlotSpy).lastCalledWith( + initialSlotProps({ + isAnonUser: true, + isLoadingQuery: false, + }), + ); + }); + }); + + describe('when skipQuery is true', () => { + let queryHandler; + beforeEach(() => { + queryHandler = jest.fn(); + + createComponent({ + queryHandler, + propsData: { + skipQuery: true, + }, + }); + }); + + it('does not run the query', async () => { + expect(queryHandler).not.toHaveBeenCalled(); + + await waitForPromises(); + + expect(queryHandler).not.toHaveBeenCalled(); + }); + + it('passes expected slot props to child', () => { + expect(defaultScopedSlotSpy).lastCalledWith( + initialSlotProps({ + isLoadingQuery: false, + shouldShowCallout: true, + }), + ); + }); + }); + + describe('dismissing', () => { + describe('given it succeeds', () => { + beforeEach(() => { + createComponent({ + queryHandler: successHandlerFactory(), + mutationHandler: mutationSuccessHandlerSpy, + }); + + return waitForPromises(); + }); + + it('dismissing calls mutation', () => { + expect(mutationSuccessHandlerSpy).not.toHaveBeenCalled(); + + callDismissSlotProp(); + + expect(mutationSuccessHandlerSpy).toHaveBeenCalledWith({ + input: { featureName: MOCK_FEATURE_NAME }, + }); + }); + + it('passes expected slot props to child', async () => { + expect(defaultScopedSlotSpy).lastCalledWith( + initialSlotProps({ + isLoadingQuery: false, + shouldShowCallout: true, + }), + ); + + callDismissSlotProp(); + + // Wait for Vue re-render due to prop change + await nextTick(); + + expect(defaultScopedSlotSpy).lastCalledWith( + initialSlotProps({ + isDismissed: true, + isLoadingMutation: true, + isLoadingQuery: false, + }), + ); + + // Wait for mutation to resolve + await waitForPromises(); + + expect(defaultScopedSlotSpy).lastCalledWith( + initialSlotProps({ + isDismissed: true, + isLoadingQuery: false, + }), + ); + }); + }); + + describe('given it fails', () => { + beforeEach(() => { + createComponent({ + queryHandler: successHandlerFactory(), + mutationHandler: mutationErrorHandlerSpy, + }); + + return waitForPromises(); + }); + + it('calls mutation', () => { + expect(mutationErrorHandlerSpy).not.toHaveBeenCalled(); + + callDismissSlotProp(); + + expect(mutationErrorHandlerSpy).toHaveBeenCalledWith({ + input: { featureName: MOCK_FEATURE_NAME }, + }); + }); + + it('passes expected slot props to child', async () => { + expect(defaultScopedSlotSpy).lastCalledWith( + initialSlotProps({ + isLoadingQuery: false, + shouldShowCallout: true, + }), + ); + + callDismissSlotProp(); + + // Wait for Vue re-render due to prop change + await nextTick(); + + expect(defaultScopedSlotSpy).lastCalledWith( + initialSlotProps({ + isDismissed: true, + isLoadingMutation: true, + isLoadingQuery: false, + }), + ); + + // Wait for mutation to resolve + await waitForPromises(); + + expect(defaultScopedSlotSpy).lastCalledWith( + initialSlotProps({ + isDismissed: true, + isLoadingQuery: false, + mutationError: ['mutation error'], + }), + ); + }); + }); + }); +}); diff --git a/spec/lib/gitlab/import_export/shared_spec.rb b/spec/lib/gitlab/import_export/shared_spec.rb index 22f2d4c5077..feeb88397eb 100644 --- a/spec/lib/gitlab/import_export/shared_spec.rb +++ b/spec/lib/gitlab/import_export/shared_spec.rb @@ -37,6 +37,28 @@ RSpec.describe Gitlab::ImportExport::Shared do end end + context 'with a group on disk' do + describe '#base_path' do + it 'uses hashed storage path' do + group = create(:group) + subject = described_class.new(group) + base_path = %(/tmp/gitlab_exports/@groups/) + + expect(subject.base_path).to match(/#{base_path}\h{2}\/\h{2}\/\h{64}/) + end + end + end + + context 'when exportable type is unsupported' do + describe '#base_path' do + it 'raises' do + subject = described_class.new('test') + + expect { subject.base_path }.to raise_error(Gitlab::ImportExport::Error, 'Unsupported Exportable Type String') + end + end + end + describe '#error' do let(:error) { StandardError.new('Error importing into /my/folder Permission denied @ unlink_internal - /var/opt/gitlab/gitlab-rails/shared/a/b/c/uploads/file') } diff --git a/spec/models/import_export_upload_spec.rb b/spec/models/import_export_upload_spec.rb index 46a611852ab..f82c8da379f 100644 --- a/spec/models/import_export_upload_spec.rb +++ b/spec/models/import_export_upload_spec.rb @@ -24,4 +24,23 @@ RSpec.describe ImportExportUpload do context 'export' do it_behaves_like 'stores the Import/Export file', :export_file end + + describe 'scopes' do + let_it_be(:upload1) { create(:import_export_upload, export_file: fixture_file_upload('spec/fixtures/project_export.tar.gz')) } + let_it_be(:upload2) { create(:import_export_upload) } + let_it_be(:upload3) { create(:import_export_upload, export_file: fixture_file_upload('spec/fixtures/project_export.tar.gz'), updated_at: 25.hours.ago) } + let_it_be(:upload4) { create(:import_export_upload, updated_at: 2.days.ago) } + + describe '.with_export_file' do + it 'returns uploads with export file' do + expect(described_class.with_export_file).to contain_exactly(upload1, upload3) + end + end + + describe '.updated_before' do + it 'returns uploads for a specified date' do + expect(described_class.updated_before(24.hours.ago)).to contain_exactly(upload3, upload4) + end + end + end end diff --git a/spec/models/pages/lookup_path_spec.rb b/spec/models/pages/lookup_path_spec.rb index 735f2225c21..2d7ee8ba3be 100644 --- a/spec/models/pages/lookup_path_spec.rb +++ b/spec/models/pages/lookup_path_spec.rb @@ -47,14 +47,7 @@ RSpec.describe Pages::LookupPath do describe '#source' do let(:source) { lookup_path.source } - it 'uses disk storage', :aggregate_failures do - expect(source[:type]).to eq('file') - expect(source[:path]).to eq(project.full_path + "/public/") - end - - it 'return nil when local storage is disabled and there is no deployment' do - allow(Settings.pages.local_store).to receive(:enabled).and_return(false) - + it 'returns nil' do expect(source).to eq(nil) end diff --git a/spec/services/import_export_clean_up_service_spec.rb b/spec/services/import_export_clean_up_service_spec.rb index 4101b13adf9..2bcdfa6dd8f 100644 --- a/spec/services/import_export_clean_up_service_spec.rb +++ b/spec/services/import_export_clean_up_service_spec.rb @@ -8,7 +8,13 @@ RSpec.describe ImportExportCleanUpService do let(:tmp_import_export_folder) { 'tmp/gitlab_exports' } - context 'when the import/export directory does not exist' do + before do + allow_next_instance_of(Gitlab::Import::Logger) do |logger| + allow(logger).to receive(:info) + end + end + + context 'when the import/export tmp storage directory does not exist' do it 'does not remove any archives' do path = '/invalid/path/' stub_repository_downloads_path(path) @@ -19,49 +25,84 @@ RSpec.describe ImportExportCleanUpService do end end - context 'when the import/export directory exists' do - it 'removes old files' do - in_directory_with_files(mtime: 2.days.ago) do |dir, files| - service.execute - - files.each { |file| expect(File.exist?(file)).to eq false } - expect(File.directory?(dir)).to eq false + context 'when the import/export tmp storage directory exists' do + shared_examples 'removes old tmp files' do |subdir| + it 'removes old files and logs' do + expect_next_instance_of(Gitlab::Import::Logger) do |logger| + expect(logger) + .to receive(:info) + .with( + message: 'Removed Import/Export tmp directory', + dir_path: anything + ) + end + + validate_cleanup(subdir: subdir, mtime: 2.days.ago, expected: false) end - end - it 'does not remove new files' do - in_directory_with_files(mtime: 2.hours.ago) do |dir, files| - service.execute + it 'does not remove new files or logs' do + expect(Gitlab::Import::Logger).not_to receive(:new) - files.each { |file| expect(File.exist?(file)).to eq true } - expect(File.directory?(dir)).to eq true + validate_cleanup(subdir: subdir, mtime: 2.hours.ago, expected: true) end end + + include_examples 'removes old tmp files', '@hashed' + include_examples 'removes old tmp files', '@groups' end context 'with uploader exports' do - it 'removes old files' do + it 'removes old files and logs' do upload = create(:import_export_upload, updated_at: 2.days.ago, export_file: fixture_file_upload('spec/fixtures/project_export.tar.gz')) + expect_next_instance_of(Gitlab::Import::Logger) do |logger| + expect(logger) + .to receive(:info) + .with( + message: 'Removed Import/Export export_file', + project_id: upload.project_id, + group_id: upload.group_id + ) + end + expect { service.execute }.to change { upload.reload.export_file.file.nil? }.to(true) + + expect(ImportExportUpload.where(export_file: nil)).to include(upload) end - it 'does not remove new files' do + it 'does not remove new files or logs' do upload = create(:import_export_upload, updated_at: 1.hour.ago, export_file: fixture_file_upload('spec/fixtures/project_export.tar.gz')) + expect(Gitlab::Import::Logger).not_to receive(:new) + expect { service.execute }.not_to change { upload.reload.export_file.file.nil? } + + expect(ImportExportUpload.where.not(export_file: nil)).to include(upload) + end + end + + def validate_cleanup(subdir:, mtime:, expected:) + in_directory_with_files(mtime: mtime, subdir: subdir) do |dir, files| + service.execute + + files.each { |file| expect(File.exist?(file)).to eq(expected) } + expect(File.directory?(dir)).to eq(expected) end end - def in_directory_with_files(mtime:) + def in_directory_with_files(mtime:, subdir:) Dir.mktmpdir do |tmpdir| stub_repository_downloads_path(tmpdir) - dir = File.join(tmpdir, tmp_import_export_folder, 'subfolder') + hashed = Digest::SHA2.hexdigest(subdir) + subdir_path = [subdir, hashed[0..1], hashed[2..3], hashed, hashed[4..10]] + dir = File.join(tmpdir, tmp_import_export_folder, *[subdir_path]) + FileUtils.mkdir_p(dir) + File.utime(mtime.to_i, mtime.to_i, dir) files = FileUtils.touch(file_list(dir) + [dir], mtime: mtime.to_time) diff --git a/spec/workers/every_sidekiq_worker_spec.rb b/spec/workers/every_sidekiq_worker_spec.rb index 72e52146baf..350d6080c85 100644 --- a/spec/workers/every_sidekiq_worker_spec.rb +++ b/spec/workers/every_sidekiq_worker_spec.rb @@ -371,7 +371,6 @@ RSpec.describe 'Every Sidekiq worker' do 'PipelineMetricsWorker' => 3, 'PipelineNotificationWorker' => 3, 'PipelineProcessWorker' => 3, - 'PipelineUpdateWorker' => 3, 'PostReceive' => 3, 'ProcessCommitWorker' => 3, 'ProjectCacheWorker' => 3, -- cgit v1.2.3