diff --git a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue index 42fc85cc5fb..3768e0bacfd 100644 --- a/app/assets/javascripts/batch_comments/components/preview_dropdown.vue +++ b/app/assets/javascripts/batch_comments/components/preview_dropdown.vue @@ -31,14 +31,14 @@ export default { }, }, methods: { - ...mapActions('diffs', ['setCurrentFileHash']), + ...mapActions('diffs', ['goToFile']), ...mapActions('batchComments', ['scrollToDraft']), isOnLatestDiff(draft) { return draft.position?.head_sha === this.getNoteableData.diff_head_sha; }, async onClickDraft(draft) { - if (this.viewDiffsFileByFile && draft.file_hash) { - await this.setCurrentFileHash(draft.file_hash); + if (this.viewDiffsFileByFile) { + await this.goToFile({ path: draft.file_path }); } if (draft.position && !this.isOnLatestDiff(draft)) { diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index 42c30dc8245..3dd7f7b70f5 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -201,8 +201,8 @@ export default { }) ); }, - totalWeight() { - return this.boardList?.totalWeight; + totalIssueWeight() { + return this.boardList?.totalIssueWeight; }, canShowTotalWeight() { return this.weightFeatureAvailable && !this.isLoading; @@ -473,8 +473,8 @@ export default {
• {{ itemsTooltipLabel }}
• - - + +
@@ -507,7 +507,7 @@ export default { - {{ totalWeight }} + {{ totalIssueWeight }} diff --git a/app/assets/javascripts/boards/graphql/cache_updates.js b/app/assets/javascripts/boards/graphql/cache_updates.js index 3551c3ed982..0cd43cb9cee 100644 --- a/app/assets/javascripts/boards/graphql/cache_updates.js +++ b/app/assets/javascripts/boards/graphql/cache_updates.js @@ -68,7 +68,7 @@ export function updateIssueCountAndWeight({ boardList: { ...boardList, issuesCount: boardList.issuesCount - 1, - totalWeight: boardList.totalWeight - issue.weight, + totalIssueWeight: boardList.totalIssueWeight - issue.weight, }, }), ); @@ -83,7 +83,7 @@ export function updateIssueCountAndWeight({ boardList: { ...boardList, issuesCount: boardList.issuesCount + 1, - totalWeight: boardList.totalWeight + issue.weight, + totalIssueWeight: boardList.totalIssueWeight + issue.weight, }, }), ); diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index e044283534a..3e7d7a7a8d3 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -621,7 +621,7 @@ export default { __typename: 'BoardList', id: fromList.boardList.id, issuesCount: fromList.boardList.issuesCount - 1, - totalWeight: fromList.boardList.totalWeight - Number(weight), + totalIssueWeight: fromList.boardList.totalIssueWeight - Number(weight), }, }; @@ -645,7 +645,7 @@ export default { __typename: 'BoardList', id: toList.boardList.id, issuesCount: toList.boardList.issuesCount + 1, - totalWeight: toList.boardList.totalWeight + Number(weight), + totalIssueWeight: toList.boardList.totalIssueWeight + Number(weight), }, }; @@ -731,7 +731,7 @@ export default { __typename: 'BoardList', id: fromList.boardList.id, issuesCount: fromList.boardList.issuesCount + 1, - totalWeight: fromList.boardList.totalWeight, + totalIssueWeight: fromList.boardList.totalIssueWeight, }, }; diff --git a/app/helpers/safe_format_helper.rb b/app/helpers/safe_format_helper.rb index 71bfc9ecb40..9f8c5082c26 100644 --- a/app/helpers/safe_format_helper.rb +++ b/app/helpers/safe_format_helper.rb @@ -25,8 +25,8 @@ module SafeFormatHelper # Use `Kernel.format` to avoid conflicts with ViewComponent's `format`. Kernel.format( - html_escape_once(format), - args.transform_values { |value| html_escape(value) } + ERB::Util.html_escape_once(format), + args.transform_values { |value| ERB::Util.html_escape(value) } ).html_safe end diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index d4604124b77..d053aeb7bfe 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -166,6 +166,10 @@ module TodosHelper todos_filter_params.values.none? end + def todos_has_filtered_results? + params[:group_id] || params[:project_id] || params[:author_id] || params[:type] || params[:action_id] + end + def no_todos_messages [ s_('Todos|Good job! Looks like you don\'t have anything left on your To-Do List'), diff --git a/app/models/bulk_import.rb b/app/models/bulk_import.rb index fde528e3fa0..a7ace7429d7 100644 --- a/app/models/bulk_import.rb +++ b/app/models/bulk_import.rb @@ -76,4 +76,8 @@ class BulkImport < ApplicationRecord def supports_batched_export? source_version_info >= self.class.min_gl_version_for_migration_in_batches end + + def completed? + finished? || failed? || timeout? + end end diff --git a/app/models/integrations/bamboo.rb b/app/models/integrations/bamboo.rb index 0b8432136dd..4b98014e0cc 100644 --- a/app/models/integrations/bamboo.rb +++ b/app/models/integrations/bamboo.rb @@ -76,6 +76,10 @@ module Integrations { build_page: read_build_page(response), commit_status: read_commit_status(response) } end + def avatar_url + ActionController::Base.helpers.image_path('illustrations/third-party-logos/integrations-logos/atlassian-bamboo.svg') + end + private def get_build_result(response) diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index a3a0ab70a43..20f88577d67 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -642,7 +642,6 @@ class ProjectPolicy < BasePolicy prevent(*create_read_update_admin_destroy(:build)) prevent(*create_read_update_admin_destroy(:pipeline_schedule)) prevent(*create_read_update_admin_destroy(:environment)) - prevent(*create_read_update_admin_destroy(:cluster)) prevent(*create_read_update_admin_destroy(:deployment)) end @@ -666,6 +665,7 @@ class ProjectPolicy < BasePolicy prevent :read_pipeline_schedule prevent(*create_read_update_admin_destroy(:feature_flag)) prevent(:admin_feature_flags_user_lists) + prevent(*create_read_update_admin_destroy(:cluster)) end rule { container_registry_disabled }.policy do diff --git a/app/services/chat_names/find_user_service.rb b/app/services/chat_names/find_user_service.rb index 3b204d51bab..a7dc6a47a6b 100644 --- a/app/services/chat_names/find_user_service.rb +++ b/app/services/chat_names/find_user_service.rb @@ -11,7 +11,7 @@ module ChatNames chat_name = find_chat_name return unless chat_name - chat_name.update_last_used_at + record_chat_activity(chat_name) chat_name end @@ -27,5 +27,10 @@ module ChatNames ) end # rubocop: enable CodeReuse/ActiveRecord + + def record_chat_activity(chat_name) + chat_name.update_last_used_at + Users::ActivityService.new(author: chat_name.user).execute + end end end diff --git a/app/services/users/signup_service.rb b/app/services/users/signup_service.rb deleted file mode 100644 index 810c29bfb1e..00000000000 --- a/app/services/users/signup_service.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -module Users - class SignupService < BaseService - def initialize(current_user, params = {}) - @user = current_user - @params = params.dup - end - - def execute - assign_attributes - inject_validators - - if @user.save - ServiceResponse.success - else - ServiceResponse.error(message: @user.errors.full_messages.join('. ')) - end - end - - private - - def assign_attributes - @user.assign_attributes(params) unless params.empty? - end - - def inject_validators - class << @user - validates :role, presence: true - validates :setup_for_company, inclusion: { in: [true, false], message: :blank } - end - end - end -end diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml index 5084adadfcb..ab97507b3c8 100644 --- a/app/views/dashboard/todos/index.html.haml +++ b/app/views/dashboard/todos/index.html.haml @@ -6,8 +6,8 @@ - add_page_specific_style 'page_bundles/todos' - add_page_specific_style 'page_bundles/issuable' - filter_by_done = params[:state] == 'done' -- open_todo_count = !filter_by_done ? @allowed_todos.count : todos_pending_count -- done_todo_count = filter_by_done ? @allowed_todos.count : todos_done_count +- open_todo_count = todos_has_filtered_results? && !filter_by_done ? @allowed_todos.count : todos_pending_count +- done_todo_count = todos_has_filtered_results? && filter_by_done ? @allowed_todos.count : todos_done_count .page-title-holder.d-flex.align-items-center %h1.page-title.gl-font-size-h-display= _("To-Do List") @@ -83,31 +83,38 @@ %ul.content-list.todos-list = render @allowed_todos = paginate @todos, theme: "gitlab" - .js-nothing-here-container.empty-state.hidden + .js-nothing-here-container.gl-empty-state.gl-text-center.hidden .svg-content.svg-150 = image_tag 'illustrations/empty-todos-all-done-md.svg' .text-content.gl-text-center - %h4 + %h1.gl-font-size-h-display.gl-line-height-36.gl-mt-0 = s_("Todos|You're all done!") - elsif current_user.todos.any? - .col.todos-all-done.empty-state + .col.todos-all-done.gl-empty-state.gl-text-center .svg-content.svg-150 - = image_tag 'illustrations/empty-todos-all-done-md.svg' - .text-content.gl-text-center - - if todos_filter_empty? - %h4 + = image_tag (!todos_filter_empty? && !todos_has_filtered_results?) ? 'illustrations/empty-todos-all-done-md.svg' : 'illustrations/empty-todos-md.svg' + .text-content.gl-text-center.gl-m-auto{ class: "gl-max-w-88!" } + %h1.gl-font-size-h-display.gl-line-height-36.gl-mt-0 + - if todos_filter_empty? = no_todos_messages.sample + - elsif todos_has_filtered_results? + = _("Sorry, your filter produced no results") + - else + = s_("Todos|Nothing is on your to-do list. Nice work!") + + - if todos_filter_empty? %p = (s_("Todos|Are you looking for things to do? Take a look at %{strongStart}%{openIssuesLinkStart}open issues%{openIssuesLinkEnd}%{strongEnd}, contribute to %{strongStart}%{mergeRequestLinkStart}a merge request%{mergeRequestLinkEnd}%{mergeRequestLinkEnd}%{strongEnd}, or mention someone in a comment to automatically assign them a new to-do item.") % { strongStart: '', strongEnd: '', openIssuesLinkStart: "", openIssuesLinkEnd: '', mergeRequestLinkStart: "", mergeRequestLinkEnd: '' }).html_safe - - else - %h4 - = s_("Todos|Nothing is on your to-do list. Nice work!") + - elsif todos_has_filtered_results? + %p + = link_to s_("Todos|Do you want to remove the filters?"), todos_filter_path(without: [:project_id, :author_id, :type, :action_id]) + - else - .col.empty-state + .col.gl-empty-state.gl-text-center .svg-content.svg-150 = image_tag 'illustrations/empty-todos-md.svg' - .text-content.gl-text-center - %h4 + .text-content.gl-text-center.gl-m-auto{ class: "gl-max-w-88!" } + %h1.gl-font-size-h-display.gl-line-height-36.gl-mt-0 = s_("Todos|Your To-Do List shows what to work on next") %p = (s_("Todos|When an issue or merge request is assigned to you, or when you receive a %{strongStart}@mention%{strongEnd} in a comment, this automatically triggers a new item in your To-Do List.") % { strongStart: '', strongEnd: '' }).html_safe diff --git a/app/views/layouts/nav/_top_bar.html.haml b/app/views/layouts/nav/_top_bar.html.haml index 59bfd8dd0dd..ef783b688e0 100644 --- a/app/views/layouts/nav/_top_bar.html.haml +++ b/app/views/layouts/nav/_top_bar.html.haml @@ -10,6 +10,6 @@ - if show_super_sidebar? = render Pajamas::ButtonComponent.new(icon: 'sidebar', category: :tertiary, button_options: { class: 'js-super-sidebar-toggle-expand super-sidebar-toggle gl-ml-n3', aria: { controls: 'super-sidebar', expanded: 'false', label: _('Primary navigation sidebar') } }) - elsif defined?(@left_sidebar) - = render Pajamas::ButtonComponent.new(icon: 'sidebar', category: :tertiary, button_options: { class: 'toggle-mobile-nav gl-ml-n3', data: { testid: 'toggle_mobile_nav_button' }, aria: { label: _('Open sidebar') } }) + = render Pajamas::ButtonComponent.new(icon: 'sidebar', category: :tertiary, button_options: { class: 'toggle-mobile-nav gl-ml-n3', data: { testid: 'toggle-mobile-nav-button' }, aria: { label: _('Open sidebar') } }) = render "layouts/nav/breadcrumbs/breadcrumbs" = render "layouts/nav/ask_duo_button" diff --git a/app/workers/bulk_import_worker.rb b/app/workers/bulk_import_worker.rb index 83b881ee525..c337824d6d9 100644 --- a/app/workers/bulk_import_worker.rb +++ b/app/workers/bulk_import_worker.rb @@ -14,7 +14,7 @@ class BulkImportWorker # rubocop:disable Scalability/IdempotentWorker @bulk_import = BulkImport.find_by_id(bulk_import_id) return unless @bulk_import - return if @bulk_import.finished? || @bulk_import.failed? + return if @bulk_import.completed? return @bulk_import.fail_op! if all_entities_failed? return @bulk_import.finish! if all_entities_processed? && @bulk_import.started? return re_enqueue if max_batch_size_exceeded? # Do not start more jobs if max allowed are already running diff --git a/doc/architecture/blueprints/cells/index.md b/doc/architecture/blueprints/cells/index.md index b8eea16e059..bac41a4d903 100644 --- a/doc/architecture/blueprints/cells/index.md +++ b/doc/architecture/blueprints/cells/index.md @@ -93,11 +93,11 @@ The first 2-3 quarters are required to define a general split of data and build The Admin Area section for the most part is shared across a cluster. -1. **User accounts are shared across cluster.** +1. **User accounts are shared across cluster.** ✓ The purpose is to make `users` cluster-wide. -1. **User can create Group.** +1. **User can create Group.** ✓ ([demo](https://www.youtube.com/watch?v=LUyV0ncfdRs)) The purpose is to perform a targeted decomposition of `users` and `namespaces`, because `namespaces` will be stored locally in the Cell. @@ -323,6 +323,7 @@ Below is a list of known affected features with preliminary proposed solutions. - [Cells: Admin Area](impacted_features/admin-area.md) - [Cells: Backups](impacted_features/backups.md) +- [Cells: CI/CD Catalog](impacted_features/ci-cd-catalog.md) - [Cells: CI Runners](impacted_features/ci-runners.md) - [Cells: Container Registry](impacted_features/container-registry.md) - [Cells: Contributions: Forks](impacted_features/contributions-forks.md) @@ -344,7 +345,6 @@ Below is a list of known affected features with preliminary proposed solutions. The following list of impacted features only represents placeholders that still require work to estimate the impact of Cells and develop solution proposals. - [Cells: Agent for Kubernetes](impacted_features/agent-for-kubernetes.md) -- [Cells: CI/CD Catalog](impacted_features/ci-cd-catalog.md) - [Cells: Data pipeline ingestion](impacted_features/data-pipeline-ingestion.md) - [Cells: GitLab Pages](impacted_features/gitlab-pages.md) - [Cells: Personal Access Tokens](impacted_features/personal-access-tokens.md) diff --git a/doc/security/token_overview.md b/doc/security/token_overview.md index 637a0ffebcd..0a28f37b99b 100644 --- a/doc/security/token_overview.md +++ b/doc/security/token_overview.md @@ -93,9 +93,9 @@ Project maintainers and owners can add or enable a deploy key for a project repo ## Runner authentication tokens -In GitLab 16.0 and later, you can use a runner authentication token to register -runners instead of a runner registration token. Runner registration tokens have -been [deprecated](../update/deprecations.md#registration-tokens-and-server-side-runner-arguments-in-gitlab-runner-register-command). +In GitLab 16.0 and later, to register a runner, you can use a runner authentication token +instead of a runner registration token. Runner registration tokens have +been [deprecated](../ci/runners/new_creation_workflow.md). After you create a runner and its configuration, you receive a runner authentication token that you use to register the runner. The runner authentication token is stored locally in the @@ -117,7 +117,7 @@ for the following executors only have access to the job token and not the runner - SSH Malicious access to a runner's file system may expose the `config.toml` file and the -runner authentication token. The attacker could use the runner authentication +runner authentication token. The attacker could use the runner authentication token to [clone the runner](https://docs.gitlab.com/runner/security/#cloning-a-runner). You can use the `runners` API to @@ -126,7 +126,7 @@ programmatically [rotate or revoke a runner authentication token](../api/runners ## Runner registration tokens (deprecated) WARNING: -The ability to pass a runner registration token has been [deprecated](https://gitlab.com/gitlab-org/gitlab/-/issues/380872) and is +The ability to pass a runner registration token has been [deprecated](../ci/runners/new_creation_workflow.md) and is planned for removal in GitLab 18.0, along with support for certain configuration arguments. This change is a breaking change. GitLab has implemented a new [GitLab Runner token architecture](../ci/runners/new_creation_workflow.md), which introduces a new method for registering runners and eliminates the diff --git a/doc/security/two_factor_authentication.md b/doc/security/two_factor_authentication.md index 0c569e9843d..281b8238d75 100644 --- a/doc/security/two_factor_authentication.md +++ b/doc/security/two_factor_authentication.md @@ -71,6 +71,8 @@ To enforce 2FA only for certain groups: and projects, the shortest grace period is used. 1. Select **Save changes**. +Enforcement affects all [direct and inherited members](../user/project/members/index.md#membership-types) in the group. + Access tokens are not required to provide a second factor for authentication because they are API-based. Tokens generated before 2FA is enforced remain valid. diff --git a/doc/user/group/saml_sso/troubleshooting.md b/doc/user/group/saml_sso/troubleshooting.md index cf9b9f5d4eb..b6b2da8e44c 100644 --- a/doc/user/group/saml_sso/troubleshooting.md +++ b/doc/user/group/saml_sso/troubleshooting.md @@ -259,6 +259,14 @@ If you receive a `404` during setup when using "verify configuration", make sure If a user is trying to sign in for the first time and the GitLab single sign-on URL has not [been configured](index.md#set-up-your-identity-provider), they may see a 404. As outlined in the [user access section](index.md#link-saml-to-your-existing-gitlabcom-account), a group Owner needs to provide the URL to users. +If the top-level group has [restricted membership by email domain](../access_and_permissions.md#restrict-group-access-by-domain), and a user with an email domain that is not allowed tries to sign in with SSO, that user might receive a 404. Users might have multiple accounts, and their SAML identity might be linked to their personal account which has an email address that is different than the company domain. To check this, verify the following: + +- That the top-level group has restricted membership by email domain. +- That, in [Audit Events](../../../administration/audit_events.md) for the top-level group: + - You can see **Signed in with GROUP_SAML authentication** action for that user. + - That the user's username is the same as the username you configured for SAML SSO, by selecting the **Author** name. + - If the username is different to the username you configured for SAML SSO, ask the user to [unlink the SAML identity](index.md#unlink-accounts) from their personal account. + If all users are receiving a `404` after signing in to the identity provider (IdP): - Verify the `assertion_consumer_service_url`: diff --git a/lib/api/helpers/packages/maven.rb b/lib/api/helpers/packages/maven.rb index 71d1ba486ed..04470bc6dad 100644 --- a/lib/api/helpers/packages/maven.rb +++ b/lib/api/helpers/packages/maven.rb @@ -9,10 +9,12 @@ module API params :path_and_file_name do requires :path, type: String, + file_path: true, desc: 'Package path', documentation: { example: 'foo/bar/mypkg/1.0-SNAPSHOT' } requires :file_name, type: String, + file_path: true, desc: 'Package file name', documentation: { example: 'mypkg-1.0-SNAPSHOT.jar' } end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 3e207cbb53a..d4e5fe8d58a 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -1268,10 +1268,10 @@ msgstr "" msgid "%{totalCpu} (%{freeSpacePercentage}%{percentSymbol} free)" msgstr "" -msgid "%{totalMemory} (%{freeSpacePercentage}%{percentSymbol} free)" +msgid "%{totalIssueWeight} total weight" msgstr "" -msgid "%{totalWeight} total weight" +msgid "%{totalMemory} (%{freeSpacePercentage}%{percentSymbol} free)" msgstr "" msgid "%{total_warnings} warning(s) found:" @@ -49497,6 +49497,9 @@ msgstr "" msgid "Todos|Design" msgstr "" +msgid "Todos|Do you want to remove the filters?" +msgstr "" + msgid "Todos|Due %{due_date}" msgstr "" diff --git a/qa/qa/mobile/page/sub_menus/common.rb b/qa/qa/mobile/page/sub_menus/common.rb index aaa2de270b0..ef2815ca777 100644 --- a/qa/qa/mobile/page/sub_menus/common.rb +++ b/qa/qa/mobile/page/sub_menus/common.rb @@ -8,7 +8,7 @@ module QA def open_mobile_nav_sidebar unless has_css?('.sidebar-expanded-mobile') Support::Retrier.retry_until do - click_element(:toggle_mobile_nav_button) + click_element('toggle-mobile-nav-button') has_css?('.sidebar-expanded-mobile') end end diff --git a/qa/qa/page/project/sub_menus/common.rb b/qa/qa/page/project/sub_menus/common.rb index 563bc9257c5..0619afc313c 100644 --- a/qa/qa/page/project/sub_menus/common.rb +++ b/qa/qa/page/project/sub_menus/common.rb @@ -14,7 +14,7 @@ module QA include QA::Page::SubMenus::Common view 'app/views/layouts/nav/_top_bar.html.haml' do - element :toggle_mobile_nav_button + element 'toggle-mobile-nav-button' end end end diff --git a/spec/factories/bulk_import.rb b/spec/factories/bulk_import.rb index 54d05264269..097bec43543 100644 --- a/spec/factories/bulk_import.rb +++ b/spec/factories/bulk_import.rb @@ -22,5 +22,9 @@ FactoryBot.define do trait :failed do status { -1 } end + + trait :timeout do + status { 3 } + end end end diff --git a/spec/features/dashboard/todos/todos_spec.rb b/spec/features/dashboard/todos/todos_spec.rb index 5642d083673..ade7da0cb49 100644 --- a/spec/features/dashboard/todos/todos_spec.rb +++ b/spec/features/dashboard/todos/todos_spec.rb @@ -7,6 +7,7 @@ RSpec.describe 'Dashboard Todos', feature_category: :team_planning do let_it_be(:user) { create(:user, :no_super_sidebar, username: 'john') } let_it_be(:user2) { create(:user, :no_super_sidebar, username: 'diane') } + let_it_be(:user3) { create(:user) } let_it_be(:author) { create(:user, :no_super_sidebar) } let_it_be(:project) { create(:project, :public) } let_it_be(:issue) { create(:issue, project: project, due_date: Date.today, title: "Fix bug") } @@ -424,6 +425,25 @@ RSpec.describe 'Dashboard Todos', feature_category: :team_planning do wait_for_requests end end + + describe 'shows a count of todos' do + before do + allow(Todo).to receive(:default_per_page).and_return(1) + create_list(:todo, 2, :mentioned, user: user3, project: project, target: issue, author: author, state: :pending) + create_list(:todo, 2, :mentioned, user: user3, project: project, target: issue, author: author, state: :done) + sign_in(user3) + end + + it 'displays a count of all pending todos' do + visit dashboard_todos_path + expect(find('.js-todos-pending')).to have_content('2') + end + + it 'displays a count of all done todos' do + visit dashboard_todos_path(state: 'done') + expect(find('.js-todos-done')).to have_content('2') + end + end end context 'User has a Build Failed todo' do diff --git a/spec/frontend/batch_comments/components/preview_dropdown_spec.js b/spec/frontend/batch_comments/components/preview_dropdown_spec.js index 608e9c82961..c0ad40b75ad 100644 --- a/spec/frontend/batch_comments/components/preview_dropdown_spec.js +++ b/spec/frontend/batch_comments/components/preview_dropdown_spec.js @@ -16,7 +16,7 @@ Vue.use(Vuex); let wrapper; -const setCurrentFileHash = jest.fn(); +const goToFile = jest.fn(); const scrollToDraft = jest.fn(); const findPreviewItem = () => wrapper.findComponent(PreviewItem); @@ -27,7 +27,7 @@ function factory({ viewDiffsFileByFile = false, draftsCount = 1, sortedDrafts = diffs: { namespaced: true, actions: { - setCurrentFileHash, + goToFile, }, state: { viewDiffsFileByFile, @@ -59,12 +59,12 @@ describe('Batch comments preview dropdown', () => { it('toggles active file when viewDiffsFileByFile is true', async () => { factory({ viewDiffsFileByFile: true, - sortedDrafts: [{ id: 1, file_hash: 'hash' }], + sortedDrafts: [{ id: 1, file_hash: 'hash', file_path: 'foo' }], }); findPreviewItem().trigger('click'); await nextTick(); - expect(setCurrentFileHash).toHaveBeenCalledWith(expect.anything(), 'hash'); + expect(goToFile).toHaveBeenCalledWith(expect.anything(), { path: 'foo' }); await nextTick(); expect(scrollToDraft).toHaveBeenCalledWith( diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js index dfcdb4c05d0..2b4c694bee9 100644 --- a/spec/frontend/boards/mock_data.js +++ b/spec/frontend/boards/mock_data.js @@ -973,7 +973,7 @@ export const boardListQueryResponse = ({ boardList: { __typename: 'BoardList', id: listId, - totalWeight: 5, + totalIssueWeight: '5', issuesCount, }, }, diff --git a/spec/lib/gitlab/ci/config/interpolation/inputs_spec.rb b/spec/lib/gitlab/ci/config/interpolation/inputs_spec.rb index c7ec0a34119..79a26f5a5ef 100644 --- a/spec/lib/gitlab/ci/config/interpolation/inputs_spec.rb +++ b/spec/lib/gitlab/ci/config/interpolation/inputs_spec.rb @@ -7,155 +7,291 @@ RSpec.describe Gitlab::Ci::Config::Interpolation::Inputs, feature_category: :pip let(:specs) { { foo: { default: 'bar' } } } let(:args) { {} } - context 'when inputs are valid' do - where(:specs, :args, :merged) do - [ - [ - { foo: { default: 'bar' } }, {}, - { foo: 'bar' } - ], - [ - { foo: { default: 'bar' } }, { foo: 'test' }, - { foo: 'test' } - ], - [ - { foo: nil }, { foo: 'bar' }, - { foo: 'bar' } - ], - [ - { foo: { type: 'string' } }, { foo: 'bar' }, - { foo: 'bar' } - ], - [ - { foo: { type: 'string', default: 'bar' } }, { foo: 'test' }, - { foo: 'test' } - ], - [ - { foo: { type: 'string', default: 'bar' } }, {}, - { foo: 'bar' } - ], - [ - { foo: { default: 'bar' }, baz: nil }, { baz: 'test' }, - { foo: 'bar', baz: 'test' } - ], - [ - { number_input: { type: 'number' } }, - { number_input: 8 }, - { number_input: 8 } - ], - [ - { default_number_input: { default: 9, type: 'number' } }, - {}, - { default_number_input: 9 } - ], - [ - { true_input: { type: 'boolean' }, false_input: { type: 'boolean' } }, - { true_input: true, false_input: false }, - { true_input: true, false_input: false } - ], - [ - { default_boolean_input: { default: true, type: 'boolean' } }, - {}, - { default_boolean_input: true } - ], - [ - { test_input: { regex: '^input_value$' } }, - { test_input: 'input_value' }, - { test_input: 'input_value' } - ], - [ - { test_input: { regex: '^input_value$', default: 'input_value' } }, - {}, - { test_input: 'input_value' } - ] - ] + context 'when given unrecognized inputs' do + let(:specs) { { foo: nil } } + let(:args) { { foo: 'bar', test: 'bar' } } + + it 'is invalid' do + expect(inputs).not_to be_valid + expect(inputs.errors).to contain_exactly('unknown input arguments: test') + end + end + + context 'when given unrecognized configuration keywords' do + let(:specs) { { foo: 123 } } + let(:args) { {} } + + it 'is invalid' do + expect(inputs).not_to be_valid + expect(inputs.errors).to contain_exactly( + 'unknown input specification for `foo` (valid types: boolean, number, string)' + ) + end + end + + context 'when the inputs have multiple errors' do + let(:specs) { { foo: nil } } + let(:args) { { test: 'bar', gitlab: '1' } } + + it 'reports all of them' do + expect(inputs).not_to be_valid + expect(inputs.errors).to contain_exactly( + 'unknown input arguments: test, gitlab', + '`foo` input: required value has not been provided' + ) + end + end + + describe 'required inputs' do + let(:specs) { { foo: nil } } + + context 'when a value is given' do + let(:args) { { foo: 'bar' } } + + it 'is valid' do + expect(inputs).to be_valid + expect(inputs.to_hash).to eq(foo: 'bar') + end end - with_them do - it 'contains the merged inputs' do - expect(inputs).to be_valid - expect(inputs.to_hash).to eq(merged) + context 'when no value is given' do + let(:args) { {} } + + it 'is invalid' do + expect(inputs).not_to be_valid + expect(inputs.errors).to contain_exactly('`foo` input: required value has not been provided') end end end - context 'when inputs are invalid' do - where(:specs, :args, :errors) do - [ - [ - { foo: nil }, { foo: 'bar', test: 'bar' }, - ['unknown input arguments: test'] - ], - [ - { foo: nil }, { test: 'bar', gitlab: '1' }, - ['unknown input arguments: test, gitlab', '`foo` input: required value has not been provided'] - ], - [ - { foo: 123 }, {}, - ['unknown input specification for `foo` (valid types: boolean, number, string)'] - ], - [ - { a: nil, foo: 123 }, { a: '123' }, - ['unknown input specification for `foo` (valid types: boolean, number, string)'] - ], - [ - { foo: nil }, {}, - ['`foo` input: required value has not been provided'] - ], - [ - { foo: { default: 123 } }, { foo: 'test' }, - ['`foo` input: default value is not a string'] - ], - [ - { foo: { default: 'test' } }, { foo: 123 }, - ['`foo` input: provided value is not a string'] - ], - [ - { foo: nil }, { foo: 123 }, - ['`foo` input: provided value is not a string'] - ], - [ - { number_input: { type: 'number' } }, - { number_input: 'NaN' }, - ['`number_input` input: provided value is not a number'] - ], - [ - { default_number_input: { default: 'NaN', type: 'number' } }, - {}, - ['`default_number_input` input: default value is not a number'] - ], - [ - { boolean_input: { type: 'boolean' } }, - { boolean_input: 'string' }, - ['`boolean_input` input: provided value is not a boolean'] - ], - [ - { default_boolean_input: { default: 'string', type: 'boolean' } }, - {}, - ['`default_boolean_input` input: default value is not a boolean'] - ], - [ - { test_input: { regex: '^input_value$' } }, - { test_input: 'input' }, - ['`test_input` input: provided value does not match required RegEx pattern'] - ], - [ - { test_input: { regex: '^input_value$', default: 'default' } }, - {}, - ['`test_input` input: default value does not match required RegEx pattern'] - ], - [ - { test_input: { regex: '^input_value$', type: 'number' } }, - { test_input: 999 }, - ['`test_input` input: RegEx validation can only be used with string inputs'] - ] - ] + describe 'inputs with a default value' do + let(:specs) { { foo: { default: 'bar' } } } + + context 'when a value is given' do + let(:args) { { foo: 'test' } } + + it 'uses the given value' do + expect(inputs).to be_valid + expect(inputs.to_hash).to eq(foo: 'test') + end end - with_them do - it 'contains the merged inputs', :aggregate_failures do + context 'when no value is given' do + let(:args) { {} } + + it 'uses the default value' do + expect(inputs).to be_valid + expect(inputs.to_hash).to eq(foo: 'bar') + end + end + end + + describe 'inputs with type validation' do + describe 'string validation' do + let(:specs) { { a_input: nil, b_input: { default: 'test' }, c_input: { default: 123 } } } + let(:args) { { a_input: 123, b_input: 123, c_input: 'test' } } + + it 'is the default type' do expect(inputs).not_to be_valid - expect(inputs.errors).to contain_exactly(*errors) + expect(inputs.errors).to contain_exactly( + '`a_input` input: provided value is not a string', + '`b_input` input: provided value is not a string', + '`c_input` input: default value is not a string' + ) + end + + context 'when the value is a string' do + let(:specs) { { foo: { type: 'string' } } } + let(:args) { { foo: 'bar' } } + + it 'is valid' do + expect(inputs).to be_valid + expect(inputs.to_hash).to eq(foo: 'bar') + end + end + + context 'when the default is a string' do + let(:specs) { { foo: { type: 'string', default: 'bar' } } } + let(:args) { {} } + + it 'is valid' do + expect(inputs).to be_valid + expect(inputs.to_hash).to eq(foo: 'bar') + end + end + + context 'when the value is not a string' do + let(:specs) { { foo: { type: 'string' } } } + let(:args) { { foo: 123 } } + + it 'is invalid' do + expect(inputs).not_to be_valid + expect(inputs.errors).to contain_exactly('`foo` input: provided value is not a string') + end + end + + context 'when the default is not a string' do + let(:specs) { { foo: { default: 123, type: 'string' } } } + let(:args) { {} } + + it 'is invalid' do + expect(inputs).not_to be_valid + expect(inputs.errors).to contain_exactly('`foo` input: default value is not a string') + end + end + end + + describe 'number validation' do + let(:specs) { { integer: { type: 'number' }, float: { type: 'number' } } } + + context 'when the value is a float or integer' do + let(:args) { { integer: 6, float: 6.6 } } + + it 'is valid' do + expect(inputs).to be_valid + expect(inputs.to_hash).to eq(integer: 6, float: 6.6) + end + end + + context 'when the default is a float or integer' do + let(:specs) { { integer: { default: 6, type: 'number' }, float: { default: 6.6, type: 'number' } } } + + it 'is valid' do + expect(inputs).to be_valid + expect(inputs.to_hash).to eq(integer: 6, float: 6.6) + end + end + + context 'when the value is not a number' do + let(:specs) { { number_input: { type: 'number' } } } + let(:args) { { number_input: 'NaN' } } + + it 'is invalid' do + expect(inputs).not_to be_valid + expect(inputs.errors).to contain_exactly('`number_input` input: provided value is not a number') + end + end + + context 'when the default is not a number' do + let(:specs) { { number_input: { default: 'NaN', type: 'number' } } } + let(:args) { {} } + + it 'is invalid' do + expect(inputs).not_to be_valid + expect(inputs.errors).to contain_exactly('`number_input` input: default value is not a number') + end + end + end + + describe 'boolean validation' do + context 'when the value is true or false' do + let(:specs) { { truthy: { type: 'boolean' }, falsey: { type: 'boolean' } } } + let(:args) { { truthy: true, falsey: false } } + + it 'is valid' do + expect(inputs).to be_valid + expect(inputs.to_hash).to eq(truthy: true, falsey: false) + end + end + + context 'when the default is true or false' do + let(:specs) { { truthy: { default: true, type: 'boolean' }, falsey: { default: false, type: 'boolean' } } } + let(:args) { {} } + + it 'is valid' do + expect(inputs).to be_valid + expect(inputs.to_hash).to eq(truthy: true, falsey: false) + end + end + + context 'when the value is not a boolean' do + let(:specs) { { boolean_input: { type: 'boolean' } } } + let(:args) { { boolean_input: 'string' } } + + it 'is invalid' do + expect(inputs).not_to be_valid + expect(inputs.errors).to contain_exactly('`boolean_input` input: provided value is not a boolean') + end + end + + context 'when the default is not a boolean' do + let(:specs) { { boolean_input: { default: 'string', type: 'boolean' } } } + let(:args) { {} } + + it 'is invalid' do + expect(inputs).not_to be_valid + expect(inputs.errors).to contain_exactly('`boolean_input` input: default value is not a boolean') + end + end + end + + context 'when given an unknown type' do + let(:specs) { { unknown: { type: 'datetime' } } } + let(:args) { { unknown: '2023-10-31' } } + + it 'is invalid' do + expect(inputs).not_to be_valid + expect(inputs.errors).to contain_exactly( + 'unknown input specification for `unknown` (valid types: boolean, number, string)' + ) + end + end + end + + describe 'inputs with RegEx validation' do + context 'when given a value that matches the pattern' do + let(:specs) { { test_input: { regex: '^input_value$' } } } + let(:args) { { test_input: 'input_value' } } + + it 'is valid' do + expect(inputs).to be_valid + expect(inputs.to_hash).to eq(test_input: 'input_value') + end + end + + context 'when given a default that matches the pattern' do + let(:specs) { { test_input: { default: 'input_value', regex: '^input_value$' } } } + let(:args) { {} } + + it 'is valid' do + expect(inputs).to be_valid + expect(inputs.to_hash).to eq(test_input: 'input_value') + end + end + + context 'when given a value that does not match the pattern' do + let(:specs) { { test_input: { regex: '^input_value$' } } } + let(:args) { { test_input: 'input' } } + + it 'is invalid' do + expect(inputs).not_to be_valid + expect(inputs.errors).to contain_exactly( + '`test_input` input: provided value does not match required RegEx pattern' + ) + end + end + + context 'when given a default that does not match the pattern' do + let(:specs) { { test_input: { default: 'input', regex: '^input_value$' } } } + let(:args) { {} } + + it 'is invalid' do + expect(inputs).not_to be_valid + expect(inputs.errors).to contain_exactly( + '`test_input` input: default value does not match required RegEx pattern' + ) + end + end + + context 'when used with any type other than `string`' do + let(:specs) { { test_input: { regex: '^input_value$', type: 'number' } } } + let(:args) { { test_input: 999 } } + + it 'is invalid' do + expect(inputs).not_to be_valid + expect(inputs.errors).to contain_exactly( + '`test_input` input: RegEx validation can only be used with string inputs' + ) end end end diff --git a/spec/models/bulk_import_spec.rb b/spec/models/bulk_import_spec.rb index a50fc6eaba4..ff24f57f7c4 100644 --- a/spec/models/bulk_import_spec.rb +++ b/spec/models/bulk_import_spec.rb @@ -40,6 +40,14 @@ RSpec.describe BulkImport, type: :model, feature_category: :importers do it { expect(described_class.min_gl_version_for_project_migration.to_s).to eq('14.4.0') } end + describe '#completed?' do + it { expect(described_class.new(status: -1)).to be_completed } + it { expect(described_class.new(status: 0)).not_to be_completed } + it { expect(described_class.new(status: 1)).not_to be_completed } + it { expect(described_class.new(status: 2)).to be_completed } + it { expect(described_class.new(status: 3)).to be_completed } + end + describe '#source_version_info' do it 'returns source_version as Gitlab::VersionInfo' do bulk_import = build(:bulk_import, source_version: '9.13.2') diff --git a/spec/models/chat_name_spec.rb b/spec/models/chat_name_spec.rb index 5271e52f429..dbe013f3872 100644 --- a/spec/models/chat_name_spec.rb +++ b/spec/models/chat_name_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe ChatName, feature_category: :integrations do - let_it_be(:chat_name) { create(:chat_name) } + let_it_be_with_reload(:chat_name) { create(:chat_name) } subject { chat_name } diff --git a/spec/models/integrations/bamboo_spec.rb b/spec/models/integrations/bamboo_spec.rb index 3b459ab9d5b..50e131a0845 100644 --- a/spec/models/integrations/bamboo_spec.rb +++ b/spec/models/integrations/bamboo_spec.rb @@ -205,6 +205,12 @@ RSpec.describe Integrations::Bamboo, :use_clean_rails_memory_store_caching, feat end end + describe '#avatar_url' do + it 'returns the avatar image path' do + expect(subject.avatar_url).to eq(ActionController::Base.helpers.image_path('illustrations/third-party-logos/integrations-logos/atlassian-bamboo.svg')) + end + end + def stub_update_and_build_request(status: 200, body: nil) bamboo_full_url = 'http://gitlab.com/bamboo/updateAndBuild.action?buildKey=foo&os_authType=basic' diff --git a/spec/policies/project_policy_spec.rb b/spec/policies/project_policy_spec.rb index e7c2dcc4158..3de006d8c9b 100644 --- a/spec/policies/project_policy_spec.rb +++ b/spec/policies/project_policy_spec.rb @@ -288,7 +288,6 @@ RSpec.describe ProjectPolicy, feature_category: :system_access do :create_build, :read_build, :update_build, :admin_build, :destroy_build, :create_pipeline_schedule, :read_pipeline_schedule_variables, :update_pipeline_schedule, :admin_pipeline_schedule, :destroy_pipeline_schedule, :create_environment, :read_environment, :update_environment, :admin_environment, :destroy_environment, - :create_cluster, :read_cluster, :update_cluster, :admin_cluster, :destroy_cluster, :create_deployment, :read_deployment, :update_deployment, :admin_deployment, :destroy_deployment ] diff --git a/spec/requests/api/maven_packages_spec.rb b/spec/requests/api/maven_packages_spec.rb index 4e746802500..1f841eefff2 100644 --- a/spec/requests/api/maven_packages_spec.rb +++ b/spec/requests/api/maven_packages_spec.rb @@ -377,6 +377,20 @@ RSpec.describe API::MavenPackages, feature_category: :package_registry do end end + shared_examples 'rejecting request with invalid params' do + context 'with invalid maven path' do + subject { download_file(file_name: package_file.file_name, path: 'foo/bar/%0d%0ahttp:/%2fexample.com') } + + it_behaves_like 'returning response status with error', status: :bad_request, error: 'path should be a valid file path' + end + + context 'with invalid file name' do + subject { download_file(file_name: '%0d%0ahttp:/%2fexample.com') } + + it_behaves_like 'returning response status with error', status: :bad_request, error: 'file_name should be a valid file path' + end + end + describe 'GET /api/v4/packages/maven/*path/:file_name' do context 'a public project' do let(:snowplow_gitlab_standard_context) { { project: project, namespace: project.namespace, property: 'i_package_maven_user' } } @@ -403,6 +417,8 @@ RSpec.describe API::MavenPackages, feature_category: :package_registry do it_behaves_like 'returning response status', :forbidden end + it_behaves_like 'rejecting request with invalid params' + it 'returns not found when a package is not found' do finder = double('finder', execute: nil) expect(::Packages::Maven::PackageFinder).to receive(:new).and_return(finder) @@ -444,6 +460,8 @@ RSpec.describe API::MavenPackages, feature_category: :package_registry do end end + it_behaves_like 'rejecting request with invalid params' + it_behaves_like 'handling groups, subgroups and user namespaces for', 'getting a file', visibilities: { public: :redirect, internal: :not_found } end @@ -501,6 +519,8 @@ RSpec.describe API::MavenPackages, feature_category: :package_registry do end end + it_behaves_like 'rejecting request with invalid params' + it_behaves_like 'handling groups, subgroups and user namespaces for', 'getting a file', visibilities: { public: :redirect, internal: :not_found, private: :not_found } end @@ -566,6 +586,8 @@ RSpec.describe API::MavenPackages, feature_category: :package_registry do end end + it_behaves_like 'rejecting request with invalid params' + it_behaves_like 'handling groups and subgroups for', 'getting a file for a group' end @@ -597,6 +619,8 @@ RSpec.describe API::MavenPackages, feature_category: :package_registry do end end + it_behaves_like 'rejecting request with invalid params' + it_behaves_like 'handling groups and subgroups for', 'getting a file for a group', visibilities: { internal: :unauthorized, public: :redirect } end @@ -634,6 +658,8 @@ RSpec.describe API::MavenPackages, feature_category: :package_registry do it_behaves_like 'returning response status', :redirect end + it_behaves_like 'rejecting request with invalid params' + context 'with group deploy token' do subject { download_file_with_token(file_name: package_file.file_name, request_headers: group_deploy_token_headers) } @@ -786,6 +812,8 @@ RSpec.describe API::MavenPackages, feature_category: :package_registry do it_behaves_like 'returning response status', :redirect end + + it_behaves_like 'rejecting request with invalid params' end context 'private project' do @@ -830,6 +858,8 @@ RSpec.describe API::MavenPackages, feature_category: :package_registry do it_behaves_like 'returning response status', :redirect end + + it_behaves_like 'rejecting request with invalid params' end it_behaves_like 'forwarding package requests' diff --git a/spec/services/chat_names/find_user_service_spec.rb b/spec/services/chat_names/find_user_service_spec.rb index 14bece4efb4..94a56553983 100644 --- a/spec/services/chat_names/find_user_service_spec.rb +++ b/spec/services/chat_names/find_user_service_spec.rb @@ -8,7 +8,7 @@ RSpec.describe ChatNames::FindUserService, :clean_gitlab_redis_shared_state, fea context 'find user mapping' do let_it_be(:user) { create(:user) } - let_it_be(:chat_name) { create(:chat_name, user: user) } + let(:chat_name) { create(:chat_name, user: user) } let(:team_id) { chat_name.team_id } let(:user_id) { chat_name.chat_id } @@ -19,26 +19,20 @@ RSpec.describe ChatNames::FindUserService, :clean_gitlab_redis_shared_state, fea end it 'updates the last used timestamp if one is not already set' do - expect(chat_name.last_used_at).to be_nil - - subject - - expect(chat_name.reload.last_used_at).to be_present + expect { subject }.to change { chat_name.reload.last_used_at }.from(nil) end it 'only updates an existing timestamp once within a certain time frame' do - chat_name = create(:chat_name, user: user) - service = described_class.new(team_id, user_id) + expect { described_class.new(team_id, user_id).execute }.to change { chat_name.reload.last_used_at }.from(nil) + expect { described_class.new(team_id, user_id).execute }.not_to change { chat_name.reload.last_used_at } + end - expect(chat_name.last_used_at).to be_nil + it 'records activity for the related user' do + expect_next_instance_of(Users::ActivityService, author: user) do |activity_service| + expect(activity_service).to receive(:execute) + end - service.execute - - time = chat_name.reload.last_used_at - - service.execute - - expect(chat_name.reload.last_used_at).to eq(time) + subject end end diff --git a/spec/services/users/signup_service_spec.rb b/spec/services/users/signup_service_spec.rb deleted file mode 100644 index e35cb03fd1c..00000000000 --- a/spec/services/users/signup_service_spec.rb +++ /dev/null @@ -1,64 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Users::SignupService, feature_category: :system_access do - let_it_be(:user) { create(:user, setup_for_company: true) } - - describe '#execute' do - context 'when updating name' do - it 'updates the name attribute' do - result = update_user(user, name: 'New Name') - - expect(result.success?).to be(true) - expect(user.reload.name).to eq('New Name') - end - - it 'returns an error result when name is missing' do - result = update_user(user, name: '') - - expect(user.reload.name).not_to be_blank - expect(result.success?).to be(false) - expect(result.message).to include("Name can't be blank") - end - end - - context 'when updating role' do - it 'updates the role attribute' do - result = update_user(user, role: 'development_team_lead') - - expect(result.success?).to be(true) - expect(user.reload.role).to eq('development_team_lead') - end - - it 'returns an error result when role is missing' do - result = update_user(user, role: '') - - expect(user.reload.role).not_to be_blank - expect(result.success?).to be(false) - expect(result.message).to eq("Role can't be blank") - end - end - - context 'when updating setup_for_company' do - it 'updates the setup_for_company attribute' do - result = update_user(user, setup_for_company: 'false') - - expect(result.success?).to be(true) - expect(user.reload.setup_for_company).to be(false) - end - - it 'returns an error result when setup_for_company is missing' do - result = update_user(user, setup_for_company: '') - - expect(user.reload.setup_for_company).not_to be_blank - expect(result.success?).to be(false) - expect(result.message).to eq("Setup for company can't be blank") - end - end - - def update_user(user, opts) - described_class.new(user, opts).execute - end - end -end diff --git a/spec/workers/bulk_import_worker_spec.rb b/spec/workers/bulk_import_worker_spec.rb index c96e5ace124..092eb7c5264 100644 --- a/spec/workers/bulk_import_worker_spec.rb +++ b/spec/workers/bulk_import_worker_spec.rb @@ -32,6 +32,16 @@ RSpec.describe BulkImportWorker, feature_category: :importers do end end + context 'when bulk import is timeout' do + it 'does nothing' do + bulk_import = create(:bulk_import, :timeout) + + expect(described_class).not_to receive(:perform_in) + + subject.perform(bulk_import.id) + end + end + context 'when all entities are processed' do it 'marks bulk import as finished' do bulk_import = create(:bulk_import, :started)