Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/spec
diff options
context:
space:
mode:
Diffstat (limited to 'spec')
-rw-r--r--spec/commands/sidekiq_cluster/cli_spec.rb12
-rw-r--r--spec/components/diffs/overflow_warning_component_spec.rb184
-rw-r--r--spec/components/diffs/stats_component_spec.rb67
-rw-r--r--spec/components/pajamas/alert_component_spec.rb104
-rw-r--r--spec/controllers/concerns/import_url_params_spec.rb18
-rw-r--r--spec/controllers/explore/projects_controller_spec.rb7
-rw-r--r--spec/controllers/graphql_controller_spec.rb63
-rw-r--r--spec/controllers/groups/group_links_controller_spec.rb117
-rw-r--r--spec/controllers/groups/runners_controller_spec.rb14
-rw-r--r--spec/controllers/groups_controller_spec.rb34
-rw-r--r--spec/controllers/help_controller_spec.rb6
-rw-r--r--spec/controllers/import/bitbucket_controller_spec.rb150
-rw-r--r--spec/controllers/import/github_controller_spec.rb42
-rw-r--r--spec/controllers/jira_connect/events_controller_spec.rb11
-rw-r--r--spec/controllers/jira_connect/subscriptions_controller_spec.rb18
-rw-r--r--spec/controllers/oauth/jira_dvcs/authorizations_controller_spec.rb (renamed from spec/controllers/oauth/jira/authorizations_controller_spec.rb)6
-rw-r--r--spec/controllers/profiles/accounts_controller_spec.rb2
-rw-r--r--spec/controllers/profiles/keys_controller_spec.rb20
-rw-r--r--spec/controllers/profiles/preferences_controller_spec.rb24
-rw-r--r--spec/controllers/profiles/two_factor_auths_controller_spec.rb25
-rw-r--r--spec/controllers/projects/artifacts_controller_spec.rb3
-rw-r--r--spec/controllers/projects/branches_controller_spec.rb14
-rw-r--r--spec/controllers/projects/commit_controller_spec.rb46
-rw-r--r--spec/controllers/projects/compare_controller_spec.rb32
-rw-r--r--spec/controllers/projects/environments_controller_spec.rb28
-rw-r--r--spec/controllers/projects/group_links_controller_spec.rb130
-rw-r--r--spec/controllers/projects/issues_controller_spec.rb7
-rw-r--r--spec/controllers/projects/jobs_controller_spec.rb2
-rw-r--r--spec/controllers/projects/notes_controller_spec.rb16
-rw-r--r--spec/controllers/projects/packages/infrastructure_registry_controller_spec.rb12
-rw-r--r--spec/controllers/projects/pipelines/tests_controller_spec.rb68
-rw-r--r--spec/controllers/projects/services_controller_spec.rb5
-rw-r--r--spec/controllers/projects/static_site_editor_controller_spec.rb65
-rw-r--r--spec/controllers/projects/todos_controller_spec.rb2
-rw-r--r--spec/controllers/projects/usage_quotas_controller_spec.rb37
-rw-r--r--spec/controllers/projects_controller_spec.rb63
-rw-r--r--spec/controllers/search_controller_spec.rb1
-rw-r--r--spec/controllers/sessions_controller_spec.rb10
-rw-r--r--spec/controllers/uploads_controller_spec.rb18
-rw-r--r--spec/db/migration_spec.rb32
-rw-r--r--spec/db/schema_spec.rb35
-rw-r--r--spec/deprecation_toolkit_env.rb3
-rw-r--r--spec/events/ci/pipeline_created_event_spec.rb27
-rw-r--r--spec/experiments/ios_specific_templates_experiment_spec.rb62
-rw-r--r--spec/experiments/new_project_sast_enabled_experiment_spec.rb20
-rw-r--r--spec/experiments/video_tutorials_continuous_onboarding_experiment_spec.rb9
-rw-r--r--spec/factories/alert_management/metric_images.rb16
-rw-r--r--spec/factories/application_settings.rb1
-rw-r--r--spec/factories/ci/builds.rb72
-rw-r--r--spec/factories/ci/job_artifacts.rb50
-rw-r--r--spec/factories/custom_emoji.rb3
-rw-r--r--spec/factories/events.rb5
-rw-r--r--spec/factories/gitlab/database/background_migration/batched_migrations.rb20
-rw-r--r--spec/factories/go_module_versions.rb14
-rw-r--r--spec/factories/groups.rb9
-rw-r--r--spec/factories/integrations.rb2
-rw-r--r--spec/factories/issues.rb5
-rw-r--r--spec/factories/keys.rb8
-rw-r--r--spec/factories/merge_requests.rb3
-rw-r--r--spec/factories/project_statistics.rb1
-rw-r--r--spec/factories/projects.rb13
-rw-r--r--spec/factories/work_items/work_item_types.rb5
-rw-r--r--spec/fast_spec_helper.rb1
-rw-r--r--spec/features/admin/admin_broadcast_messages_spec.rb7
-rw-r--r--spec/features/admin/admin_dev_ops_reports_spec.rb (renamed from spec/features/admin/admin_dev_ops_report_spec.rb)10
-rw-r--r--spec/features/admin/admin_runners_spec.rb292
-rw-r--r--spec/features/admin/admin_sees_background_migrations_spec.rb10
-rw-r--r--spec/features/admin/admin_settings_spec.rb93
-rw-r--r--spec/features/admin/admin_users_impersonation_tokens_spec.rb4
-rw-r--r--spec/features/admin/clusters/eks_spec.rb4
-rw-r--r--spec/features/boards/boards_spec.rb2
-rw-r--r--spec/features/boards/focus_mode_spec.rb2
-rw-r--r--spec/features/boards/multi_select_spec.rb6
-rw-r--r--spec/features/clusters/create_agent_spec.rb4
-rw-r--r--spec/features/commit_spec.rb4
-rw-r--r--spec/features/commits_spec.rb3
-rw-r--r--spec/features/error_tracking/user_searches_sentry_errors_spec.rb2
-rw-r--r--spec/features/groups/clusters/eks_spec.rb4
-rw-r--r--spec/features/groups/clusters/user_spec.rb3
-rw-r--r--spec/features/groups/group_runners_spec.rb168
-rw-r--r--spec/features/groups/import_export/export_file_spec.rb16
-rw-r--r--spec/features/groups/members/manage_groups_spec.rb149
-rw-r--r--spec/features/groups/members/manage_members_spec.rb139
-rw-r--r--spec/features/groups/members/sort_members_spec.rb40
-rw-r--r--spec/features/groups/milestone_spec.rb2
-rw-r--r--spec/features/groups/milestones_sorting_spec.rb14
-rw-r--r--spec/features/groups/settings/ci_cd_spec.rb81
-rw-r--r--spec/features/issuables/shortcuts_issuable_spec.rb12
-rw-r--r--spec/features/issues/incident_issue_spec.rb32
-rw-r--r--spec/features/issues/user_creates_issue_spec.rb6
-rw-r--r--spec/features/issues/user_edits_issue_spec.rb6
-rw-r--r--spec/features/issues/user_sees_sidebar_updates_in_realtime_spec.rb37
-rw-r--r--spec/features/jira_connect/subscriptions_spec.rb2
-rw-r--r--spec/features/jira_oauth_provider_authorize_spec.rb6
-rw-r--r--spec/features/merge_request/user_merges_merge_request_spec.rb21
-rw-r--r--spec/features/merge_request/user_posts_notes_spec.rb2
-rw-r--r--spec/features/merge_request/user_sees_merge_widget_spec.rb3
-rw-r--r--spec/features/merge_request/user_suggests_changes_on_diff_spec.rb37
-rw-r--r--spec/features/merge_requests/user_mass_updates_spec.rb2
-rw-r--r--spec/features/milestones/user_deletes_milestone_spec.rb2
-rw-r--r--spec/features/oauth_login_spec.rb2
-rw-r--r--spec/features/profiles/personal_access_tokens_spec.rb4
-rw-r--r--spec/features/profiles/user_edit_profile_spec.rb25
-rw-r--r--spec/features/profiles/user_visits_profile_preferences_page_spec.rb8
-rw-r--r--spec/features/projects/blobs/balsamiq_spec.rb17
-rw-r--r--spec/features/projects/blobs/blob_line_permalink_updater_spec.rb8
-rw-r--r--spec/features/projects/branches_spec.rb10
-rw-r--r--spec/features/projects/cluster_agents_spec.rb1
-rw-r--r--spec/features/projects/clusters/eks_spec.rb2
-rw-r--r--spec/features/projects/clusters/gcp_spec.rb5
-rw-r--r--spec/features/projects/clusters/user_spec.rb5
-rw-r--r--spec/features/projects/clusters_spec.rb5
-rw-r--r--spec/features/projects/commits/multi_view_diff_spec.rb79
-rw-r--r--spec/features/projects/environments/environments_spec.rb6
-rw-r--r--spec/features/projects/import_export/import_file_spec.rb6
-rw-r--r--spec/features/projects/issues/design_management/user_uploads_designs_spec.rb4
-rw-r--r--spec/features/projects/jobs/user_browses_jobs_spec.rb11
-rw-r--r--spec/features/projects/members/groups_with_access_list_spec.rb5
-rw-r--r--spec/features/projects/members/invite_group_spec.rb116
-rw-r--r--spec/features/projects/members/manage_members_spec.rb (renamed from spec/features/projects/members/list_spec.rb)65
-rw-r--r--spec/features/projects/members/sorting_spec.rb40
-rw-r--r--spec/features/projects/milestones/milestones_sorting_spec.rb62
-rw-r--r--spec/features/projects/new_project_spec.rb11
-rw-r--r--spec/features/projects/pipelines/pipeline_spec.rb13
-rw-r--r--spec/features/projects/pipelines/pipelines_spec.rb1
-rw-r--r--spec/features/projects/releases/user_views_releases_spec.rb154
-rw-r--r--spec/features/projects/terraform_spec.rb2
-rw-r--r--spec/features/projects/user_creates_project_spec.rb28
-rw-r--r--spec/features/projects/user_sorts_projects_spec.rb28
-rw-r--r--spec/features/projects_spec.rb39
-rw-r--r--spec/features/refactor_blob_viewer_disabled/projects/blobs/balsamiq_spec.rb18
-rw-r--r--spec/features/runners_spec.rb1
-rw-r--r--spec/features/search/user_searches_for_projects_spec.rb2
-rw-r--r--spec/features/search/user_uses_header_search_field_spec.rb38
-rw-r--r--spec/features/static_site_editor_spec.rb113
-rw-r--r--spec/features/task_lists_spec.rb16
-rw-r--r--spec/features/users/login_spec.rb11
-rw-r--r--spec/finders/bulk_imports/entities_finder_spec.rb32
-rw-r--r--spec/finders/bulk_imports/imports_finder_spec.rb24
-rw-r--r--spec/finders/ci/jobs_finder_spec.rb22
-rw-r--r--spec/finders/concerns/finder_methods_spec.rb51
-rw-r--r--spec/finders/concerns/finder_with_cross_project_access_spec.rb4
-rw-r--r--spec/finders/keys_finder_spec.rb55
-rw-r--r--spec/finders/packages/build_infos_for_many_packages_finder_spec.rb136
-rw-r--r--spec/finders/packages/group_packages_finder_spec.rb16
-rw-r--r--spec/finders/packages/packages_finder_spec.rb16
-rw-r--r--spec/finders/releases/group_releases_finder_spec.rb15
-rw-r--r--spec/finders/user_recent_events_finder_spec.rb356
-rw-r--r--spec/finders/users_finder_spec.rb50
-rw-r--r--spec/fixtures/api/schemas/entities/member_user.json15
-rw-r--r--spec/fixtures/api/schemas/group_link/group_group_link.json15
-rw-r--r--spec/fixtures/api/schemas/group_link/group_link.json10
-rw-r--r--spec/fixtures/api/schemas/group_link/project_group_link.json14
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/agent.json18
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/agents.json4
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/issue.json1
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/issue_links.json9
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/project_identity.json22
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/related_issues.json26
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/release/release_for_guest.json1
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/resource_access_token.json31
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/resource_access_tokens.json4
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/user/admin.json3
-rw-r--r--spec/fixtures/avatars/avatar1.pngbin0 -> 1461 bytes
-rw-r--r--spec/fixtures/avatars/avatar2.pngbin0 -> 1665 bytes
-rw-r--r--spec/fixtures/avatars/avatar3.pngbin0 -> 1767 bytes
-rw-r--r--spec/fixtures/avatars/avatar4.pngbin0 -> 1624 bytes
-rw-r--r--spec/fixtures/avatars/avatar5.pngbin0 -> 1700 bytes
-rw-r--r--spec/fixtures/emails/service_desk_reply_to_and_from.eml28
-rw-r--r--spec/fixtures/markdown/markdown_golden_master_examples.yml28
-rw-r--r--spec/fixtures/security_reports/master/gl-sast-report-bandit.json43
-rw-r--r--spec/fixtures/security_reports/master/gl-sast-report-gosec.json68
-rw-r--r--spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-bandit.json71
-rw-r--r--spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-gosec.json70
-rw-r--r--spec/frontend/__helpers__/matchers/index.js1
-rw-r--r--spec/frontend/__helpers__/matchers/to_validate_json_schema.js34
-rw-r--r--spec/frontend/__helpers__/matchers/to_validate_json_schema_spec.js65
-rw-r--r--spec/frontend/__helpers__/mock_apollo_helper.js2
-rw-r--r--spec/frontend/__helpers__/mock_dom_observer.js4
-rw-r--r--spec/frontend/__helpers__/vuex_action_helper.js1
-rw-r--r--spec/frontend/__helpers__/yaml_transformer.js11
-rw-r--r--spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap2
-rw-r--r--spec/frontend/add_context_commits_modal/store/actions_spec.js67
-rw-r--r--spec/frontend/admin/statistics_panel/store/actions_spec.js37
-rw-r--r--spec/frontend/admin/topics/components/remove_avatar_spec.js11
-rw-r--r--spec/frontend/admin/users/components/actions/actions_spec.js42
-rw-r--r--spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap174
-rw-r--r--spec/frontend/admin/users/components/modals/delete_user_modal_spec.js104
-rw-r--r--spec/frontend/admin/users/components/modals/user_modal_manager_spec.js126
-rw-r--r--spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js3
-rw-r--r--spec/frontend/alerts_settings/components/mocks/apollo_mock.js2
-rw-r--r--spec/frontend/api/alert_management_alerts_api_spec.js140
-rw-r--r--spec/frontend/api_spec.js600
-rw-r--r--spec/frontend/authentication/u2f/authenticate_spec.js15
-rw-r--r--spec/frontend/authentication/u2f/register_spec.js4
-rw-r--r--spec/frontend/badges/components/badge_spec.js6
-rw-r--r--spec/frontend/badges/store/actions_spec.js260
-rw-r--r--spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js98
-rw-r--r--spec/frontend/behaviors/gl_emoji_spec.js6
-rw-r--r--spec/frontend/blob/balsamiq/balsamiq_viewer_spec.js363
-rw-r--r--spec/frontend/boards/boards_util_spec.js30
-rw-r--r--spec/frontend/boards/components/board_filtered_search_spec.js4
-rw-r--r--spec/frontend/boards/components/board_form_spec.js2
-rw-r--r--spec/frontend/boards/components/board_top_bar_spec.js88
-rw-r--r--spec/frontend/boards/components/boards_selector_spec.js14
-rw-r--r--spec/frontend/boards/components/issuable_title_spec.js33
-rw-r--r--spec/frontend/boards/components/issue_board_filtered_search_spec.js3
-rw-r--r--spec/frontend/boards/components/issue_time_estimate_spec.js8
-rw-r--r--spec/frontend/boards/components/item_count_spec.js6
-rw-r--r--spec/frontend/boards/stores/actions_spec.js70
-rw-r--r--spec/frontend/captcha/apollo_captcha_link_spec.js94
-rw-r--r--spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js2
-rw-r--r--spec/frontend/ci_variable_list/store/actions_spec.js92
-rw-r--r--spec/frontend/clusters_list/components/agent_empty_state_spec.js20
-rw-r--r--spec/frontend/clusters_list/components/agent_table_spec.js2
-rw-r--r--spec/frontend/clusters_list/components/agent_token_spec.js8
-rw-r--r--spec/frontend/clusters_list/components/agents_spec.js2
-rw-r--r--spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js21
-rw-r--r--spec/frontend/clusters_list/components/clusters_actions_spec.js107
-rw-r--r--spec/frontend/clusters_list/components/clusters_empty_state_spec.js45
-rw-r--r--spec/frontend/clusters_list/components/clusters_view_all_spec.js93
-rw-r--r--spec/frontend/clusters_list/mocks/apollo.js1
-rw-r--r--spec/frontend/clusters_list/store/actions_spec.js101
-rw-r--r--spec/frontend/code_navigation/components/app_spec.js7
-rw-r--r--spec/frontend/code_navigation/store/actions_spec.js64
-rw-r--r--spec/frontend/code_navigation/store/mutations_spec.js2
-rw-r--r--spec/frontend/code_navigation/utils/index_spec.js30
-rw-r--r--spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js57
-rw-r--r--spec/frontend/commit/components/commit_box_pipeline_status_spec.js150
-rw-r--r--spec/frontend/commit/mock_data.js46
-rw-r--r--spec/frontend/commit/pipelines/pipelines_table_spec.js16
-rw-r--r--spec/frontend/commit/pipelines/utils_spec.js59
-rw-r--r--spec/frontend/commits_spec.js28
-rw-r--r--spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap8
-rw-r--r--spec/frontend/content_editor/components/code_block_bubble_menu_spec.js142
-rw-r--r--spec/frontend/content_editor/components/formatting_bubble_menu_spec.js2
-rw-r--r--spec/frontend/content_editor/components/wrappers/media_spec.js (renamed from spec/frontend/content_editor/components/wrappers/image_spec.js)21
-rw-r--r--spec/frontend/content_editor/extensions/attachment_spec.js79
-rw-r--r--spec/frontend/content_editor/extensions/code_block_highlight_spec.js74
-rw-r--r--spec/frontend/content_editor/extensions/frontmatter_spec.js2
-rw-r--r--spec/frontend/content_editor/services/code_block_language_loader_spec.js120
-rw-r--r--spec/frontend/content_editor/services/content_editor_spec.js24
-rw-r--r--spec/frontend/contributors/store/actions_spec.js26
-rw-r--r--spec/frontend/create_cluster/gke_cluster/stores/actions_spec.js25
-rw-r--r--spec/frontend/crm/contact_form_spec.js157
-rw-r--r--spec/frontend/crm/contact_form_wrapper_spec.js88
-rw-r--r--spec/frontend/crm/contacts_root_spec.js77
-rw-r--r--spec/frontend/crm/form_spec.js51
-rw-r--r--spec/frontend/crm/mock_data.js25
-rw-r--r--spec/frontend/crm/new_organization_form_spec.js109
-rw-r--r--spec/frontend/crm/organization_form_wrapper_spec.js88
-rw-r--r--spec/frontend/crm/organizations_root_spec.js51
-rw-r--r--spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap28
-rw-r--r--spec/frontend/design_management/components/design_notes/design_discussion_spec.js7
-rw-r--r--spec/frontend/design_management/components/design_notes/design_note_spec.js37
-rw-r--r--spec/frontend/design_management/pages/index_spec.js2
-rw-r--r--spec/frontend/diffs/components/commit_item_spec.js9
-rw-r--r--spec/frontend/diffs/components/diff_line_note_form_spec.js19
-rw-r--r--spec/frontend/diffs/store/actions_spec.js439
-rw-r--r--spec/frontend/diffs/store/utils_spec.js2
-rw-r--r--spec/frontend/editor/components/helpers.js12
-rw-r--r--spec/frontend/editor/components/source_editor_toolbar_button_spec.js146
-rw-r--r--spec/frontend/editor/components/source_editor_toolbar_spec.js116
-rw-r--r--spec/frontend/editor/schema/ci/ci_schema_spec.js90
-rw-r--r--spec/frontend/editor/schema/ci/json_tests/negative_tests/default_no_additional_properties.json12
-rw-r--r--spec/frontend/editor/schema/ci/json_tests/negative_tests/inherit_default_no_additional_properties.json8
-rw-r--r--spec/frontend/editor/schema/ci/json_tests/negative_tests/job_variables_must_not_contain_objects.json12
-rw-r--r--spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_empty.json13
-rw-r--r--spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_invalid_link_type.json24
-rw-r--r--spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_missing.json11
-rw-r--r--spec/frontend/editor/schema/ci/json_tests/negative_tests/retry_unknown_when.json9
-rw-r--r--spec/frontend/editor/schema/ci/json_tests/positive_tests/allow_failure.json19
-rw-r--r--spec/frontend/editor/schema/ci/json_tests/positive_tests/environment.json75
-rw-r--r--spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci-dependencies.json68
-rw-r--r--spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci.json350
-rw-r--r--spec/frontend/editor/schema/ci/json_tests/positive_tests/inherit.json54
-rw-r--r--spec/frontend/editor/schema/ci/json_tests/positive_tests/multiple-caches.json24
-rw-r--r--spec/frontend/editor/schema/ci/json_tests/positive_tests/retry.json60
-rw-r--r--spec/frontend/editor/schema/ci/json_tests/positive_tests/terraform_report.json50
-rw-r--r--spec/frontend/editor/schema/ci/json_tests/positive_tests/variables.json22
-rw-r--r--spec/frontend/editor/schema/ci/json_tests/positive_tests/variables_mix_string_and_user_input.json10
-rw-r--r--spec/frontend/editor/schema/ci/yaml_tests/negative_tests/cache.yml15
-rw-r--r--spec/frontend/editor/schema/ci/yaml_tests/negative_tests/include.yml17
-rw-r--r--spec/frontend/editor/schema/ci/yaml_tests/positive_tests/cache.yml25
-rw-r--r--spec/frontend/editor/schema/ci/yaml_tests/positive_tests/filter.yml18
-rw-r--r--spec/frontend/editor/schema/ci/yaml_tests/positive_tests/include.yml32
-rw-r--r--spec/frontend/editor/schema/ci/yaml_tests/positive_tests/rules.yml13
-rw-r--r--spec/frontend/environments/deploy_board_component_spec.js16
-rw-r--r--spec/frontend/environments/empty_state_spec.js53
-rw-r--r--spec/frontend/environments/emtpy_state_spec.js24
-rw-r--r--spec/frontend/environments/environment_item_spec.js53
-rw-r--r--spec/frontend/environments/environment_table_spec.js5
-rw-r--r--spec/frontend/environments/graphql/mock_data.js2
-rw-r--r--spec/frontend/environments/new_environment_item_spec.js28
-rw-r--r--spec/frontend/error_tracking/store/actions_spec.js19
-rw-r--r--spec/frontend/error_tracking/store/details/actions_spec.js26
-rw-r--r--spec/frontend/error_tracking/store/list/actions_spec.js16
-rw-r--r--spec/frontend/error_tracking_settings/store/actions_spec.js72
-rw-r--r--spec/frontend/feature_flags/store/edit/actions_spec.js55
-rw-r--r--spec/frontend/feature_flags/store/index/actions_spec.js86
-rw-r--r--spec/frontend/feature_flags/store/new/actions_spec.js25
-rw-r--r--spec/frontend/filtered_search/droplab/plugins/ajax_filter_spec.js22
-rw-r--r--spec/frontend/filtered_search/filtered_search_manager_spec.js18
-rw-r--r--spec/frontend/filtered_search/services/recent_searches_service_spec.js58
-rw-r--r--spec/frontend/filtered_search/visual_token_value_spec.js91
-rw-r--r--spec/frontend/fixtures/startup_css.rb8
-rw-r--r--spec/frontend/frequent_items/store/actions_spec.js70
-rw-r--r--spec/frontend/google_cloud/components/app_spec.js2
-rw-r--r--spec/frontend/gpg_badges_spec.js52
-rw-r--r--spec/frontend/groups/components/item_type_icon_spec.js25
-rw-r--r--spec/frontend/header_search/components/app_spec.js14
-rw-r--r--spec/frontend/header_search/components/header_search_autocomplete_items_spec.js98
-rw-r--r--spec/frontend/header_search/components/header_search_scoped_items_spec.js31
-rw-r--r--spec/frontend/header_search/mock_data.js131
-rw-r--r--spec/frontend/header_spec.js10
-rw-r--r--spec/frontend/ide/components/commit_sidebar/form_spec.js2
-rw-r--r--spec/frontend/ide/components/ide_side_bar_spec.js2
-rw-r--r--spec/frontend/ide/components/new_dropdown/upload_spec.js22
-rw-r--r--spec/frontend/ide/stores/actions/merge_request_spec.js387
-rw-r--r--spec/frontend/ide/stores/actions/project_spec.js172
-rw-r--r--spec/frontend/ide/stores/actions/tree_spec.js92
-rw-r--r--spec/frontend/ide/stores/actions_spec.js537
-rw-r--r--spec/frontend/ide/stores/modules/branches/actions_spec.js30
-rw-r--r--spec/frontend/ide/stores/modules/clientside/actions_spec.js8
-rw-r--r--spec/frontend/ide/stores/modules/commit/actions_spec.js384
-rw-r--r--spec/frontend/ide/stores/modules/file_templates/actions_spec.js62
-rw-r--r--spec/frontend/ide/stores/modules/merge_requests/actions_spec.js35
-rw-r--r--spec/frontend/ide/stores/modules/pane/actions_spec.js27
-rw-r--r--spec/frontend/ide/stores/modules/pipelines/actions_spec.js152
-rw-r--r--spec/frontend/ide/stores/modules/terminal_sync/actions_spec.js31
-rw-r--r--spec/frontend/ide/stores/plugins/terminal_spec.js16
-rw-r--r--spec/frontend/image_diff/init_discussion_tab_spec.js6
-rw-r--r--spec/frontend/image_diff/replaced_image_diff_spec.js64
-rw-r--r--spec/frontend/import_entities/components/import_status_spec.js145
-rw-r--r--spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js2
-rw-r--r--spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js7
-rw-r--r--spec/frontend/import_entities/import_projects/store/mutations_spec.js29
-rw-r--r--spec/frontend/incidents/components/incidents_list_spec.js30
-rw-r--r--spec/frontend/integrations/edit/components/integration_form_spec.js63
-rw-r--r--spec/frontend/integrations/edit/components/jira_issues_fields_spec.js44
-rw-r--r--spec/frontend/invite_members/components/group_select_spec.js5
-rw-r--r--spec/frontend/invite_members/components/invite_groups_modal_spec.js13
-rw-r--r--spec/frontend/invite_members/components/invite_members_modal_spec.js131
-rw-r--r--spec/frontend/invite_members/components/invite_modal_base_spec.js24
-rw-r--r--spec/frontend/invite_members/components/members_token_select_spec.js4
-rw-r--r--spec/frontend/invite_members/components/user_limit_notification_spec.js71
-rw-r--r--spec/frontend/invite_members/mock_data/api_responses.js62
-rw-r--r--spec/frontend/invite_members/mock_data/group_modal.js1
-rw-r--r--spec/frontend/invite_members/mock_data/member_modal.js1
-rw-r--r--spec/frontend/invite_members/utils/response_message_parser_spec.js28
-rw-r--r--spec/frontend/issuable/issuable_form_spec.js44
-rw-r--r--spec/frontend/issues/create_merge_request_dropdown_spec.js19
-rw-r--r--spec/frontend/issues/list/components/issue_card_time_info_spec.js13
-rw-r--r--spec/frontend/issues/list/components/issues_list_app_spec.js27
-rw-r--r--spec/frontend/issues/list/mock_data.js2
-rw-r--r--spec/frontend/issues/related_merge_requests/store/actions_spec.js40
-rw-r--r--spec/frontend/issues/show/components/app_spec.js84
-rw-r--r--spec/frontend/issues/show/components/description_spec.js170
-rw-r--r--spec/frontend/issues/show/components/fields/description_spec.js5
-rw-r--r--spec/frontend/issues/show/components/fields/description_template_spec.js101
-rw-r--r--spec/frontend/issues/show/components/fields/title_spec.js4
-rw-r--r--spec/frontend/issues/show/components/incidents/incident_tabs_spec.js18
-rw-r--r--spec/frontend/issues/show/mock_data/mock_data.js15
-rw-r--r--spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item_spec.js2
-rw-r--r--spec/frontend/jira_connect/subscriptions/components/app_spec.js28
-rw-r--r--spec/frontend/jira_connect/subscriptions/components/browser_support_alert_spec.js37
-rw-r--r--spec/frontend/jira_connect/subscriptions/components/compatibility_alert_spec.js2
-rw-r--r--spec/frontend/jira_connect/subscriptions/pages/sign_in_spec.js2
-rw-r--r--spec/frontend/jira_import/components/jira_import_form_spec.js21
-rw-r--r--spec/frontend/jobs/components/filtered_search/jobs_filtered_search_spec.js49
-rw-r--r--spec/frontend/jobs/components/filtered_search/tokens/job_status_token_spec.js57
-rw-r--r--spec/frontend/jobs/components/job_app_spec.js23
-rw-r--r--spec/frontend/jobs/components/table/graphql/cache_config_spec.js20
-rw-r--r--spec/frontend/jobs/components/table/job_table_app_spec.js105
-rw-r--r--spec/frontend/jobs/components/table/jobs_table_tabs_spec.js46
-rw-r--r--spec/frontend/jobs/components/trigger_block_spec.js7
-rw-r--r--spec/frontend/jobs/mock_data.js16
-rw-r--r--spec/frontend/jobs/store/actions_spec.js138
-rw-r--r--spec/frontend/labels/components/promote_label_modal_spec.js38
-rw-r--r--spec/frontend/lib/apollo/suppress_network_errors_during_navigation_link_spec.js39
-rw-r--r--spec/frontend/lib/gfm/index_spec.js46
-rw-r--r--spec/frontend/lib/utils/apollo_startup_js_link_spec.js51
-rw-r--r--spec/frontend/lib/utils/common_utils_spec.js69
-rw-r--r--spec/frontend/lib/utils/confirm_via_gl_modal/confirm_modal_spec.js26
-rw-r--r--spec/frontend/lib/utils/datetime/date_calculation_utility_spec.js17
-rw-r--r--spec/frontend/lib/utils/datetime/date_format_utility_spec.js12
-rw-r--r--spec/frontend/lib/utils/datetime/timeago_utility_spec.js50
-rw-r--r--spec/frontend/lib/utils/poll_spec.js60
-rw-r--r--spec/frontend/lib/utils/text_markdown_spec.js17
-rw-r--r--spec/frontend/lib/utils/unit_format/formatter_factory_spec.js50
-rw-r--r--spec/frontend/lib/utils/unit_format/index_spec.js15
-rw-r--r--spec/frontend/lib/utils/users_cache_spec.js108
-rw-r--r--spec/frontend/members/components/table/members_table_spec.js19
-rw-r--r--spec/frontend/members/mock_data.js3
-rw-r--r--spec/frontend/merge_conflicts/store/actions_spec.js85
-rw-r--r--spec/frontend/milestones/components/delete_milestone_modal_spec.js37
-rw-r--r--spec/frontend/milestones/components/milestone_combobox_spec.js22
-rw-r--r--spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap8
-rw-r--r--spec/frontend/monitoring/components/__snapshots__/empty_state_spec.js.snap3
-rw-r--r--spec/frontend/monitoring/components/__snapshots__/group_empty_state_spec.js.snap7
-rw-r--r--spec/frontend/monitoring/components/dashboard_actions_menu_spec.js4
-rw-r--r--spec/frontend/monitoring/components/dashboard_url_time_spec.js2
-rw-r--r--spec/frontend/monitoring/store/actions_spec.js335
-rw-r--r--spec/frontend/monitoring/store/utils_spec.js4
-rw-r--r--spec/frontend/mr_notes/stores/actions_spec.js88
-rw-r--r--spec/frontend/notes/components/diff_discussion_header_spec.js2
-rw-r--r--spec/frontend/notes/components/note_actions_spec.js3
-rw-r--r--spec/frontend/notes/components/note_form_spec.js9
-rw-r--r--spec/frontend/notes/components/note_header_spec.js28
-rw-r--r--spec/frontend/notes/components/noteable_discussion_spec.js1
-rw-r--r--spec/frontend/notes/components/noteable_note_spec.js2
-rw-r--r--spec/frontend/notes/components/notes_app_spec.js8
-rw-r--r--spec/frontend/notes/components/sort_discussion_spec.js4
-rw-r--r--spec/frontend/notes/deprecated_notes_spec.js11
-rw-r--r--spec/frontend/notes/stores/actions_spec.js407
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/delete_button_spec.js67
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js10
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js10
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js2
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js31
-rw-r--r--spec/frontend/packages_and_registries/dependency_proxy/app_spec.js70
-rw-r--r--spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_header_spec.js88
-rw-r--r--spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_row_spec.js99
-rw-r--r--spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_spec.js39
-rw-r--r--spec/frontend/packages_and_registries/harbor_registry/mock_data.js175
-rw-r--r--spec/frontend/packages_and_registries/harbor_registry/pages/index_spec.js24
-rw-r--r--spec/frontend/packages_and_registries/harbor_registry/pages/list_spec.js140
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/actions_spec.js108
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap2
-rw-r--r--spec/frontend/packages_and_registries/infrastructure_registry/components/list/stores/actions_spec.js129
-rw-r--r--spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js1
-rw-r--r--spec/frontend/packages_and_registries/package_registry/pages/__snapshots__/list_spec.js.snap2
-rw-r--r--spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js24
-rw-r--r--spec/frontend/packages_and_registries/shared/components/__snapshots__/cleanup_policy_enabled_alert_spec.js.snap19
-rw-r--r--spec/frontend/packages_and_registries/shared/components/__snapshots__/registry_breadcrumb_spec.js.snap10
-rw-r--r--spec/frontend/packages_and_registries/shared/components/cleanup_policy_enabled_alert_spec.js49
-rw-r--r--spec/frontend/packages_and_registries/shared/components/registry_breadcrumb_spec.js8
-rw-r--r--spec/frontend/pager_spec.js57
-rw-r--r--spec/frontend/pages/admin/application_settings/account_and_limits_spec.js3
-rw-r--r--spec/frontend/pages/admin/jobs/index/components/stop_jobs_modal_spec.js22
-rw-r--r--spec/frontend/pages/dashboard/todos/index/todos_spec.js8
-rw-r--r--spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js10
-rw-r--r--spec/frontend/pages/import/history/components/import_error_details_spec.js66
-rw-r--r--spec/frontend/pages/import/history/components/import_history_app_spec.js205
-rw-r--r--spec/frontend/pages/profiles/show/emoji_menu_spec.js16
-rw-r--r--spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_section_card_spec.js.snap37
-rw-r--r--spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap452
-rw-r--r--spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js53
-rw-r--r--spec/frontend/pages/projects/shared/permissions/components/project_feature_settings_spec.js30
-rw-r--r--spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js63
-rw-r--r--spec/frontend/pages/shared/wikis/components/wiki_content_spec.js97
-rw-r--r--spec/frontend/pages/shared/wikis/components/wiki_form_spec.js192
-rw-r--r--spec/frontend/pdf/page_spec.js16
-rw-r--r--spec/frontend/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js137
-rw-r--r--spec/frontend/pipeline_editor/components/editor/ci_editor_header_spec.js53
-rw-r--r--spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js1
-rw-r--r--spec/frontend/pipeline_editor/pipeline_editor_home_spec.js99
-rw-r--r--spec/frontend/pipeline_wizard/components/wrapper_spec.js84
-rw-r--r--spec/frontend/pipeline_wizard/mock/yaml.js11
-rw-r--r--spec/frontend/pipelines/components/pipeline_tabs_spec.js61
-rw-r--r--spec/frontend/pipelines/components/pipelines_filtered_search_spec.js2
-rw-r--r--spec/frontend/pipelines/empty_state/ci_templates_spec.js85
-rw-r--r--spec/frontend/pipelines/empty_state/pipelines_ci_templates_spec.js (renamed from spec/frontend/pipelines/pipelines_ci_templates_spec.js)72
-rw-r--r--spec/frontend/pipelines/empty_state_spec.js2
-rw-r--r--spec/frontend/pipelines/graph/action_component_spec.js11
-rw-r--r--spec/frontend/pipelines/graph/graph_component_wrapper_spec.js6
-rw-r--r--spec/frontend/pipelines/pipeline_triggerer_spec.js81
-rw-r--r--spec/frontend/pipelines/pipeline_url_spec.js25
-rw-r--r--spec/frontend/pipelines/pipelines_spec.js2
-rw-r--r--spec/frontend/pipelines/test_reports/stores/actions_spec.js51
-rw-r--r--spec/frontend/profile/add_ssh_key_validation_spec.js2
-rw-r--r--spec/frontend/profile/preferences/components/__snapshots__/diffs_colors_preview_spec.js.snap915
-rw-r--r--spec/frontend/profile/preferences/components/diffs_colors_preview_spec.js23
-rw-r--r--spec/frontend/profile/preferences/components/diffs_colors_spec.js153
-rw-r--r--spec/frontend/profile/preferences/components/integration_view_spec.js33
-rw-r--r--spec/frontend/projects/commit/store/actions_spec.js16
-rw-r--r--spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap2
-rw-r--r--spec/frontend/projects/new/components/deployment_target_select_spec.js39
-rw-r--r--spec/frontend/projects/new/components/new_project_url_select_spec.js37
-rw-r--r--spec/frontend/releases/components/app_index_apollo_client_spec.js398
-rw-r--r--spec/frontend/releases/components/app_index_spec.js483
-rw-r--r--spec/frontend/releases/components/app_show_spec.js6
-rw-r--r--spec/frontend/releases/components/releases_pagination_apollo_client_spec.js126
-rw-r--r--spec/frontend/releases/components/releases_pagination_spec.js180
-rw-r--r--spec/frontend/releases/components/releases_sort_apollo_client_spec.js103
-rw-r--r--spec/frontend/releases/components/releases_sort_spec.js122
-rw-r--r--spec/frontend/releases/stores/modules/list/actions_spec.js197
-rw-r--r--spec/frontend/releases/stores/modules/list/helpers.js5
-rw-r--r--spec/frontend/releases/stores/modules/list/mutations_spec.js81
-rw-r--r--spec/frontend/reports/accessibility_report/store/actions_spec.js30
-rw-r--r--spec/frontend/reports/codequality_report/components/codequality_issue_body_spec.js1
-rw-r--r--spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js4
-rw-r--r--spec/frontend/reports/codequality_report/store/actions_spec.js44
-rw-r--r--spec/frontend/reports/components/report_section_spec.js31
-rw-r--r--spec/frontend/reports/components/summary_row_spec.js34
-rw-r--r--spec/frontend/reports/grouped_test_report/store/actions_spec.js44
-rw-r--r--spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap111
-rw-r--r--spec/frontend/repository/components/blob_content_viewer_spec.js19
-rw-r--r--spec/frontend/repository/components/breadcrumbs_spec.js45
-rw-r--r--spec/frontend/repository/components/last_commit_spec.js23
-rw-r--r--spec/frontend/runner/admin_runners/admin_runners_app_spec.js111
-rw-r--r--spec/frontend/runner/components/__snapshots__/runner_status_popover_spec.js.snap3
-rw-r--r--spec/frontend/runner/components/cells/runner_actions_cell_spec.js22
-rw-r--r--spec/frontend/runner/components/cells/runner_summary_cell_spec.js6
-rw-r--r--spec/frontend/runner/components/registration/registration_dropdown_spec.js17
-rw-r--r--spec/frontend/runner/components/registration/registration_token_spec.js75
-rw-r--r--spec/frontend/runner/components/runner_assigned_item_spec.js3
-rw-r--r--spec/frontend/runner/components/runner_bulk_delete_spec.js103
-rw-r--r--spec/frontend/runner/components/runner_delete_button_spec.js64
-rw-r--r--spec/frontend/runner/components/runner_filtered_search_bar_spec.js6
-rw-r--r--spec/frontend/runner/components/runner_jobs_spec.js2
-rw-r--r--spec/frontend/runner/components/runner_list_spec.js65
-rw-r--r--spec/frontend/runner/components/runner_pause_button_spec.js4
-rw-r--r--spec/frontend/runner/components/runner_projects_spec.js2
-rw-r--r--spec/frontend/runner/components/runner_status_badge_spec.js25
-rw-r--r--spec/frontend/runner/components/runner_status_popover_spec.js36
-rw-r--r--spec/frontend/runner/graphql/local_state_spec.js72
-rw-r--r--spec/frontend/runner/group_runners/group_runners_app_spec.js56
-rw-r--r--spec/frontend/runner/mock_data.js4
-rw-r--r--spec/frontend/runner/runner_search_utils_spec.js40
-rw-r--r--spec/frontend/runner/utils_spec.js4
-rw-r--r--spec/frontend/search/store/actions_spec.js20
-rw-r--r--spec/frontend/search_autocomplete_spec.js32
-rw-r--r--spec/frontend/search_settings/components/search_settings_spec.js41
-rw-r--r--spec/frontend/security_configuration/components/app_spec.js14
-rw-r--r--spec/frontend/security_configuration/components/feature_card_badge_spec.js40
-rw-r--r--spec/frontend/security_configuration/components/feature_card_spec.js27
-rw-r--r--spec/frontend/security_configuration/components/training_provider_list_spec.js16
-rw-r--r--spec/frontend/self_monitor/store/actions_spec.js50
-rw-r--r--spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap2
-rw-r--r--spec/frontend/serverless/store/actions_spec.js46
-rw-r--r--spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js44
-rw-r--r--spec/frontend/shortcuts_spec.js2
-rw-r--r--spec/frontend/sidebar/assignees_realtime_spec.js23
-rw-r--r--spec/frontend/sidebar/components/incidents/escalation_status_spec.js34
-rw-r--r--spec/frontend/sidebar/mock_data.js22
-rw-r--r--spec/frontend/sidebar/participants_spec.js5
-rw-r--r--spec/frontend/snippets/components/edit_spec.js4
-rw-r--r--spec/frontend/snippets/components/snippet_header_spec.js2
-rw-r--r--spec/frontend/task_list_spec.js32
-rw-r--r--spec/frontend/terraform/components/empty_state_spec.js13
-rw-r--r--spec/frontend/terraform/components/mock_data.js35
-rw-r--r--spec/frontend/terraform/components/states_table_actions_spec.js3
-rw-r--r--spec/frontend/tracking/tracking_spec.js66
-rw-r--r--spec/frontend/user_lists/components/edit_user_list_spec.js2
-rw-r--r--spec/frontend/user_lists/components/new_user_list_spec.js2
-rw-r--r--spec/frontend/user_lists/components/user_list_form_spec.js2
-rw-r--r--spec/frontend/user_lists/components/user_list_spec.js2
-rw-r--r--spec/frontend/user_lists/components/user_lists_spec.js2
-rw-r--r--spec/frontend/user_lists/components/user_lists_table_spec.js2
-rw-r--r--spec/frontend/user_lists/store/edit/actions_spec.js2
-rw-r--r--spec/frontend/user_lists/store/edit/mutations_spec.js2
-rw-r--r--spec/frontend/user_lists/store/index/actions_spec.js51
-rw-r--r--spec/frontend/user_lists/store/index/mutations_spec.js2
-rw-r--r--spec/frontend/user_lists/store/new/actions_spec.js2
-rw-r--r--spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js70
-rw-r--r--spec/frontend/vue_mr_widget/components/extensions/utils_spec.js2
-rw-r--r--spec/frontend/vue_mr_widget/components/mr_widget_memory_usage_spec.js44
-rw-r--r--spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js31
-rw-r--r--spec/frontend/vue_mr_widget/extensions/test_report/index_spec.js149
-rw-r--r--spec/frontend/vue_mr_widget/mr_widget_options_spec.js121
-rw-r--r--spec/frontend/vue_mr_widget/stores/artifacts_list/actions_spec.js35
-rw-r--r--spec/frontend/vue_mr_widget/test_extensions.js9
-rw-r--r--spec/frontend/vue_shared/alert_details/alert_details_spec.js30
-rw-r--r--spec/frontend/vue_shared/alert_details/service_spec.js44
-rw-r--r--spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap127
-rw-r--r--spec/frontend/vue_shared/components/__snapshots__/identicon_spec.js.snap21
-rw-r--r--spec/frontend/vue_shared/components/awards_list_spec.js3
-rw-r--r--spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js17
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js32
-rw-r--r--spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js10
-rw-r--r--spec/frontend/vue_shared/components/help_popover_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/identicon_spec.js50
-rw-r--r--spec/frontend/vue_shared/components/line_numbers_spec.js37
-rw-r--r--spec/frontend/vue_shared/components/local_storage_sync_spec.js277
-rw-r--r--spec/frontend/vue_shared/components/markdown/apply_suggestion_spec.js23
-rw-r--r--spec/frontend/vue_shared/components/markdown/field_spec.js17
-rw-r--r--spec/frontend/vue_shared/components/markdown/header_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/metric_images/__snapshots__/metric_images_table_spec.js.snap73
-rw-r--r--spec/frontend/vue_shared/components/metric_images/metric_images_tab_spec.js174
-rw-r--r--spec/frontend/vue_shared/components/metric_images/metric_images_table_spec.js230
-rw-r--r--spec/frontend/vue_shared/components/metric_images/mock_data.js5
-rw-r--r--spec/frontend/vue_shared/components/metric_images/store/actions_spec.js158
-rw-r--r--spec/frontend/vue_shared/components/metric_images/store/mutations_spec.js147
-rw-r--r--spec/frontend/vue_shared/components/notes/placeholder_note_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/project_avatar/default_spec.js50
-rw-r--r--spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js9
-rw-r--r--spec/frontend/vue_shared/components/registry/persisted_dropdown_selection_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap8
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js74
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js35
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js28
-rw-r--r--spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js69
-rw-r--r--spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js82
-rw-r--r--spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js107
-rw-r--r--spec/frontend/vue_shared/components/source_viewer/utils_spec.js26
-rw-r--r--spec/frontend/vue_shared/components/user_avatar/user_avatar_image_new_spec.js32
-rw-r--r--spec/frontend/vue_shared/components/user_avatar/user_avatar_image_old_spec.js39
-rw-r--r--spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js25
-rw-r--r--spec/frontend/vue_shared/components/user_select_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/web_ide_link_spec.js5
-rw-r--r--spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js7
-rw-r--r--spec/frontend/vue_shared/security_reports/store/modules/sast/actions_spec.js39
-rw-r--r--spec/frontend/vue_shared/security_reports/store/modules/secret_detection/actions_spec.js40
-rw-r--r--spec/frontend/vuex_shared/modules/modal/actions_spec.js16
-rw-r--r--spec/frontend/work_items/components/item_title_spec.js4
-rw-r--r--spec/frontend/work_items/components/work_item_actions_spec.js103
-rw-r--r--spec/frontend/work_items/components/work_item_detail_modal_spec.js58
-rw-r--r--spec/frontend/work_items/components/work_item_detail_spec.js40
-rw-r--r--spec/frontend/work_items/components/work_item_title_spec.js117
-rw-r--r--spec/frontend/work_items/mock_data.js97
-rw-r--r--spec/frontend/work_items/pages/create_work_item_spec.js73
-rw-r--r--spec/frontend/work_items/pages/work_item_detail_spec.js99
-rw-r--r--spec/frontend/work_items/pages/work_item_root_spec.js91
-rw-r--r--spec/frontend/work_items/router_spec.js2
-rw-r--r--spec/frontend_integration/content_editor/content_editor_integration_spec.js63
-rw-r--r--spec/graphql/graphql_triggers_spec.rb16
-rw-r--r--spec/graphql/mutations/ci/runner/delete_spec.rb9
-rw-r--r--spec/graphql/mutations/environments/canary_ingress/update_spec.rb14
-rw-r--r--spec/graphql/mutations/saved_replies/destroy_spec.rb46
-rw-r--r--spec/graphql/resolvers/blobs_resolver_spec.rb14
-rw-r--r--spec/graphql/resolvers/group_members/notification_email_resolver_spec.rb4
-rw-r--r--spec/graphql/resolvers/issues_resolver_spec.rb44
-rw-r--r--spec/graphql/resolvers/project_jobs_resolver_spec.rb17
-rw-r--r--spec/graphql/resolvers/users_resolver_spec.rb4
-rw-r--r--spec/graphql/resolvers/work_item_resolver_spec.rb4
-rw-r--r--spec/graphql/resolvers/work_items/types_resolver_spec.rb10
-rw-r--r--spec/graphql/types/base_object_spec.rb20
-rw-r--r--spec/graphql/types/ci/job_kind_enum_spec.rb11
-rw-r--r--spec/graphql/types/ci/job_type_spec.rb1
-rw-r--r--spec/graphql/types/container_repository_details_type_spec.rb4
-rw-r--r--spec/graphql/types/container_repository_type_spec.rb4
-rw-r--r--spec/graphql/types/dependency_proxy/manifest_type_spec.rb2
-rw-r--r--spec/graphql/types/issue_sort_enum_spec.rb2
-rw-r--r--spec/graphql/types/range_input_type_spec.rb2
-rw-r--r--spec/graphql/types/repository/blob_type_spec.rb3
-rw-r--r--spec/graphql/types/subscription_type_spec.rb1
-rw-r--r--spec/haml_lint/linter/documentation_links_spec.rb6
-rw-r--r--spec/helpers/admin/background_migrations_helper_spec.rb10
-rw-r--r--spec/helpers/application_settings_helper_spec.rb15
-rw-r--r--spec/helpers/boards_helper_spec.rb17
-rw-r--r--spec/helpers/broadcast_messages_helper_spec.rb29
-rw-r--r--spec/helpers/button_helper_spec.rb2
-rw-r--r--spec/helpers/ci/pipeline_editor_helper_spec.rb8
-rw-r--r--spec/helpers/ci/pipelines_helper_spec.rb41
-rw-r--r--spec/helpers/ci/runners_helper_spec.rb45
-rw-r--r--spec/helpers/clusters_helper_spec.rb4
-rw-r--r--spec/helpers/colors_helper_spec.rb89
-rw-r--r--spec/helpers/commits_helper_spec.rb10
-rw-r--r--spec/helpers/diff_helper_spec.rb57
-rw-r--r--spec/helpers/environment_helper_spec.rb2
-rw-r--r--spec/helpers/environments_helper_spec.rb2
-rw-r--r--spec/helpers/groups/group_members_helper_spec.rb62
-rw-r--r--spec/helpers/invite_members_helper_spec.rb2
-rw-r--r--spec/helpers/issuables_helper_spec.rb2
-rw-r--r--spec/helpers/issues_helper_spec.rb14
-rw-r--r--spec/helpers/namespaces_helper_spec.rb11
-rw-r--r--spec/helpers/packages_helper_spec.rb4
-rw-r--r--spec/helpers/preferences_helper_spec.rb61
-rw-r--r--spec/helpers/projects/alert_management_helper_spec.rb23
-rw-r--r--spec/helpers/projects/pipeline_helper_spec.rb23
-rw-r--r--spec/helpers/projects/security/configuration_helper_spec.rb6
-rw-r--r--spec/helpers/projects_helper_spec.rb48
-rw-r--r--spec/helpers/routing/pseudonymization_helper_spec.rb6
-rw-r--r--spec/helpers/search_helper_spec.rb46
-rw-r--r--spec/helpers/timeboxes_helper_spec.rb28
-rw-r--r--spec/helpers/wiki_helper_spec.rb4
-rw-r--r--spec/initializers/mail_encoding_patch_spec.rb3
-rw-r--r--spec/initializers/omniauth_spec.rb46
-rw-r--r--spec/lib/api/entities/application_setting_spec.rb31
-rw-r--r--spec/lib/api/validations/validators/limit_spec.rb6
-rw-r--r--spec/lib/backup/artifacts_spec.rb24
-rw-r--r--spec/lib/backup/files_spec.rb26
-rw-r--r--spec/lib/backup/gitaly_backup_spec.rb38
-rw-r--r--spec/lib/backup/gitaly_rpc_backup_spec.rb154
-rw-r--r--spec/lib/backup/lfs_spec.rb26
-rw-r--r--spec/lib/backup/manager_spec.rb300
-rw-r--r--spec/lib/backup/object_backup_spec.rb35
-rw-r--r--spec/lib/backup/pages_spec.rb25
-rw-r--r--spec/lib/backup/repositories_spec.rb153
-rw-r--r--spec/lib/backup/task_spec.rb8
-rw-r--r--spec/lib/backup/uploads_spec.rb25
-rw-r--r--spec/lib/banzai/filter/custom_emoji_filter_spec.rb9
-rw-r--r--spec/lib/banzai/filter/image_link_filter_spec.rb10
-rw-r--r--spec/lib/banzai/filter/kroki_filter_spec.rb6
-rw-r--r--spec/lib/banzai/filter/plantuml_filter_spec.rb4
-rw-r--r--spec/lib/bulk_imports/common/pipelines/entity_finisher_spec.rb2
-rw-r--r--spec/lib/bulk_imports/groups/stage_spec.rb37
-rw-r--r--spec/lib/bulk_imports/projects/stage_spec.rb4
-rw-r--r--spec/lib/container_registry/gitlab_api_client_spec.rb189
-rw-r--r--spec/lib/container_registry/migration_spec.rb36
-rw-r--r--spec/lib/error_tracking/sentry_client/issue_spec.rb54
-rw-r--r--spec/lib/gitlab/application_context_spec.rb13
-rw-r--r--spec/lib/gitlab/auth/o_auth/user_spec.rb47
-rw-r--r--spec/lib/gitlab/background_migration/backfill_draft_status_on_merge_requests_spec.rb16
-rw-r--r--spec/lib/gitlab/background_migration/backfill_group_features_spec.rb28
-rw-r--r--spec/lib/gitlab/background_migration/backfill_incident_issue_escalation_statuses_spec.rb27
-rw-r--r--spec/lib/gitlab/background_migration/backfill_issue_search_data_spec.rb2
-rw-r--r--spec/lib/gitlab/background_migration/backfill_namespace_id_for_project_route_spec.rb53
-rw-r--r--spec/lib/gitlab/background_migration/backfill_work_item_type_id_for_issues_spec.rb67
-rw-r--r--spec/lib/gitlab/background_migration/batching_strategies/backfill_issue_work_item_type_batching_strategy_spec.rb135
-rw-r--r--spec/lib/gitlab/background_migration/batching_strategies/backfill_project_namespace_per_group_batching_strategy_spec.rb2
-rw-r--r--spec/lib/gitlab/background_migration/batching_strategies/primary_key_batching_strategy_spec.rb29
-rw-r--r--spec/lib/gitlab/background_migration/cleanup_draft_data_from_faulty_regex_spec.rb54
-rw-r--r--spec/lib/gitlab/background_migration/disable_expiration_policies_linked_to_no_container_images_spec.rb2
-rw-r--r--spec/lib/gitlab/background_migration/encrypt_static_object_token_spec.rb8
-rw-r--r--spec/lib/gitlab/background_migration/fix_duplicate_project_name_and_path_spec.rb65
-rw-r--r--spec/lib/gitlab/background_migration/merge_topics_with_same_name_spec.rb135
-rw-r--r--spec/lib/gitlab/background_migration/migrate_shimo_confluence_integration_category_spec.rb28
-rw-r--r--spec/lib/gitlab/background_migration/populate_container_repository_migration_plan_spec.rb44
-rw-r--r--spec/lib/gitlab/background_migration/populate_namespace_statistics_spec.rb71
-rw-r--r--spec/lib/gitlab/background_migration/populate_vulnerability_reads_spec.rb2
-rw-r--r--spec/lib/gitlab/background_migration/project_namespaces/backfill_project_namespaces_spec.rb2
-rw-r--r--spec/lib/gitlab/background_migration/remove_duplicate_vulnerabilities_findings_spec.rb2
-rw-r--r--spec/lib/gitlab/background_migration/remove_occurrence_pipelines_and_duplicate_vulnerabilities_findings_spec.rb2
-rw-r--r--spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_encrypted_values_on_projects_spec.rb2
-rw-r--r--spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_values_on_projects_spec.rb2
-rw-r--r--spec/lib/gitlab/blame_spec.rb79
-rw-r--r--spec/lib/gitlab/ci/build/image_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/config/entry/image_spec.rb16
-rw-r--r--spec/lib/gitlab/ci/config/entry/reports/coverage_report_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/config/entry/root_spec.rb14
-rw-r--r--spec/lib/gitlab/ci/config/external/file/artifact_spec.rb71
-rw-r--r--spec/lib/gitlab/ci/config/external/file/base_spec.rb75
-rw-r--r--spec/lib/gitlab/ci/config/external/file/local_spec.rb42
-rw-r--r--spec/lib/gitlab/ci/config/external/file/project_spec.rb83
-rw-r--r--spec/lib/gitlab/ci/config/external/file/remote_spec.rb48
-rw-r--r--spec/lib/gitlab/ci/config/external/file/template_spec.rb27
-rw-r--r--spec/lib/gitlab/ci/config/external/mapper_spec.rb77
-rw-r--r--spec/lib/gitlab/ci/config/external/processor_spec.rb64
-rw-r--r--spec/lib/gitlab/ci/config_spec.rb74
-rw-r--r--spec/lib/gitlab/ci/parsers/security/common_spec.rb236
-rw-r--r--spec/lib/gitlab/ci/parsers/security/validators/schema_validator_spec.rb662
-rw-r--r--spec/lib/gitlab/ci/pipeline/chain/limit/rate_limit_spec.rb179
-rw-r--r--spec/lib/gitlab/ci/pipeline/chain/template_usage_spec.rb2
-rw-r--r--spec/lib/gitlab/ci/reports/security/report_spec.rb16
-rw-r--r--spec/lib/gitlab/ci/reports/security/scanner_spec.rb1
-rw-r--r--spec/lib/gitlab/ci/runner_releases_spec.rb114
-rw-r--r--spec/lib/gitlab/ci/runner_upgrade_check_spec.rb89
-rw-r--r--spec/lib/gitlab/ci/status/build/manual_spec.rb18
-rw-r--r--spec/lib/gitlab/ci/templates/MATLAB_spec.rb26
-rw-r--r--spec/lib/gitlab/ci/templates/templates_spec.rb3
-rw-r--r--spec/lib/gitlab/ci/templates/themekit_gitlab_ci_yaml_spec.rb60
-rw-r--r--spec/lib/gitlab/ci/variables/builder_spec.rb362
-rw-r--r--spec/lib/gitlab/ci/yaml_processor_spec.rb28
-rw-r--r--spec/lib/gitlab/config/loader/yaml_spec.rb10
-rw-r--r--spec/lib/gitlab/content_security_policy/config_loader_spec.rb20
-rw-r--r--spec/lib/gitlab/data_builder/deployment_spec.rb37
-rw-r--r--spec/lib/gitlab/data_builder/note_spec.rb79
-rw-r--r--spec/lib/gitlab/database/background_migration/batch_metrics_spec.rb30
-rw-r--r--spec/lib/gitlab/database/background_migration/batched_job_spec.rb87
-rw-r--r--spec/lib/gitlab/database/background_migration/batched_migration_runner_spec.rb40
-rw-r--r--spec/lib/gitlab/database/background_migration/batched_migration_spec.rb120
-rw-r--r--spec/lib/gitlab/database/background_migration/batched_migration_wrapper_spec.rb110
-rw-r--r--spec/lib/gitlab/database/background_migration/prometheus_metrics_spec.rb118
-rw-r--r--spec/lib/gitlab/database/consistency_checker_spec.rb189
-rw-r--r--spec/lib/gitlab/database/each_database_spec.rb9
-rw-r--r--spec/lib/gitlab/database/load_balancing/setup_spec.rb89
-rw-r--r--spec/lib/gitlab/database/migration_helpers/restrict_gitlab_schema_spec.rb22
-rw-r--r--spec/lib/gitlab/database/migration_helpers/v2_spec.rb6
-rw-r--r--spec/lib/gitlab/database/migration_helpers_spec.rb13
-rw-r--r--spec/lib/gitlab/database/migration_spec.rb2
-rw-r--r--spec/lib/gitlab/database/migrations/batched_background_migration_helpers_spec.rb2
-rw-r--r--spec/lib/gitlab/database/migrations/instrumentation_spec.rb25
-rw-r--r--spec/lib/gitlab/database/migrations/runner_spec.rb12
-rw-r--r--spec/lib/gitlab/database/migrations/test_background_runner_spec.rb53
-rw-r--r--spec/lib/gitlab/database/partitioning_migration_helpers/table_management_helpers_spec.rb26
-rw-r--r--spec/lib/gitlab/database/query_analyzers/gitlab_schemas_metrics_spec.rb76
-rw-r--r--spec/lib/gitlab/database/reindexing/grafana_notifier_spec.rb58
-rw-r--r--spec/lib/gitlab/database/schema_cache_with_renamed_table_spec.rb4
-rw-r--r--spec/lib/gitlab/database_spec.rb74
-rw-r--r--spec/lib/gitlab/diff/custom_diff_spec.rb53
-rw-r--r--spec/lib/gitlab/diff/file_spec.rb98
-rw-r--r--spec/lib/gitlab/diff/rendered/notebook/diff_file_spec.rb22
-rw-r--r--spec/lib/gitlab/email/handler/service_desk_handler_spec.rb14
-rw-r--r--spec/lib/gitlab/email/message/in_product_marketing/base_spec.rb1
-rw-r--r--spec/lib/gitlab/email/message/in_product_marketing/invite_team_spec.rb39
-rw-r--r--spec/lib/gitlab/email/message/in_product_marketing_spec.rb1
-rw-r--r--spec/lib/gitlab/encoding_helper_spec.rb10
-rw-r--r--spec/lib/gitlab/gfm/uploads_rewriter_spec.rb2
-rw-r--r--spec/lib/gitlab/git/blame_spec.rb98
-rw-r--r--spec/lib/gitlab/git/diff_spec.rb58
-rw-r--r--spec/lib/gitlab/git/repository_spec.rb2
-rw-r--r--spec/lib/gitlab/gitaly_client/commit_service_spec.rb35
-rw-r--r--spec/lib/gitlab/github_import/importer/diff_notes_importer_spec.rb6
-rw-r--r--spec/lib/gitlab/github_import/importer/issues_importer_spec.rb6
-rw-r--r--spec/lib/gitlab/github_import/importer/lfs_objects_importer_spec.rb6
-rw-r--r--spec/lib/gitlab/github_import/importer/notes_importer_spec.rb6
-rw-r--r--spec/lib/gitlab/github_import/object_counter_spec.rb10
-rw-r--r--spec/lib/gitlab/github_import/parallel_scheduling_spec.rb11
-rw-r--r--spec/lib/gitlab/gon_helper_spec.rb28
-rw-r--r--spec/lib/gitlab/graphql/known_operations_spec.rb6
-rw-r--r--spec/lib/gitlab/graphql/pagination/active_record_array_connection_spec.rb135
-rw-r--r--spec/lib/gitlab/graphql/pagination/keyset/connection_generic_keyset_spec.rb13
-rw-r--r--spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb127
-rw-r--r--spec/lib/gitlab/hook_data/issuable_builder_spec.rb8
-rw-r--r--spec/lib/gitlab/hook_data/merge_request_builder_spec.rb1
-rw-r--r--spec/lib/gitlab/http_connection_adapter_spec.rb10
-rw-r--r--spec/lib/gitlab/import_export/all_models.yml1
-rw-r--r--spec/lib/gitlab/import_export/command_line_util_spec.rb2
-rw-r--r--spec/lib/gitlab/import_export/duration_measuring_spec.rb35
-rw-r--r--spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb2
-rw-r--r--spec/lib/gitlab/import_export/version_checker_spec.rb3
-rw-r--r--spec/lib/gitlab/insecure_key_fingerprint_spec.rb9
-rw-r--r--spec/lib/gitlab/legacy_github_import/project_creator_spec.rb4
-rw-r--r--spec/lib/gitlab/metrics/rails_slis_spec.rb6
-rw-r--r--spec/lib/gitlab/omniauth_initializer_spec.rb12
-rw-r--r--spec/lib/gitlab/pagination/keyset/column_order_definition_spec.rb16
-rw-r--r--spec/lib/gitlab/pagination/keyset/in_operator_optimization/query_builder_spec.rb39
-rw-r--r--spec/lib/gitlab/pagination/keyset/iterator_spec.rb12
-rw-r--r--spec/lib/gitlab/pagination/keyset/order_spec.rb57
-rw-r--r--spec/lib/gitlab/pagination/keyset/simple_order_builder_spec.rb107
-rw-r--r--spec/lib/gitlab/pagination/offset_pagination_spec.rb70
-rw-r--r--spec/lib/gitlab/patch/database_config_spec.rb (renamed from spec/lib/gitlab/patch/legacy_database_config_spec.rb)2
-rw-r--r--spec/lib/gitlab/path_regex_spec.rb2
-rw-r--r--spec/lib/gitlab/project_template_spec.rb2
-rw-r--r--spec/lib/gitlab/quick_actions/command_definition_spec.rb7
-rw-r--r--spec/lib/gitlab/search_context/builder_spec.rb1
-rw-r--r--spec/lib/gitlab/security/scan_configuration_spec.rb10
-rw-r--r--spec/lib/gitlab/seeder_spec.rb27
-rw-r--r--spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb2
-rw-r--r--spec/lib/gitlab/sidekiq_middleware/worker_context/client_spec.rb2
-rw-r--r--spec/lib/gitlab/sidekiq_middleware/worker_context/server_spec.rb2
-rw-r--r--spec/lib/gitlab/ssh_public_key_spec.rb74
-rw-r--r--spec/lib/gitlab/suggestions/commit_message_spec.rb131
-rw-r--r--spec/lib/gitlab/suggestions/suggestion_set_spec.rb116
-rw-r--r--spec/lib/gitlab/tracking_spec.rb38
-rw-r--r--spec/lib/gitlab/url_sanitizer_spec.rb17
-rw-r--r--spec/lib/gitlab/usage/service_ping_report_spec.rb137
-rw-r--r--spec/lib/gitlab/usage_data_counters/ci_template_unique_counter_spec.rb44
-rw-r--r--spec/lib/gitlab/usage_data_counters/gitlab_cli_activity_unique_counter_spec.rb15
-rw-r--r--spec/lib/gitlab/usage_data_queries_spec.rb12
-rw-r--r--spec/lib/gitlab/usage_data_spec.rb5
-rw-r--r--spec/lib/gitlab/utils/delegator_override/validator_spec.rb9
-rw-r--r--spec/lib/gitlab/view/presenter/base_spec.rb34
-rw-r--r--spec/lib/gitlab/web_ide/config/entry/terminal_spec.rb4
-rw-r--r--spec/lib/gitlab/web_ide/config_spec.rb4
-rw-r--r--spec/lib/gitlab/workhorse_spec.rb8
-rw-r--r--spec/lib/mattermost/session_spec.rb6
-rw-r--r--spec/lib/prometheus/cleanup_multiproc_dir_service_spec.rb28
-rw-r--r--spec/lib/sidebars/groups/menus/group_information_menu_spec.rb14
-rw-r--r--spec/lib/sidebars/projects/menus/infrastructure_menu_spec.rb32
-rw-r--r--spec/lib/sidebars/projects/panel_spec.rb36
-rw-r--r--spec/lib/system_check/app/git_user_default_ssh_config_check_spec.rb24
-rw-r--r--spec/mailers/emails/in_product_marketing_spec.rb7
-rw-r--r--spec/mailers/emails/profile_spec.rb23
-rw-r--r--spec/mailers/notify_spec.rb167
-rw-r--r--spec/migrations/20210430134202_copy_adoption_snapshot_namespace_spec.rb1
-rw-r--r--spec/migrations/20211214012507_backfill_incident_issue_escalation_statuses_spec.rb25
-rw-r--r--spec/migrations/20220204095121_backfill_namespace_statistics_with_dependency_proxy_size_spec.rb64
-rw-r--r--spec/migrations/20220223124428_schedule_merge_topics_with_same_name_spec.rb36
-rw-r--r--spec/migrations/20220315171129_cleanup_draft_data_from_faulty_regex_spec.rb40
-rw-r--r--spec/migrations/20220316202640_populate_container_repositories_migration_plan_spec.rb34
-rw-r--r--spec/migrations/20220321234317_remove_all_issuable_escalation_statuses_spec.rb20
-rw-r--r--spec/migrations/20220322132242_update_pages_onboarding_state_spec.rb53
-rw-r--r--spec/migrations/20220324032250_migrate_shimo_confluence_service_category_spec.rb34
-rw-r--r--spec/migrations/20220329175119_remove_leftover_ci_job_artifact_deletions_spec.rb92
-rw-r--r--spec/migrations/20220412143552_consume_remaining_encrypt_integration_property_jobs_spec.rb42
-rw-r--r--spec/migrations/add_epics_relative_position_spec.rb29
-rw-r--r--spec/migrations/backfill_group_features_spec.rb31
-rw-r--r--spec/migrations/backfill_namespace_id_for_project_routes_spec.rb29
-rw-r--r--spec/migrations/backfill_work_item_type_id_on_issues_spec.rb52
-rw-r--r--spec/migrations/cleanup_after_fixing_issue_when_admin_changed_primary_email_spec.rb40
-rw-r--r--spec/migrations/finalize_project_namespaces_backfill_spec.rb69
-rw-r--r--spec/migrations/finalize_traversal_ids_background_migrations_spec.rb60
-rw-r--r--spec/migrations/fix_and_backfill_project_namespaces_for_projects_with_duplicate_name_spec.rb51
-rw-r--r--spec/migrations/remove_wiki_notes_spec.rb33
-rw-r--r--spec/migrations/replace_work_item_type_backfill_next_batch_strategy_spec.rb55
-rw-r--r--spec/models/alert_management/metric_image_spec.rb26
-rw-r--r--spec/models/analytics/cycle_analytics/aggregation_spec.rb77
-rw-r--r--spec/models/application_setting_spec.rb49
-rw-r--r--spec/models/award_emoji_spec.rb80
-rw-r--r--spec/models/blob_spec.rb14
-rw-r--r--spec/models/board_spec.rb4
-rw-r--r--spec/models/bulk_import_spec.rb15
-rw-r--r--spec/models/bulk_imports/entity_spec.rb6
-rw-r--r--spec/models/bulk_imports/export_status_spec.rb18
-rw-r--r--spec/models/bulk_imports/tracker_spec.rb4
-rw-r--r--spec/models/ci/bridge_spec.rb26
-rw-r--r--spec/models/ci/build_dependencies_spec.rb4
-rw-r--r--spec/models/ci/build_spec.rb136
-rw-r--r--spec/models/ci/job_artifact_spec.rb13
-rw-r--r--spec/models/ci/namespace_mirror_spec.rb47
-rw-r--r--spec/models/ci/pipeline_spec.rb73
-rw-r--r--spec/models/ci/processable_spec.rb94
-rw-r--r--spec/models/ci/runner_spec.rb8
-rw-r--r--spec/models/ci/secure_file_spec.rb32
-rw-r--r--spec/models/clusters/agent_spec.rb17
-rw-r--r--spec/models/commit_status_spec.rb7
-rw-r--r--spec/models/concerns/approvable_base_spec.rb4
-rw-r--r--spec/models/concerns/batch_nullify_dependent_associations_spec.rb49
-rw-r--r--spec/models/concerns/featurable_spec.rb172
-rw-r--r--spec/models/concerns/issuable_spec.rb10
-rw-r--r--spec/models/concerns/sensitive_serializable_hash_spec.rb10
-rw-r--r--spec/models/concerns/taskable_spec.rb10
-rw-r--r--spec/models/container_repository_spec.rb327
-rw-r--r--spec/models/custom_emoji_spec.rb2
-rw-r--r--spec/models/customer_relations/contact_spec.rb40
-rw-r--r--spec/models/customer_relations/issue_contact_spec.rb12
-rw-r--r--spec/models/customer_relations/organization_spec.rb28
-rw-r--r--spec/models/deploy_token_spec.rb1
-rw-r--r--spec/models/deployment_spec.rb44
-rw-r--r--spec/models/environment_spec.rb261
-rw-r--r--spec/models/environment_status_spec.rb7
-rw-r--r--spec/models/error_tracking/project_error_tracking_setting_spec.rb86
-rw-r--r--spec/models/group_group_link_spec.rb48
-rw-r--r--spec/models/group_spec.rb175
-rw-r--r--spec/models/groups/feature_setting_spec.rb13
-rw-r--r--spec/models/integration_spec.rb202
-rw-r--r--spec/models/integrations/base_third_party_wiki_spec.rb42
-rw-r--r--spec/models/integrations/emails_on_push_spec.rb3
-rw-r--r--spec/models/integrations/external_wiki_spec.rb2
-rw-r--r--spec/models/integrations/field_spec.rb12
-rw-r--r--spec/models/integrations/jira_spec.rb2
-rw-r--r--spec/models/integrations/slack_spec.rb4
-rw-r--r--spec/models/issue_spec.rb39
-rw-r--r--spec/models/key_spec.rb52
-rw-r--r--spec/models/member_spec.rb102
-rw-r--r--spec/models/merge_request_spec.rb16
-rw-r--r--spec/models/namespace_spec.rb6
-rw-r--r--spec/models/namespaces/project_namespace_spec.rb4
-rw-r--r--spec/models/note_spec.rb125
-rw-r--r--spec/models/packages/package_file_spec.rb22
-rw-r--r--spec/models/packages/package_spec.rb4
-rw-r--r--spec/models/plan_limits_spec.rb1
-rw-r--r--spec/models/preloaders/environments/deployment_preloader_spec.rb10
-rw-r--r--spec/models/preloaders/group_root_ancestor_preloader_spec.rb63
-rw-r--r--spec/models/programming_language_spec.rb18
-rw-r--r--spec/models/project_feature_spec.rb95
-rw-r--r--spec/models/project_import_state_spec.rb59
-rw-r--r--spec/models/project_setting_spec.rb30
-rw-r--r--spec/models/project_spec.rb284
-rw-r--r--spec/models/project_statistics_spec.rb4
-rw-r--r--spec/models/projects/build_artifacts_size_refresh_spec.rb6
-rw-r--r--spec/models/projects/topic_spec.rb8
-rw-r--r--spec/models/user_preference_spec.rb42
-rw-r--r--spec/models/user_spec.rb179
-rw-r--r--spec/models/users/in_product_marketing_email_spec.rb18
-rw-r--r--spec/models/web_ide_terminal_spec.rb6
-rw-r--r--spec/models/wiki_page_spec.rb15
-rw-r--r--spec/policies/alert_management/alert_policy_spec.rb52
-rw-r--r--spec/policies/note_policy_spec.rb33
-rw-r--r--spec/policies/project_member_policy_spec.rb6
-rw-r--r--spec/policies/project_policy_spec.rb30
-rw-r--r--spec/presenters/ci/bridge_presenter_spec.rb9
-rw-r--r--spec/presenters/ci/build_runner_presenter_spec.rb60
-rw-r--r--spec/presenters/gitlab/blame_presenter_spec.rb29
-rw-r--r--spec/presenters/issue_presenter_spec.rb61
-rw-r--r--spec/presenters/project_clusterable_presenter_spec.rb6
-rw-r--r--spec/presenters/projects/security/configuration_presenter_spec.rb1
-rw-r--r--spec/requests/admin/background_migrations_controller_spec.rb6
-rw-r--r--spec/requests/api/alert_management_alerts_spec.rb411
-rw-r--r--spec/requests/api/award_emoji_spec.rb17
-rw-r--r--spec/requests/api/bulk_imports_spec.rb23
-rw-r--r--spec/requests/api/ci/job_artifacts_spec.rb23
-rw-r--r--spec/requests/api/ci/jobs_spec.rb9
-rw-r--r--spec/requests/api/ci/runner/jobs_request_post_spec.rb42
-rw-r--r--spec/requests/api/ci/secure_files_spec.rb153
-rw-r--r--spec/requests/api/clusters/agents_spec.rb153
-rw-r--r--spec/requests/api/composer_packages_spec.rb49
-rw-r--r--spec/requests/api/files_spec.rb100
-rw-r--r--spec/requests/api/graphql/ci/job_spec.rb1
-rw-r--r--spec/requests/api/graphql/ci/jobs_spec.rb50
-rw-r--r--spec/requests/api/graphql/ci/runner_spec.rb57
-rw-r--r--spec/requests/api/graphql/ci/runners_spec.rb2
-rw-r--r--spec/requests/api/graphql/mutations/boards/create_spec.rb10
-rw-r--r--spec/requests/api/graphql/mutations/ci/job_retry_spec.rb20
-rw-r--r--spec/requests/api/graphql/mutations/ci/pipeline_cancel_spec.rb1
-rw-r--r--spec/requests/api/graphql/mutations/issues/update_spec.rb22
-rw-r--r--spec/requests/api/graphql/mutations/merge_requests/set_labels_spec.rb8
-rw-r--r--spec/requests/api/graphql/mutations/notes/create/note_spec.rb23
-rw-r--r--spec/requests/api/graphql/mutations/notes/update/note_spec.rb36
-rw-r--r--spec/requests/api/graphql/mutations/todos/mark_all_done_spec.rb31
-rw-r--r--spec/requests/api/graphql/mutations/user_preferences/update_spec.rb22
-rw-r--r--spec/requests/api/graphql_spec.rb2
-rw-r--r--spec/requests/api/group_export_spec.rb129
-rw-r--r--spec/requests/api/groups_spec.rb34
-rw-r--r--spec/requests/api/integrations_spec.rb29
-rw-r--r--spec/requests/api/internal/container_registry/migration_spec.rb15
-rw-r--r--spec/requests/api/invitations_spec.rb153
-rw-r--r--spec/requests/api/issue_links_spec.rb20
-rw-r--r--spec/requests/api/issues/get_project_issues_spec.rb30
-rw-r--r--spec/requests/api/issues/issues_spec.rb21
-rw-r--r--spec/requests/api/keys_spec.rb66
-rw-r--r--spec/requests/api/lint_spec.rb6
-rw-r--r--spec/requests/api/members_spec.rb8
-rw-r--r--spec/requests/api/merge_requests_spec.rb6
-rw-r--r--spec/requests/api/notes_spec.rb2
-rw-r--r--spec/requests/api/project_attributes.yml5
-rw-r--r--spec/requests/api/project_export_spec.rb23
-rw-r--r--spec/requests/api/project_import_spec.rb90
-rw-r--r--spec/requests/api/projects_spec.rb52
-rw-r--r--spec/requests/api/releases_spec.rb91
-rw-r--r--spec/requests/api/remote_mirrors_spec.rb66
-rw-r--r--spec/requests/api/repositories_spec.rb2
-rw-r--r--spec/requests/api/resource_access_tokens_spec.rb99
-rw-r--r--spec/requests/api/settings_spec.rb51
-rw-r--r--spec/requests/api/users_spec.rb60
-rw-r--r--spec/requests/api/v3/github_spec.rb2
-rw-r--r--spec/requests/groups/crm/contacts_controller_spec.rb15
-rw-r--r--spec/requests/groups/crm/organizations_controller_spec.rb15
-rw-r--r--spec/requests/groups/email_campaigns_controller_spec.rb10
-rw-r--r--spec/requests/import/gitlab_groups_controller_spec.rb14
-rw-r--r--spec/requests/jira_authorizations_spec.rb2
-rw-r--r--spec/requests/projects/work_items_spec.rb38
-rw-r--r--spec/routing/admin_routing_spec.rb11
-rw-r--r--spec/routing/project_routing_spec.rb7
-rw-r--r--spec/routing/uploads_routing_spec.rb11
-rw-r--r--spec/rubocop/cop/database/disable_referential_integrity_spec.rb36
-rw-r--r--spec/rubocop/cop/gitlab/avoid_feature_category_not_owned_spec.rb69
-rw-r--r--spec/rubocop/cop/qa/duplicate_testcase_link_spec.rb36
-rw-r--r--spec/rubocop/cop/qa/testcase_link_format_spec.rb45
-rw-r--r--spec/serializers/commit_entity_spec.rb9
-rw-r--r--spec/serializers/deployment_entity_spec.rb6
-rw-r--r--spec/serializers/environment_serializer_spec.rb28
-rw-r--r--spec/serializers/group_link/group_group_link_entity_spec.rb50
-rw-r--r--spec/serializers/group_link/project_group_link_entity_spec.rb2
-rw-r--r--spec/serializers/member_user_entity_spec.rb10
-rw-r--r--spec/services/alert_management/metric_images/upload_service_spec.rb79
-rw-r--r--spec/services/audit_event_service_spec.rb28
-rw-r--r--spec/services/bulk_imports/relation_export_service_spec.rb12
-rw-r--r--spec/services/bulk_update_integration_service_spec.rb12
-rw-r--r--spec/services/ci/after_requeue_job_service_spec.rb23
-rw-r--r--spec/services/ci/create_pipeline_service/rate_limit_spec.rb91
-rw-r--r--spec/services/ci/create_pipeline_service_spec.rb14
-rw-r--r--spec/services/ci/create_web_ide_terminal_service_spec.rb2
-rw-r--r--spec/services/ci/job_artifacts/destroy_all_expired_service_spec.rb46
-rw-r--r--spec/services/ci/job_artifacts/destroy_batch_service_spec.rb12
-rw-r--r--spec/services/ci/job_artifacts/update_unknown_locked_status_service_spec.rb145
-rw-r--r--spec/services/ci/pipeline_processing/atomic_processing_service_spec.rb6
-rw-r--r--spec/services/ci/register_job_service_spec.rb14
-rw-r--r--spec/services/ci/retry_job_service_spec.rb (renamed from spec/services/ci/retry_build_service_spec.rb)32
-rw-r--r--spec/services/ci/retry_pipeline_service_spec.rb2
-rw-r--r--spec/services/database/consistency_check_service_spec.rb154
-rw-r--r--spec/services/deployments/update_environment_service_spec.rb31
-rw-r--r--spec/services/emails/create_service_spec.rb29
-rw-r--r--spec/services/environments/stop_service_spec.rb25
-rw-r--r--spec/services/event_create_service_spec.rb32
-rw-r--r--spec/services/git/branch_push_service_spec.rb10
-rw-r--r--spec/services/groups/create_service_spec.rb37
-rw-r--r--spec/services/groups/transfer_service_spec.rb122
-rw-r--r--spec/services/import/github_service_spec.rb4
-rw-r--r--spec/services/incident_management/issuable_escalation_statuses/build_service_spec.rb20
-rw-r--r--spec/services/incident_management/issuable_escalation_statuses/create_service_spec.rb23
-rw-r--r--spec/services/incident_management/issuable_escalation_statuses/prepare_update_service_spec.rb7
-rw-r--r--spec/services/issues/update_service_spec.rb29
-rw-r--r--spec/services/members/create_service_spec.rb66
-rw-r--r--spec/services/members/creator_service_spec.rb26
-rw-r--r--spec/services/members/invite_service_spec.rb447
-rw-r--r--spec/services/merge_requests/update_service_spec.rb15
-rw-r--r--spec/services/metrics/dashboard/custom_dashboard_service_spec.rb2
-rw-r--r--spec/services/metrics/dashboard/custom_metric_embed_service_spec.rb2
-rw-r--r--spec/services/metrics/dashboard/default_embed_service_spec.rb2
-rw-r--r--spec/services/metrics/dashboard/dynamic_embed_service_spec.rb2
-rw-r--r--spec/services/metrics/dashboard/self_monitoring_dashboard_service_spec.rb2
-rw-r--r--spec/services/metrics/dashboard/system_dashboard_service_spec.rb2
-rw-r--r--spec/services/metrics/dashboard/transient_embed_service_spec.rb2
-rw-r--r--spec/services/namespaces/in_product_marketing_email_records_spec.rb6
-rw-r--r--spec/services/namespaces/in_product_marketing_emails_service_spec.rb2
-rw-r--r--spec/services/namespaces/invite_team_email_service_spec.rb128
-rw-r--r--spec/services/notes/build_service_spec.rb202
-rw-r--r--spec/services/notes/create_service_spec.rb17
-rw-r--r--spec/services/notes/update_service_spec.rb39
-rw-r--r--spec/services/notification_recipients/builder/default_spec.rb2
-rw-r--r--spec/services/notification_service_spec.rb75
-rw-r--r--spec/services/packages/rubygems/metadata_extraction_service_spec.rb8
-rw-r--r--spec/services/projects/apple_target_platform_detector_service_spec.rb61
-rw-r--r--spec/services/projects/container_repository/cleanup_tags_service_spec.rb2
-rw-r--r--spec/services/projects/container_repository/third_party/delete_tags_service_spec.rb14
-rw-r--r--spec/services/projects/create_service_spec.rb28
-rw-r--r--spec/services/projects/operations/update_service_spec.rb31
-rw-r--r--spec/services/projects/record_target_platforms_service_spec.rb66
-rw-r--r--spec/services/projects/refresh_build_artifacts_size_statistics_service_spec.rb13
-rw-r--r--spec/services/projects/transfer_service_spec.rb72
-rw-r--r--spec/services/quick_actions/interpret_service_spec.rb26
-rw-r--r--spec/services/service_ping/build_payload_service_spec.rb4
-rw-r--r--spec/services/task_list_toggle_service_spec.rb21
-rw-r--r--spec/services/users/destroy_service_spec.rb35
-rw-r--r--spec/services/users/saved_replies/destroy_service_spec.rb35
-rw-r--r--spec/services/users/saved_replies/update_service_spec.rb2
-rw-r--r--spec/services/web_hook_service_spec.rb15
-rw-r--r--spec/spec_helper.rb12
-rw-r--r--spec/support/database_cleaner.rb3
-rw-r--r--spec/support/fips.rb27
-rw-r--r--spec/support/gitlab_stubs/gitlab_ci.yml2
-rw-r--r--spec/support/helpers/cycle_analytics_helpers.rb19
-rw-r--r--spec/support/helpers/features/invite_members_modal_helper.rb40
-rw-r--r--spec/support/helpers/features/runner_helpers.rb68
-rw-r--r--spec/support/helpers/gitaly_setup.rb2
-rw-r--r--spec/support/helpers/login_helpers.rb2
-rw-r--r--spec/support/helpers/navbar_structure_helper.rb12
-rw-r--r--spec/support/helpers/search_helpers.rb7
-rw-r--r--spec/support/helpers/test_env.rb5
-rw-r--r--spec/support/helpers/usage_data_helpers.rb1
-rw-r--r--spec/support/matchers/graphql_matchers.rb10
-rw-r--r--spec/support/matchers/markdown_matchers.rb2
-rw-r--r--spec/support/matchers/project_namespace_matcher.rb2
-rw-r--r--spec/support/services/deploy_token_shared_examples.rb4
-rw-r--r--spec/support/services/issuable_update_service_shared_examples.rb44
-rw-r--r--spec/support/shared_contexts/container_repositories_shared_context.rb14
-rw-r--r--spec/support/shared_contexts/finders/users_finder_shared_contexts.rb4
-rw-r--r--spec/support/shared_contexts/lib/container_registry/client_stubs_shared_context.rb4
-rw-r--r--spec/support/shared_contexts/markdown_golden_master_shared_examples.rb3
-rw-r--r--spec/support/shared_contexts/navbar_structure_context.rb10
-rw-r--r--spec/support/shared_contexts/serializers/group_group_link_shared_context.rb6
-rw-r--r--spec/support/shared_contexts/services/service_ping/stubbed_service_ping_metrics_definitions_shared_context.rb9
-rw-r--r--spec/support/shared_contexts/url_shared_context.rb4
-rw-r--r--spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/controllers/githubish_import_controller_shared_examples.rb37
-rw-r--r--spec/support/shared_examples/controllers/wiki_actions_shared_examples.rb11
-rw-r--r--spec/support/shared_examples/features/access_tokens_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/features/content_editor_shared_examples.rb46
-rw-r--r--spec/support/shared_examples/features/inviting_members_shared_examples.rb175
-rw-r--r--spec/support/shared_examples/features/project_upload_files_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/features/runners_shared_examples.rb141
-rw-r--r--spec/support/shared_examples/features/search/search_timeouts_shared_examples.rb1
-rw-r--r--spec/support/shared_examples/features/wiki/user_creates_wiki_page_shared_examples.rb6
-rw-r--r--spec/support/shared_examples/features/wiki/user_updates_wiki_page_shared_examples.rb13
-rw-r--r--spec/support/shared_examples/graphql/mutations/boards_create_shared_examples.rb10
-rw-r--r--spec/support/shared_examples/graphql/notes_creation_shared_examples.rb11
-rw-r--r--spec/support/shared_examples/graphql/types/gitlab_style_deprecations_shared_examples.rb6
-rw-r--r--spec/support/shared_examples/helpers/wiki_helpers_shared_examples.rb20
-rw-r--r--spec/support/shared_examples/incident_management/issuable_escalation_statuses/build_examples.rb20
-rw-r--r--spec/support/shared_examples/lib/gitlab/event_store_shared_examples.rb18
-rw-r--r--spec/support/shared_examples/lib/gitlab/usage_data_counters/issuable_activity_shared_examples.rb3
-rw-r--r--spec/support/shared_examples/lib/sidebars/projects/menus/zentao_menu_shared_examples.rb12
-rw-r--r--spec/support/shared_examples/lib/wikis_api_examples.rb6
-rw-r--r--spec/support/shared_examples/models/application_setting_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/models/concerns/bulk_users_by_email_load_shared_examples.rb39
-rw-r--r--spec/support/shared_examples/models/concerns/from_set_operator_shared_examples.rb6
-rw-r--r--spec/support/shared_examples/models/concerns/integrations/slack_mattermost_notifier_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/models/group_shared_examples.rb43
-rw-r--r--spec/support/shared_examples/models/issuable_hook_data_shared_examples.rb61
-rw-r--r--spec/support/shared_examples/models/issuable_link_shared_examples.rb13
-rw-r--r--spec/support/shared_examples/models/member_shared_examples.rb60
-rw-r--r--spec/support/shared_examples/models/project_shared_examples.rb27
-rw-r--r--spec/support/shared_examples/models/wiki_shared_examples.rb140
-rw-r--r--spec/support/shared_examples/policies/wiki_policies_shared_examples.rb6
-rw-r--r--spec/support/shared_examples/requests/api/composer_packages_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/requests/api/container_repositories_shared_examples.rb90
-rw-r--r--spec/support/shared_examples/requests/api/graphql/group_and_project_boards_query_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/requests/api/issuable_participants_examples.rb30
-rw-r--r--spec/support/shared_examples/requests/api/notes_shared_examples.rb79
-rw-r--r--spec/support/shared_examples/serializers/environment_serializer_shared_examples.rb19
-rw-r--r--spec/support/shared_examples/services/boards/boards_list_service_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/services/container_registry_auth_service_shared_examples.rb94
-rw-r--r--spec/support/shared_examples/services/issuable_links/create_links_shared_examples.rb6
-rw-r--r--spec/support/shared_examples/views/milestone_shared_examples.rb78
-rw-r--r--spec/support/shared_examples/workers/batched_background_migration_worker_shared_examples.rb16
-rw-r--r--spec/support/shared_examples/workers/concerns/git_garbage_collect_methods_shared_examples.rb70
-rw-r--r--spec/tasks/dev_rake_spec.rb112
-rw-r--r--spec/tasks/gitlab/backup_rake_spec.rb46
-rw-r--r--spec/tasks/gitlab/db/validate_config_rake_spec.rb205
-rw-r--r--spec/tasks/gitlab/db_rake_spec.rb513
-rw-r--r--spec/tasks/gitlab/refresh_project_statistics_build_artifacts_size_rake_spec.rb44
-rw-r--r--spec/tasks/gitlab/setup_rake_spec.rb21
-rw-r--r--spec/tooling/danger/product_intelligence_spec.rb103
-rw-r--r--spec/tooling/danger/project_helper_spec.rb34
-rw-r--r--spec/uploaders/ci/secure_file_uploader_spec.rb4
-rw-r--r--spec/validators/addressable_url_validator_spec.rb4
-rw-r--r--spec/views/admin/application_settings/_ci_cd.html.haml_spec.rb87
-rw-r--r--spec/views/admin/application_settings/_repository_storage.html.haml_spec.rb32
-rw-r--r--spec/views/dashboard/milestones/index.html.haml_spec.rb7
-rw-r--r--spec/views/groups/milestones/index.html.haml_spec.rb7
-rw-r--r--spec/views/groups/runners/_sort_dropdown.html.haml_spec.rb21
-rw-r--r--spec/views/groups/show.html.haml_spec.rb118
-rw-r--r--spec/views/profiles/keys/_form.html.haml_spec.rb4
-rw-r--r--spec/views/projects/commit/show.html.haml_spec.rb1
-rw-r--r--spec/views/projects/milestones/index.html.haml_spec.rb7
-rw-r--r--spec/views/projects/pipelines/show.html.haml_spec.rb1
-rw-r--r--spec/views/shared/_global_alert.html.haml_spec.rb46
-rw-r--r--spec/views/shared/_milestones_sort_dropdown.html.haml_spec.rb27
-rw-r--r--spec/views/shared/groups/_dropdown.html.haml_spec.rb27
-rw-r--r--spec/views/shared/projects/_list.html.haml_spec.rb12
-rw-r--r--spec/workers/bulk_import_worker_spec.rb25
-rw-r--r--spec/workers/bulk_imports/entity_worker_spec.rb45
-rw-r--r--spec/workers/bulk_imports/export_request_worker_spec.rb18
-rw-r--r--spec/workers/bulk_imports/pipeline_worker_spec.rb65
-rw-r--r--spec/workers/bulk_imports/stuck_import_worker_spec.rb36
-rw-r--r--spec/workers/ci/update_locked_unknown_artifacts_worker_spec.rb44
-rw-r--r--spec/workers/concerns/application_worker_spec.rb2
-rw-r--r--spec/workers/container_registry/migration/enqueuer_worker_spec.rb192
-rw-r--r--spec/workers/container_registry/migration/guard_worker_spec.rb72
-rw-r--r--spec/workers/database/batched_background_migration/ci_database_worker_spec.rb2
-rw-r--r--spec/workers/database/batched_background_migration_worker_spec.rb2
-rw-r--r--spec/workers/database/ci_namespace_mirrors_consistency_check_worker_spec.rb67
-rw-r--r--spec/workers/database/ci_project_mirrors_consistency_check_worker_spec.rb67
-rw-r--r--spec/workers/every_sidekiq_worker_spec.rb2
-rw-r--r--spec/workers/gitlab/github_import/import_diff_note_worker_spec.rb2
-rw-r--r--spec/workers/gitlab/github_import/import_issue_worker_spec.rb2
-rw-r--r--spec/workers/gitlab/github_import/import_note_worker_spec.rb2
-rw-r--r--spec/workers/gitlab/github_import/import_pull_request_worker_spec.rb2
-rw-r--r--spec/workers/merge_requests/update_head_pipeline_worker_spec.rb6
-rw-r--r--spec/workers/namespaces/invite_team_email_worker_spec.rb27
-rw-r--r--spec/workers/namespaces/root_statistics_worker_spec.rb2
-rw-r--r--spec/workers/namespaces/update_root_statistics_worker_spec.rb6
-rw-r--r--spec/workers/packages/cleanup_package_file_worker_spec.rb60
-rw-r--r--spec/workers/project_export_worker_spec.rb26
-rw-r--r--spec/workers/projects/post_creation_worker_spec.rb2
-rw-r--r--spec/workers/projects/record_target_platforms_worker_spec.rb87
-rw-r--r--spec/workers/quality/test_data_cleanup_worker_spec.rb44
1201 files changed, 36257 insertions, 16548 deletions
diff --git a/spec/commands/sidekiq_cluster/cli_spec.rb b/spec/commands/sidekiq_cluster/cli_spec.rb
index 2cb3f67b03d..bbf5f2bc4d9 100644
--- a/spec/commands/sidekiq_cluster/cli_spec.rb
+++ b/spec/commands/sidekiq_cluster/cli_spec.rb
@@ -41,6 +41,7 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo
end
let(:supervisor) { instance_double(Gitlab::SidekiqCluster::SidekiqProcessSupervisor) }
+ let(:metrics_cleanup_service) { instance_double(Prometheus::CleanupMultiprocDirService, execute: nil) }
before do
stub_env('RAILS_ENV', 'test')
@@ -54,6 +55,8 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo
allow(Gitlab::ProcessManagement).to receive(:write_pid)
allow(Gitlab::SidekiqCluster::SidekiqProcessSupervisor).to receive(:instance).and_return(supervisor)
allow(supervisor).to receive(:supervise)
+
+ allow(Prometheus::CleanupMultiprocDirService).to receive(:new).and_return(metrics_cleanup_service)
end
after do
@@ -300,6 +303,13 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo
allow(Gitlab::SidekiqCluster).to receive(:start).and_return([])
end
+ it 'wipes the metrics directory before starting workers' do
+ expect(metrics_cleanup_service).to receive(:execute).ordered
+ expect(Gitlab::SidekiqCluster).to receive(:start).ordered.and_return([])
+
+ cli.run(%w(foo))
+ end
+
context 'when there are no sidekiq_health_checks settings set' do
let(:sidekiq_exporter_enabled) { true }
@@ -379,7 +389,7 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo
with_them do
specify do
if start_metrics_server
- expect(MetricsServer).to receive(:fork).with('sidekiq', metrics_dir: metrics_dir, wipe_metrics_dir: true, reset_signals: trapped_signals)
+ expect(MetricsServer).to receive(:fork).with('sidekiq', metrics_dir: metrics_dir, reset_signals: trapped_signals)
else
expect(MetricsServer).not_to receive(:fork)
end
diff --git a/spec/components/diffs/overflow_warning_component_spec.rb b/spec/components/diffs/overflow_warning_component_spec.rb
new file mode 100644
index 00000000000..ee4014ee492
--- /dev/null
+++ b/spec/components/diffs/overflow_warning_component_spec.rb
@@ -0,0 +1,184 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe Diffs::OverflowWarningComponent, type: :component do
+ include RepoHelpers
+
+ subject(:component) do
+ described_class.new(
+ diffs: diffs,
+ diff_files: diff_files,
+ project: project,
+ commit: commit,
+ merge_request: merge_request
+ )
+ end
+
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:repository) { project.repository }
+ let_it_be(:commit) { project.commit(sample_commit.id) }
+ let_it_be(:diffs) { commit.raw_diffs }
+ let_it_be(:diff) { diffs.first }
+ let_it_be(:diff_refs) { commit.diff_refs }
+ let_it_be(:diff_file) { Gitlab::Diff::File.new(diff, diff_refs: diff_refs, repository: repository) }
+ let_it_be(:diff_files) { [diff_file] }
+ let_it_be(:merge_request) { create(:merge_request, source_project: project) }
+
+ let(:expected_button_classes) do
+ "btn gl-alert-action btn-default gl-button btn-default-secondary"
+ end
+
+ describe "rendered component" do
+ subject { rendered_component }
+
+ context "on a commit page" do
+ before do
+ with_controller_class Projects::CommitController do
+ render_inline component
+ end
+ end
+
+ it { is_expected.to include(component.message) }
+
+ it "links to the diff" do
+ expect(component.diff_link).to eq(
+ ActionController::Base.helpers.link_to(
+ _("Plain diff"),
+ project_commit_path(project, commit, format: :diff),
+ class: expected_button_classes
+ )
+ )
+
+ is_expected.to include(component.diff_link)
+ end
+
+ it "links to the patch" do
+ expect(component.patch_link).to eq(
+ ActionController::Base.helpers.link_to(
+ _("Email patch"),
+ project_commit_path(project, commit, format: :patch),
+ class: expected_button_classes
+ )
+ )
+
+ is_expected.to include(component.patch_link)
+ end
+ end
+
+ context "on a merge request page and the merge request is persisted" do
+ before do
+ with_controller_class Projects::MergeRequests::DiffsController do
+ render_inline component
+ end
+ end
+
+ it { is_expected.to include(component.message) }
+
+ it "links to the diff" do
+ expect(component.diff_link).to eq(
+ ActionController::Base.helpers.link_to(
+ _("Plain diff"),
+ merge_request_path(merge_request, format: :diff),
+ class: expected_button_classes
+ )
+ )
+
+ is_expected.to include(component.diff_link)
+ end
+
+ it "links to the patch" do
+ expect(component.patch_link).to eq(
+ ActionController::Base.helpers.link_to(
+ _("Email patch"),
+ merge_request_path(merge_request, format: :patch),
+ class: expected_button_classes
+ )
+ )
+
+ is_expected.to include(component.patch_link)
+ end
+ end
+
+ context "both conditions fail" do
+ before do
+ allow(component).to receive(:commit?).and_return(false)
+ allow(component).to receive(:merge_request?).and_return(false)
+ render_inline component
+ end
+
+ it { is_expected.to include(component.message) }
+ it { is_expected.not_to include(expected_button_classes) }
+ it { is_expected.not_to include("Plain diff") }
+ it { is_expected.not_to include("Email patch") }
+ end
+ end
+
+ describe "#message" do
+ subject { component.message }
+
+ it { is_expected.to be_a(String) }
+
+ it "is HTML-safe" do
+ expect(subject.html_safe?).to be_truthy
+ end
+ end
+
+ describe "#diff_link" do
+ subject { component.diff_link }
+
+ before do
+ allow(component).to receive(:link_to).and_return("foo")
+ render_inline component
+ end
+
+ it "is a string when on a commit page" do
+ allow(component).to receive(:commit?).and_return(true)
+
+ is_expected.to eq("foo")
+ end
+
+ it "is a string when on a merge request page" do
+ allow(component).to receive(:commit?).and_return(false)
+ allow(component).to receive(:merge_request?).and_return(true)
+
+ is_expected.to eq("foo")
+ end
+
+ it "is nil in other situations" do
+ allow(component).to receive(:commit?).and_return(false)
+ allow(component).to receive(:merge_request?).and_return(false)
+
+ is_expected.to be_nil
+ end
+ end
+
+ describe "#patch_link" do
+ subject { component.patch_link }
+
+ before do
+ allow(component).to receive(:link_to).and_return("foo")
+ render_inline component
+ end
+
+ it "is a string when on a commit page" do
+ allow(component).to receive(:commit?).and_return(true)
+
+ is_expected.to eq("foo")
+ end
+
+ it "is a string when on a merge request page" do
+ allow(component).to receive(:commit?).and_return(false)
+ allow(component).to receive(:merge_request?).and_return(true)
+
+ is_expected.to eq("foo")
+ end
+
+ it "is nil in other situations" do
+ allow(component).to receive(:commit?).and_return(false)
+ allow(component).to receive(:merge_request?).and_return(false)
+
+ is_expected.to be_nil
+ end
+ end
+end
diff --git a/spec/components/diffs/stats_component_spec.rb b/spec/components/diffs/stats_component_spec.rb
new file mode 100644
index 00000000000..2e5a5f2ca26
--- /dev/null
+++ b/spec/components/diffs/stats_component_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe Diffs::StatsComponent, type: :component do
+ include RepoHelpers
+
+ subject(:component) do
+ described_class.new(diff_files: diff_files)
+ end
+
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:repository) { project.repository }
+ let_it_be(:commit) { project.commit(sample_commit.id) }
+ let_it_be(:diffs) { commit.raw_diffs }
+ let_it_be(:diff) { diffs.first }
+ let_it_be(:diff_refs) { commit.diff_refs }
+ let_it_be(:diff_file) { Gitlab::Diff::File.new(diff, diff_refs: diff_refs, repository: repository) }
+ let_it_be(:diff_files) { [diff_file] }
+
+ describe "rendered component" do
+ subject { rendered_component }
+
+ let(:element) { page.find(".js-diff-stats-dropdown") }
+
+ before do
+ render_inline component
+ end
+
+ it { is_expected.to have_selector(".js-diff-stats-dropdown") }
+
+ it "renders the data attributes" do
+ expect(element["data-changed"]).to eq("1")
+ expect(element["data-added"]).to eq("10")
+ expect(element["data-deleted"]).to eq("3")
+
+ expect(Gitlab::Json.parse(element["data-files"])).to eq([{
+ "href" => "##{Digest::SHA1.hexdigest(diff_file.file_path)}",
+ "title" => diff_file.new_path,
+ "name" => diff_file.file_path,
+ "path" => diff_file.file_path,
+ "icon" => "file-modified",
+ "iconColor" => "",
+ "added" => diff_file.added_lines,
+ "removed" => diff_file.removed_lines
+ }])
+ end
+ end
+
+ describe "#diff_file_path_text" do
+ it "returns full path by default" do
+ expect(subject.diff_file_path_text(diff_file)).to eq(diff_file.new_path)
+ end
+
+ it "returns truncated path" do
+ expect(subject.diff_file_path_text(diff_file, max: 10)).to eq("...open.rb")
+ end
+
+ it "returns the path if max is oddly small" do
+ expect(subject.diff_file_path_text(diff_file, max: 3)).to eq(diff_file.new_path)
+ end
+
+ it "returns the path if max is oddly large" do
+ expect(subject.diff_file_path_text(diff_file, max: 100)).to eq(diff_file.new_path)
+ end
+ end
+end
diff --git a/spec/components/pajamas/alert_component_spec.rb b/spec/components/pajamas/alert_component_spec.rb
new file mode 100644
index 00000000000..628d715ff64
--- /dev/null
+++ b/spec/components/pajamas/alert_component_spec.rb
@@ -0,0 +1,104 @@
+# frozen_string_literal: true
+require "spec_helper"
+
+RSpec.describe Pajamas::AlertComponent, :aggregate_failures, type: :component do
+ context 'with content' do
+ before do
+ render_inline(described_class.new) { '_content_' }
+ end
+
+ it 'has content' do
+ expect(rendered_component).to have_text('_content_')
+ end
+ end
+
+ context 'with defaults' do
+ before do
+ render_inline described_class.new
+ end
+
+ it 'does not set a title' do
+ expect(rendered_component).not_to have_selector('.gl-alert-title')
+ expect(rendered_component).to have_selector('.gl-alert-icon-no-title')
+ end
+
+ it 'renders the default variant' do
+ expect(rendered_component).to have_selector('.gl-alert-info')
+ expect(rendered_component).to have_selector("[data-testid='information-o-icon']")
+ end
+
+ it 'renders a dismiss button' do
+ expect(rendered_component).to have_selector('.gl-dismiss-btn.js-close')
+ expect(rendered_component).to have_selector("[data-testid='close-icon']")
+ end
+ end
+
+ context 'with custom options' do
+ context 'with simple options' do
+ context 'without dismissible content' do
+ before do
+ render_inline described_class.new(
+ title: '_title_',
+ dismissible: false,
+ alert_class: '_alert_class_',
+ alert_data: {
+ feature_id: '_feature_id_',
+ dismiss_endpoint: '_dismiss_endpoint_'
+ }
+ )
+ end
+
+ it 'sets the title' do
+ expect(rendered_component).to have_selector('.gl-alert-title')
+ expect(rendered_component).to have_content('_title_')
+ expect(rendered_component).not_to have_selector('.gl-alert-icon-no-title')
+ end
+
+ it 'sets to not be dismissible' do
+ expect(rendered_component).not_to have_selector('.gl-dismiss-btn.js-close')
+ expect(rendered_component).not_to have_selector("[data-testid='close-icon']")
+ end
+
+ it 'sets the alert_class' do
+ expect(rendered_component).to have_selector('._alert_class_')
+ end
+
+ it 'sets the alert_data' do
+ expect(rendered_component).to have_selector('[data-feature-id="_feature_id_"][data-dismiss-endpoint="_dismiss_endpoint_"]')
+ end
+ end
+ end
+
+ context 'with dismissible content' do
+ before do
+ render_inline described_class.new(
+ close_button_class: '_close_button_class_',
+ close_button_data: {
+ testid: '_close_button_testid_'
+ }
+ )
+ end
+
+ it 'renders a dismiss button and data' do
+ expect(rendered_component).to have_selector('.gl-dismiss-btn.js-close._close_button_class_')
+ expect(rendered_component).to have_selector("[data-testid='close-icon']")
+ expect(rendered_component).to have_selector('[data-testid="_close_button_testid_"]')
+ end
+ end
+
+ context 'with setting variant type' do
+ where(:variant) { [:warning, :success, :danger, :tip] }
+
+ before do
+ render_inline described_class.new(variant: variant)
+ end
+
+ with_them do
+ it 'renders the variant' do
+ expect(rendered_component).to have_selector(".gl-alert-#{variant}")
+ expect(rendered_component).to have_selector("[data-testid='#{described_class::ICONS[variant]}-icon']")
+ end
+ end
+ end
+ end
+end
diff --git a/spec/controllers/concerns/import_url_params_spec.rb b/spec/controllers/concerns/import_url_params_spec.rb
index ddffb243f7a..170263d10a4 100644
--- a/spec/controllers/concerns/import_url_params_spec.rb
+++ b/spec/controllers/concerns/import_url_params_spec.rb
@@ -55,4 +55,22 @@ RSpec.describe ImportUrlParams do
end
end
end
+
+ context 'url with provided mixed credentials' do
+ let(:params) do
+ ActionController::Parameters.new(project: {
+ import_url: 'https://user@url.com',
+ import_url_user: '', import_url_password: 'password'
+ })
+ end
+
+ describe '#import_url_params' do
+ it 'returns import_url built from both url and hash credentials' do
+ expect(import_url_params).to eq(
+ import_url: 'https://user:password@url.com',
+ import_type: 'git'
+ )
+ end
+ end
+ end
end
diff --git a/spec/controllers/explore/projects_controller_spec.rb b/spec/controllers/explore/projects_controller_spec.rb
index c3f6c653376..bf578489916 100644
--- a/spec/controllers/explore/projects_controller_spec.rb
+++ b/spec/controllers/explore/projects_controller_spec.rb
@@ -112,6 +112,13 @@ RSpec.describe Explore::ProjectsController do
expect(response).to have_gitlab_http_status(:ok)
expect(response).to render_template('topic')
end
+
+ it 'finds topic by case insensitive name' do
+ get :topic, params: { topic_name: 'TOPIC1' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to render_template('topic')
+ end
end
end
end
diff --git a/spec/controllers/graphql_controller_spec.rb b/spec/controllers/graphql_controller_spec.rb
index dbaed8aaa19..4de31e2e135 100644
--- a/spec/controllers/graphql_controller_spec.rb
+++ b/spec/controllers/graphql_controller_spec.rb
@@ -134,6 +134,47 @@ RSpec.describe GraphqlController do
post :execute
end
+
+ it 'calls the track gitlab cli when trackable method' do
+ agent = 'GLab - GitLab CLI'
+ request.env['HTTP_USER_AGENT'] = agent
+
+ expect(Gitlab::UsageDataCounters::GitLabCliActivityUniqueCounter)
+ .to receive(:track_api_request_when_trackable).with(user_agent: agent, user: user)
+
+ post :execute
+ end
+
+ it "assigns username in ApplicationContext" do
+ post :execute
+
+ expect(Gitlab::ApplicationContext.current).to include('meta.user' => user.username)
+ end
+ end
+
+ context 'when 2FA is required for the user' do
+ let(:user) { create(:user, last_activity_on: Date.yesterday) }
+
+ before do
+ group = create(:group, require_two_factor_authentication: true)
+ group.add_developer(user)
+
+ sign_in(user)
+ end
+
+ it 'does not redirect if 2FA is enabled' do
+ expect(controller).not_to receive(:redirect_to)
+
+ post :execute
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+
+ expected_message = "Authentication error: " \
+ "enable 2FA in your profile settings to continue using GitLab: %{mfa_help_page}" %
+ { mfa_help_page: EnforcesTwoFactorAuthentication::MFA_HELP_PAGE }
+
+ expect(json_response).to eq({ 'errors' => [{ 'message' => expected_message }] })
+ end
end
context 'when user uses an API token' do
@@ -189,6 +230,12 @@ RSpec.describe GraphqlController do
expect(assigns(:context)[:is_sessionless_user]).to be true
end
+ it "assigns username in ApplicationContext" do
+ subject
+
+ expect(Gitlab::ApplicationContext.current).to include('meta.user' => user.username)
+ end
+
it 'calls the track api when trackable method' do
agent = 'vs-code-gitlab-workflow/3.11.1 VSCode/1.52.1 Node.js/12.14.1 (darwin; x64)'
request.env['HTTP_USER_AGENT'] = agent
@@ -208,6 +255,16 @@ RSpec.describe GraphqlController do
subject
end
+
+ it 'calls the track gitlab cli when trackable method' do
+ agent = 'GLab - GitLab CLI'
+ request.env['HTTP_USER_AGENT'] = agent
+
+ expect(Gitlab::UsageDataCounters::GitLabCliActivityUniqueCounter)
+ .to receive(:track_api_request_when_trackable).with(user_agent: agent, user: user)
+
+ subject
+ end
end
context 'when user is not logged in' do
@@ -222,6 +279,12 @@ RSpec.describe GraphqlController do
expect(assigns(:context)[:is_sessionless_user]).to be false
end
+
+ it "does not assign a username in ApplicationContext" do
+ subject
+
+ expect(Gitlab::ApplicationContext.current.key?('meta.user')).to be false
+ end
end
it 'includes request object in context' do
diff --git a/spec/controllers/groups/group_links_controller_spec.rb b/spec/controllers/groups/group_links_controller_spec.rb
index fafe9715946..28febd786de 100644
--- a/spec/controllers/groups/group_links_controller_spec.rb
+++ b/spec/controllers/groups/group_links_controller_spec.rb
@@ -35,120 +35,6 @@ RSpec.describe Groups::GroupLinksController do
end
end
- describe '#create' do
- let(:shared_with_group_id) { shared_with_group.id }
- let(:shared_group_access) { GroupGroupLink.default_access }
-
- subject do
- post(:create,
- params: { group_id: shared_group,
- shared_with_group_id: shared_with_group_id,
- shared_group_access: shared_group_access })
- end
-
- shared_examples 'creates group group link' do
- it 'links group with selected group' do
- expect { subject }.to change { shared_with_group.shared_groups.include?(shared_group) }.from(false).to(true)
- end
-
- it 'redirects to group links page' do
- subject
-
- expect(response).to(redirect_to(group_group_members_path(shared_group)))
- end
-
- it 'allows access for group member' do
- expect { subject }.to(
- change { group_member.can?(:read_group, shared_group) }.from(false).to(true))
- end
- end
-
- context 'when user has correct access to both groups' do
- before do
- shared_with_group.add_developer(user)
- shared_group.add_owner(user)
- end
-
- context 'when default access level is requested' do
- include_examples 'creates group group link'
- end
-
- context 'when owner access is requested' do
- let(:shared_group_access) { Gitlab::Access::OWNER }
-
- before do
- shared_with_group.add_owner(group_member)
- end
-
- include_examples 'creates group group link'
-
- it 'allows admin access for group member' do
- expect { subject }.to(
- change { group_member.can?(:admin_group, shared_group) }.from(false).to(true))
- end
- end
-
- it 'updates project permissions', :sidekiq_inline do
- expect { subject }.to change { group_member.can?(:read_project, project) }.from(false).to(true)
- end
-
- context 'when shared with group id is not present' do
- let(:shared_with_group_id) { nil }
-
- it 'redirects to group links page' do
- subject
-
- expect(response).to(redirect_to(group_group_members_path(shared_group)))
- expect(flash[:alert]).to eq('Please select a group.')
- end
- end
-
- context 'when link is not persisted in the database' do
- before do
- allow(::Groups::GroupLinks::CreateService).to(
- receive_message_chain(:new, :execute)
- .and_return({ status: :error,
- http_status: 409,
- message: 'error' }))
- end
-
- it 'redirects to group links page' do
- subject
-
- expect(response).to(redirect_to(group_group_members_path(shared_group)))
- expect(flash[:alert]).to eq('error')
- end
- end
- end
-
- context 'when user does not have access to the group' do
- before do
- shared_group.add_owner(user)
- end
-
- it 'renders 404' do
- subject
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
-
- context 'when user does not have admin access to the shared group' do
- before do
- shared_with_group.add_developer(user)
- shared_group.add_developer(user)
- end
-
- it 'renders 404' do
- subject
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
-
- include_examples 'placeholder is passed as `id` parameter', :create
- end
-
describe '#update' do
let!(:link) do
create(:group_group_link, { shared_group: shared_group,
@@ -193,7 +79,8 @@ RSpec.describe Groups::GroupLinksController do
subject
- expect(json_response).to eq({ "expires_in" => "about 1 month", "expires_soon" => false })
+ expect(json_response).to eq({ "expires_in" => controller.helpers.time_ago_with_tooltip(expiry_date),
+ "expires_soon" => false })
end
end
diff --git a/spec/controllers/groups/runners_controller_spec.rb b/spec/controllers/groups/runners_controller_spec.rb
index b4950b93a3f..a53f09e2afc 100644
--- a/spec/controllers/groups/runners_controller_spec.rb
+++ b/spec/controllers/groups/runners_controller_spec.rb
@@ -17,7 +17,7 @@ RSpec.describe Groups::RunnersController do
sign_in(user)
end
- describe '#index' do
+ describe '#index', :snowplow do
context 'when user is owner' do
before do
group.add_owner(user)
@@ -30,6 +30,12 @@ RSpec.describe Groups::RunnersController do
expect(response).to render_template(:index)
expect(assigns(:group_runners_limited_count)).to be(2)
end
+
+ it 'tracks the event' do
+ get :index, params: { group_id: group }
+
+ expect_snowplow_event(category: described_class.name, action: 'index', user: user, namespace: group)
+ end
end
context 'when user is not owner' do
@@ -42,6 +48,12 @@ RSpec.describe Groups::RunnersController do
expect(response).to have_gitlab_http_status(:not_found)
end
+
+ it 'does not track the event' do
+ get :index, params: { group_id: group }
+
+ expect_no_snowplow_event
+ end
end
end
diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb
index a82c5681911..be30011905c 100644
--- a/spec/controllers/groups_controller_spec.rb
+++ b/spec/controllers/groups_controller_spec.rb
@@ -509,6 +509,14 @@ RSpec.describe GroupsController, factory_default: :keep do
expect(assigns(:issues)).to eq([issue_1])
end
end
+
+ it 'saves the sort order to user preferences' do
+ stub_feature_flags(vue_issues_list: true)
+
+ get :issues, params: { id: group.to_param, sort: 'priority' }
+
+ expect(user.reload.user_preference.issues_sort).to eq('priority')
+ end
end
describe 'GET #merge_requests', :sidekiq_might_not_need_inline do
@@ -1076,19 +1084,6 @@ RSpec.describe GroupsController, factory_default: :keep do
enable_admin_mode!(admin)
end
- context 'when the group export feature flag is not enabled' do
- before do
- sign_in(admin)
- stub_feature_flags(group_import_export: false)
- end
-
- it 'returns a not found error' do
- post :export, params: { id: group.to_param }
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
-
context 'when the user does not have permission to export the group' do
before do
sign_in(guest)
@@ -1189,19 +1184,6 @@ RSpec.describe GroupsController, factory_default: :keep do
end
end
- context 'when the group export feature flag is not enabled' do
- before do
- sign_in(admin)
- stub_feature_flags(group_import_export: false)
- end
-
- it 'returns a not found error' do
- post :export, params: { id: group.to_param }
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
-
context 'when the user does not have the required permissions' do
before do
sign_in(guest)
diff --git a/spec/controllers/help_controller_spec.rb b/spec/controllers/help_controller_spec.rb
index 4e2123c8cc4..70dc710f604 100644
--- a/spec/controllers/help_controller_spec.rb
+++ b/spec/controllers/help_controller_spec.rb
@@ -142,11 +142,11 @@ RSpec.describe HelpController do
context 'for Markdown formats' do
subject { get :show, params: { path: path }, format: :md }
- let(:path) { 'ssh/index' }
+ let(:path) { 'user/ssh' }
context 'when requested file exists' do
before do
- expect_file_read(File.join(Rails.root, 'doc/ssh/index.md'), content: fixture_file('blockquote_fence_after.md'))
+ expect_file_read(File.join(Rails.root, 'doc/user/ssh.md'), content: fixture_file('blockquote_fence_after.md'))
subject
end
@@ -257,7 +257,7 @@ RSpec.describe HelpController do
it 'always renders not found' do
get :show,
params: {
- path: 'ssh/index'
+ path: 'user/ssh'
},
format: :foo
expect(response).to be_not_found
diff --git a/spec/controllers/import/bitbucket_controller_spec.rb b/spec/controllers/import/bitbucket_controller_spec.rb
index 91e43adc472..6d24830af27 100644
--- a/spec/controllers/import/bitbucket_controller_spec.rb
+++ b/spec/controllers/import/bitbucket_controller_spec.rb
@@ -26,31 +26,55 @@ RSpec.describe Import::BitbucketController do
session[:oauth_request_token] = {}
end
- it "updates access token" do
- expires_at = Time.current + 1.day
- expires_in = 1.day
- access_token = double(token: token,
- secret: secret,
- expires_at: expires_at,
- expires_in: expires_in,
- refresh_token: refresh_token)
- allow_any_instance_of(OAuth2::Client)
- .to receive(:get_token)
- .with(hash_including(
- 'grant_type' => 'authorization_code',
- 'code' => code,
- redirect_uri: users_import_bitbucket_callback_url),
- {})
- .and_return(access_token)
- stub_omniauth_provider('bitbucket')
-
- get :callback, params: { code: code }
-
- expect(session[:bitbucket_token]).to eq(token)
- expect(session[:bitbucket_refresh_token]).to eq(refresh_token)
- expect(session[:bitbucket_expires_at]).to eq(expires_at)
- expect(session[:bitbucket_expires_in]).to eq(expires_in)
- expect(controller).to redirect_to(status_import_bitbucket_url)
+ context "when auth state param is invalid" do
+ let(:random_key) { "pure_random" }
+ let(:external_bitbucket_auth_url) { "http://fake.bitbucket.host/url" }
+
+ it "redirects to external auth url" do
+ allow(SecureRandom).to receive(:base64).and_return(random_key)
+ allow_next_instance_of(OAuth2::Client) do |client|
+ allow(client).to receive_message_chain(:auth_code, :authorize_url)
+ .with(redirect_uri: users_import_bitbucket_callback_url, state: random_key)
+ .and_return(external_bitbucket_auth_url)
+ end
+
+ get :callback, params: { code: code, state: "invalid-token" }
+
+ expect(controller).to redirect_to(external_bitbucket_auth_url)
+ end
+ end
+
+ context "when auth state param is valid" do
+ before do
+ session[:bitbucket_auth_state] = 'state'
+ end
+
+ it "updates access token" do
+ expires_at = Time.current + 1.day
+ expires_in = 1.day
+ access_token = double(token: token,
+ secret: secret,
+ expires_at: expires_at,
+ expires_in: expires_in,
+ refresh_token: refresh_token)
+ allow_any_instance_of(OAuth2::Client)
+ .to receive(:get_token)
+ .with(hash_including(
+ 'grant_type' => 'authorization_code',
+ 'code' => code,
+ redirect_uri: users_import_bitbucket_callback_url),
+ {})
+ .and_return(access_token)
+ stub_omniauth_provider('bitbucket')
+
+ get :callback, params: { code: code, state: 'state' }
+
+ expect(session[:bitbucket_token]).to eq(token)
+ expect(session[:bitbucket_refresh_token]).to eq(refresh_token)
+ expect(session[:bitbucket_expires_at]).to eq(expires_at)
+ expect(session[:bitbucket_expires_in]).to eq(expires_in)
+ expect(controller).to redirect_to(status_import_bitbucket_url)
+ end
end
end
@@ -59,46 +83,68 @@ RSpec.describe Import::BitbucketController do
@repo = double(name: 'vim', slug: 'vim', owner: 'asd', full_name: 'asd/vim', clone_url: 'http://test.host/demo/url.git', 'valid?' => true)
@invalid_repo = double(name: 'mercurialrepo', slug: 'mercurialrepo', owner: 'asd', full_name: 'asd/mercurialrepo', clone_url: 'http://test.host/demo/mercurialrepo.git', 'valid?' => false)
allow(controller).to receive(:provider_url).and_return('http://demobitbucket.org')
+ end
- assign_session_tokens
+ context "when token does not exists" do
+ let(:random_key) { "pure_random" }
+ let(:external_bitbucket_auth_url) { "http://fake.bitbucket.host/url" }
+
+ it 'redirects to authorize url with state included' do
+ allow(SecureRandom).to receive(:base64).and_return(random_key)
+ allow_next_instance_of(OAuth2::Client) do |client|
+ allow(client).to receive_message_chain(:auth_code, :authorize_url)
+ .with(redirect_uri: users_import_bitbucket_callback_url, state: random_key)
+ .and_return(external_bitbucket_auth_url)
+ end
+
+ get :status, format: :json
+
+ expect(controller).to redirect_to(external_bitbucket_auth_url)
+ end
end
- it_behaves_like 'import controller status' do
+ context "when token is valid" do
before do
- allow(controller).to receive(:provider_url).and_return('http://demobitbucket.org')
+ assign_session_tokens
end
- let(:repo) { @repo }
- let(:repo_id) { @repo.full_name }
- let(:import_source) { @repo.full_name }
- let(:provider_name) { 'bitbucket' }
- let(:client_repos_field) { :repos }
- end
+ it_behaves_like 'import controller status' do
+ before do
+ allow(controller).to receive(:provider_url).and_return('http://demobitbucket.org')
+ end
- it 'returns invalid repos' do
- allow_any_instance_of(Bitbucket::Client).to receive(:repos).and_return([@repo, @invalid_repo])
+ let(:repo) { @repo }
+ let(:repo_id) { @repo.full_name }
+ let(:import_source) { @repo.full_name }
+ let(:provider_name) { 'bitbucket' }
+ let(:client_repos_field) { :repos }
+ end
- get :status, format: :json
+ it 'returns invalid repos' do
+ allow_any_instance_of(Bitbucket::Client).to receive(:repos).and_return([@repo, @invalid_repo])
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response['incompatible_repos'].length).to eq(1)
- expect(json_response.dig("incompatible_repos", 0, "id")).to eq(@invalid_repo.full_name)
- expect(json_response['provider_repos'].length).to eq(1)
- expect(json_response.dig("provider_repos", 0, "id")).to eq(@repo.full_name)
- end
+ get :status, format: :json
- context 'when filtering' do
- let(:filter) { '<html>test</html>' }
- let(:expected_filter) { 'test' }
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['incompatible_repos'].length).to eq(1)
+ expect(json_response.dig("incompatible_repos", 0, "id")).to eq(@invalid_repo.full_name)
+ expect(json_response['provider_repos'].length).to eq(1)
+ expect(json_response.dig("provider_repos", 0, "id")).to eq(@repo.full_name)
+ end
- subject { get :status, params: { filter: filter }, as: :json }
+ context 'when filtering' do
+ let(:filter) { '<html>test</html>' }
+ let(:expected_filter) { 'test' }
- it 'passes sanitized filter param to bitbucket client' do
- expect_next_instance_of(Bitbucket::Client) do |client|
- expect(client).to receive(:repos).with(filter: expected_filter).and_return([@repo])
- end
+ subject { get :status, params: { filter: filter }, as: :json }
- subject
+ it 'passes sanitized filter param to bitbucket client' do
+ expect_next_instance_of(Bitbucket::Client) do |client|
+ expect(client).to receive(:repos).with(filter: expected_filter).and_return([@repo])
+ end
+
+ subject
+ end
end
end
end
diff --git a/spec/controllers/import/github_controller_spec.rb b/spec/controllers/import/github_controller_spec.rb
index fd380f9b763..ef66124bff1 100644
--- a/spec/controllers/import/github_controller_spec.rb
+++ b/spec/controllers/import/github_controller_spec.rb
@@ -82,11 +82,33 @@ RSpec.describe Import::GithubController do
expect(controller).to redirect_to(new_import_url)
expect(flash[:alert]).to eq('Access denied to your GitHub account.')
end
+
+ it "includes namespace_id from session if it is present" do
+ namespace_id = 1
+ session[:namespace_id] = 1
+
+ get :callback, params: { state: valid_auth_state }
+
+ expect(controller).to redirect_to(status_import_github_url(namespace_id: namespace_id))
+ end
end
end
describe "POST personal_access_token" do
it_behaves_like 'a GitHub-ish import controller: POST personal_access_token'
+
+ it 'passes namespace_id param as query param if it was present' do
+ namespace_id = 5
+ status_import_url = public_send("status_import_#{provider}_url", { namespace_id: namespace_id })
+
+ allow_next_instance_of(Gitlab::LegacyGithubImport::Client) do |client|
+ allow(client).to receive(:user).and_return(true)
+ end
+
+ post :personal_access_token, params: { personal_access_token: 'some-token', namespace_id: 5 }
+
+ expect(controller).to redirect_to(status_import_url)
+ end
end
describe "GET status" do
@@ -258,7 +280,9 @@ RSpec.describe Import::GithubController do
context 'when user input contains colons and spaces' do
before do
- allow(controller).to receive(:client_repos).and_return([])
+ allow_next_instance_of(Gitlab::GithubImport::Client) do |client|
+ allow(client).to receive(:search_repos_by_name).and_return(items: [])
+ end
end
it 'sanitizes user input' do
@@ -293,6 +317,22 @@ RSpec.describe Import::GithubController do
end
describe "GET realtime_changes" do
+ let(:user) { create(:user) }
+
it_behaves_like 'a GitHub-ish import controller: GET realtime_changes'
+
+ before do
+ assign_session_token(provider)
+ end
+
+ it 'includes stats in response' do
+ create(:project, import_type: provider, namespace: user.namespace, import_status: :finished, import_source: 'example/repo')
+
+ get :realtime_changes
+
+ expect(json_response[0]).to include('stats')
+ expect(json_response[0]['stats']).to include('fetched')
+ expect(json_response[0]['stats']).to include('imported')
+ end
end
end
diff --git a/spec/controllers/jira_connect/events_controller_spec.rb b/spec/controllers/jira_connect/events_controller_spec.rb
index 2129b24b2fb..5e90ceb0f9c 100644
--- a/spec/controllers/jira_connect/events_controller_spec.rb
+++ b/spec/controllers/jira_connect/events_controller_spec.rb
@@ -114,17 +114,6 @@ RSpec.describe JiraConnect::EventsController do
base_url: base_url
)
end
-
- context 'when the `jira_connect_installation_update` feature flag is disabled' do
- before do
- stub_feature_flags(jira_connect_installation_update: false)
- end
-
- it 'does not update the installation', :aggregate_failures do
- expect { subject }.not_to change { installation.reload.attributes }
- expect(response).to have_gitlab_http_status(:ok)
- end
- end
end
context 'when the new base_url is invalid' do
diff --git a/spec/controllers/jira_connect/subscriptions_controller_spec.rb b/spec/controllers/jira_connect/subscriptions_controller_spec.rb
index f548c1f399d..e9c94f09c99 100644
--- a/spec/controllers/jira_connect/subscriptions_controller_spec.rb
+++ b/spec/controllers/jira_connect/subscriptions_controller_spec.rb
@@ -75,6 +75,18 @@ RSpec.describe JiraConnect::SubscriptionsController do
expect(json_response).to include('login_path' => nil)
end
end
+
+ context 'with context qsh' do
+ # The JSON endpoint will be requested by frontend using a JWT that Atlassian provides via Javascript.
+ # This JWT will likely use a context-qsh because Atlassian don't know for which endpoint it will be used.
+ # Read more about context JWT here: https://developer.atlassian.com/cloud/jira/platform/understanding-jwt-for-connect-apps/
+
+ let(:qsh) { 'context-qsh' }
+
+ specify do
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
end
end
end
@@ -102,7 +114,7 @@ RSpec.describe JiraConnect::SubscriptionsController do
end
context 'with valid JWT' do
- let(:claims) { { iss: installation.client_key, sub: 1234 } }
+ let(:claims) { { iss: installation.client_key, sub: 1234, qsh: '123' } }
let(:jwt) { Atlassian::Jwt.encode(claims, installation.shared_secret) }
let(:jira_user) { { 'groups' => { 'items' => [{ 'name' => jira_group_name }] } } }
let(:jira_group_name) { 'site-admins' }
@@ -158,7 +170,7 @@ RSpec.describe JiraConnect::SubscriptionsController do
.stub_request(:get, "#{installation.base_url}/rest/api/3/user?accountId=1234&expand=groups")
.to_return(body: jira_user.to_json, status: 200, headers: { 'Content-Type' => 'application/json' })
- delete :destroy, params: { jwt: jwt, id: subscription.id }
+ delete :destroy, params: { jwt: jwt, id: subscription.id, format: :json }
end
context 'without JWT' do
@@ -170,7 +182,7 @@ RSpec.describe JiraConnect::SubscriptionsController do
end
context 'with valid JWT' do
- let(:claims) { { iss: installation.client_key, sub: 1234 } }
+ let(:claims) { { iss: installation.client_key, sub: 1234, qsh: '123' } }
let(:jwt) { Atlassian::Jwt.encode(claims, installation.shared_secret) }
it 'deletes the subscription' do
diff --git a/spec/controllers/oauth/jira/authorizations_controller_spec.rb b/spec/controllers/oauth/jira_dvcs/authorizations_controller_spec.rb
index f4a335b30f4..496ef7859f9 100644
--- a/spec/controllers/oauth/jira/authorizations_controller_spec.rb
+++ b/spec/controllers/oauth/jira_dvcs/authorizations_controller_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Oauth::Jira::AuthorizationsController do
+RSpec.describe Oauth::JiraDvcs::AuthorizationsController do
describe 'GET new' do
it 'redirects to OAuth authorization with correct params' do
get :new, params: { client_id: 'client-123', scope: 'foo', redirect_uri: 'http://example.com/' }
@@ -10,7 +10,7 @@ RSpec.describe Oauth::Jira::AuthorizationsController do
expect(response).to redirect_to(oauth_authorization_url(client_id: 'client-123',
response_type: 'code',
scope: 'foo',
- redirect_uri: oauth_jira_callback_url))
+ redirect_uri: oauth_jira_dvcs_callback_url))
end
it 'replaces the GitHub "repo" scope with "api"' do
@@ -19,7 +19,7 @@ RSpec.describe Oauth::Jira::AuthorizationsController do
expect(response).to redirect_to(oauth_authorization_url(client_id: 'client-123',
response_type: 'code',
scope: 'api',
- redirect_uri: oauth_jira_callback_url))
+ redirect_uri: oauth_jira_dvcs_callback_url))
end
end
diff --git a/spec/controllers/profiles/accounts_controller_spec.rb b/spec/controllers/profiles/accounts_controller_spec.rb
index 011528016ce..1b4b67eeaff 100644
--- a/spec/controllers/profiles/accounts_controller_spec.rb
+++ b/spec/controllers/profiles/accounts_controller_spec.rb
@@ -31,7 +31,7 @@ RSpec.describe Profiles::AccountsController do
end
end
- [:twitter, :facebook, :google_oauth2, :gitlab, :github, :bitbucket, :crowd, :auth0, :authentiq, :dingtalk].each do |provider|
+ [:twitter, :facebook, :google_oauth2, :gitlab, :github, :bitbucket, :crowd, :auth0, :authentiq, :dingtalk, :alicloud].each do |provider|
describe "#{provider} provider" do
let(:user) { create(:omniauth_user, provider: provider.to_s) }
diff --git a/spec/controllers/profiles/keys_controller_spec.rb b/spec/controllers/profiles/keys_controller_spec.rb
index 66f6135df1e..63818337722 100644
--- a/spec/controllers/profiles/keys_controller_spec.rb
+++ b/spec/controllers/profiles/keys_controller_spec.rb
@@ -17,7 +17,25 @@ RSpec.describe Profiles::KeysController do
post :create, params: { key: build(:key, expires_at: expires_at).attributes }
end.to change { Key.count }.by(1)
- expect(Key.last.expires_at).to be_like_time(expires_at)
+ key = Key.last
+ expect(key.expires_at).to be_like_time(expires_at)
+ expect(key.fingerprint_md5).to be_present
+ expect(key.fingerprint_sha256).to be_present
+ end
+
+ context 'with FIPS mode', :fips_mode do
+ it 'creates a new key without MD5 fingerprint' do
+ expires_at = 3.days.from_now
+
+ expect do
+ post :create, params: { key: build(:rsa_key_4096, expires_at: expires_at).attributes }
+ end.to change { Key.count }.by(1)
+
+ key = Key.last
+ expect(key.expires_at).to be_like_time(expires_at)
+ expect(key.fingerprint_md5).to be_nil
+ expect(key.fingerprint_sha256).to be_present
+ end
end
end
end
diff --git a/spec/controllers/profiles/preferences_controller_spec.rb b/spec/controllers/profiles/preferences_controller_spec.rb
index b7870a63f9d..7add3a72337 100644
--- a/spec/controllers/profiles/preferences_controller_spec.rb
+++ b/spec/controllers/profiles/preferences_controller_spec.rb
@@ -46,6 +46,8 @@ RSpec.describe Profiles::PreferencesController do
it "changes the user's preferences" do
prefs = {
color_scheme_id: '1',
+ diffs_deletion_color: '#123456',
+ diffs_addition_color: '#abcdef',
dashboard: 'stars',
theme_id: '2',
first_day_of_week: '1',
@@ -84,5 +86,27 @@ RSpec.describe Profiles::PreferencesController do
expect(response.parsed_body['type']).to eq('alert')
end
end
+
+ context 'on invalid diffs colors setting' do
+ it 'responds with error for diffs_deletion_color' do
+ prefs = { diffs_deletion_color: '#1234567' }
+
+ go params: prefs
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(response.parsed_body['message']).to eq _('Failed to save preferences.')
+ expect(response.parsed_body['type']).to eq('alert')
+ end
+
+ it 'responds with error for diffs_addition_color' do
+ prefs = { diffs_addition_color: '#1234567' }
+
+ go params: prefs
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(response.parsed_body['message']).to eq _('Failed to save preferences.')
+ expect(response.parsed_body['type']).to eq('alert')
+ end
+ end
end
end
diff --git a/spec/controllers/profiles/two_factor_auths_controller_spec.rb b/spec/controllers/profiles/two_factor_auths_controller_spec.rb
index 47086ccdd2c..33cba675777 100644
--- a/spec/controllers/profiles/two_factor_auths_controller_spec.rb
+++ b/spec/controllers/profiles/two_factor_auths_controller_spec.rb
@@ -104,17 +104,29 @@ RSpec.describe Profiles::TwoFactorAuthsController do
expect(subject).to receive(:build_qr_code).and_return(code)
get :show
- expect(assigns[:qr_code]).to eq code
+ expect(assigns[:qr_code]).to eq(code)
end
- it 'generates a unique otp_secret every time the page is loaded' do
- expect(User).to receive(:generate_otp_secret).with(32).and_call_original.twice
+ it 'generates a single otp_secret with multiple page loads', :freeze_time do
+ expect(User).to receive(:generate_otp_secret).with(32).and_call_original.once
+
+ user.update!(otp_secret: nil, otp_secret_expires_at: nil)
2.times do
get :show
end
end
+ it 'generates a new otp_secret once the ttl has expired' do
+ expect(User).to receive(:generate_otp_secret).with(32).and_call_original.once
+
+ user.update!(otp_secret: "FT7KAVNU63YZH7PBRVPVL7CPSAENXY25", otp_secret_expires_at: 2.minutes.from_now)
+
+ travel_to(10.minutes.from_now) do
+ get :show
+ end
+ end
+
it_behaves_like 'user must first verify their primary email address' do
let(:go) { get :show }
end
@@ -183,7 +195,12 @@ RSpec.describe Profiles::TwoFactorAuthsController do
expect(subject).to receive(:build_qr_code).and_return(code)
go
- expect(assigns[:qr_code]).to eq code
+ expect(assigns[:qr_code]).to eq(code)
+ end
+
+ it 'assigns account_string' do
+ go
+ expect(assigns[:account_string]).to eq("#{Gitlab.config.gitlab.host}:#{user.email}")
end
it 'renders show' do
diff --git a/spec/controllers/projects/artifacts_controller_spec.rb b/spec/controllers/projects/artifacts_controller_spec.rb
index f410c16b30b..d51880b282d 100644
--- a/spec/controllers/projects/artifacts_controller_spec.rb
+++ b/spec/controllers/projects/artifacts_controller_spec.rb
@@ -323,6 +323,7 @@ RSpec.describe Projects::ArtifactsController do
subject
expect(response).to have_gitlab_http_status(:ok)
+ expect(response.headers['Gitlab-Workhorse-Detect-Content-Type']).to eq('true')
expect(send_data).to start_with('artifacts-entry:')
expect(params.keys).to eq(%w(Archive Entry))
@@ -338,7 +339,7 @@ RSpec.describe Projects::ArtifactsController do
def params
@params ||= begin
- base64_params = send_data.sub(/\Aartifacts\-entry:/, '')
+ base64_params = send_data.delete_prefix('artifacts-entry:')
Gitlab::Json.parse(Base64.urlsafe_decode64(base64_params))
end
end
diff --git a/spec/controllers/projects/branches_controller_spec.rb b/spec/controllers/projects/branches_controller_spec.rb
index ea22e6b6f10..1580ad9361d 100644
--- a/spec/controllers/projects/branches_controller_spec.rb
+++ b/spec/controllers/projects/branches_controller_spec.rb
@@ -688,21 +688,23 @@ RSpec.describe Projects::BranchesController do
end
context 'when gitaly is not available' do
+ let(:request) { get :index, format: :html, params: { namespace_id: project.namespace, project_id: project } }
+
before do
allow_next_instance_of(Gitlab::GitalyClient::RefService) do |ref_service|
allow(ref_service).to receive(:local_branches).and_raise(GRPC::DeadlineExceeded)
end
-
- get :index, format: :html, params: {
- namespace_id: project.namespace, project_id: project
- }
end
- it 'returns with a status 200' do
- expect(response).to have_gitlab_http_status(:ok)
+ it 'returns with a status 503' do
+ request
+
+ expect(response).to have_gitlab_http_status(:service_unavailable)
end
it 'sets gitaly_unavailable variable' do
+ request
+
expect(assigns[:gitaly_unavailable]).to be_truthy
end
end
diff --git a/spec/controllers/projects/commit_controller_spec.rb b/spec/controllers/projects/commit_controller_spec.rb
index 72fee40a6e9..a72c98552a5 100644
--- a/spec/controllers/projects/commit_controller_spec.rb
+++ b/spec/controllers/projects/commit_controller_spec.rb
@@ -60,6 +60,22 @@ RSpec.describe Projects::CommitController do
end
end
+ context 'with valid page' do
+ it 'responds with 200' do
+ go(id: commit.id, page: 1)
+
+ expect(response).to be_ok
+ end
+ end
+
+ context 'with invalid page' do
+ it 'does not return an error' do
+ go(id: commit.id, page: ['invalid'])
+
+ expect(response).to be_ok
+ end
+ end
+
it 'handles binary files' do
go(id: TestEnv::BRANCH_SHA['binary-encoding'], format: 'html')
@@ -212,6 +228,21 @@ RSpec.describe Projects::CommitController do
end
end
+ context 'when the revert commit is missing' do
+ it 'renders the 404 page' do
+ post(:revert,
+ params: {
+ namespace_id: project.namespace,
+ project_id: project,
+ start_branch: 'master',
+ id: '1234567890'
+ })
+
+ expect(response).not_to be_successful
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
context 'when the revert was successful' do
it 'redirects to the commits page' do
post(:revert,
@@ -269,6 +300,21 @@ RSpec.describe Projects::CommitController do
end
end
+ context 'when the cherry-pick commit is missing' do
+ it 'renders the 404 page' do
+ post(:cherry_pick,
+ params: {
+ namespace_id: project.namespace,
+ project_id: project,
+ start_branch: 'master',
+ id: '1234567890'
+ })
+
+ expect(response).not_to be_successful
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
context 'when the cherry-pick was successful' do
it 'redirects to the commits page' do
post(:cherry_pick,
diff --git a/spec/controllers/projects/compare_controller_spec.rb b/spec/controllers/projects/compare_controller_spec.rb
index 62b93a2728b..9821618df8d 100644
--- a/spec/controllers/projects/compare_controller_spec.rb
+++ b/spec/controllers/projects/compare_controller_spec.rb
@@ -58,11 +58,13 @@ RSpec.describe Projects::CompareController do
from_project_id: from_project_id,
from: from_ref,
to: to_ref,
- w: whitespace
+ w: whitespace,
+ page: page
}
end
let(:whitespace) { nil }
+ let(:page) { nil }
context 'when the refs exist in the same project' do
context 'when we set the white space param' do
@@ -196,6 +198,34 @@ RSpec.describe Projects::CompareController do
expect(response).to have_gitlab_http_status(:found)
end
end
+
+ context 'when page is valid' do
+ let(:from_project_id) { nil }
+ let(:from_ref) { '08f22f25' }
+ let(:to_ref) { '66eceea0' }
+ let(:page) { 1 }
+
+ it 'shows the diff' do
+ show_request
+
+ expect(response).to be_successful
+ expect(assigns(:diffs).diff_files.first).to be_present
+ expect(assigns(:commits).length).to be >= 1
+ end
+ end
+
+ context 'when page is not valid' do
+ let(:from_project_id) { nil }
+ let(:from_ref) { '08f22f25' }
+ let(:to_ref) { '66eceea0' }
+ let(:page) { ['invalid'] }
+
+ it 'does not return an error' do
+ show_request
+
+ expect(response).to be_successful
+ end
+ end
end
describe 'GET diff_for_path' do
diff --git a/spec/controllers/projects/environments_controller_spec.rb b/spec/controllers/projects/environments_controller_spec.rb
index fdfc21887a6..f4cad5790a3 100644
--- a/spec/controllers/projects/environments_controller_spec.rb
+++ b/spec/controllers/projects/environments_controller_spec.rb
@@ -254,38 +254,54 @@ RSpec.describe Projects::EnvironmentsController do
end
describe 'PATCH #stop' do
+ subject { patch :stop, params: environment_params(format: :json) }
+
context 'when env not available' do
it 'returns 404' do
allow_any_instance_of(Environment).to receive(:available?) { false }
- patch :stop, params: environment_params(format: :json)
+ subject
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'when stop action' do
- it 'returns action url' do
+ it 'returns action url for single stop action' do
action = create(:ci_build, :manual)
allow_any_instance_of(Environment)
- .to receive_messages(available?: true, stop_with_action!: action)
+ .to receive_messages(available?: true, stop_with_actions!: [action])
- patch :stop, params: environment_params(format: :json)
+ subject
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to eq(
{ 'redirect_url' =>
project_job_url(project, action) })
end
+
+ it 'returns environment url for multiple stop actions' do
+ actions = create_list(:ci_build, 2, :manual)
+
+ allow_any_instance_of(Environment)
+ .to receive_messages(available?: true, stop_with_actions!: actions)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to eq(
+ { 'redirect_url' =>
+ project_environment_url(project, environment) })
+ end
end
context 'when no stop action' do
it 'returns env url' do
allow_any_instance_of(Environment)
- .to receive_messages(available?: true, stop_with_action!: nil)
+ .to receive_messages(available?: true, stop_with_actions!: nil)
- patch :stop, params: environment_params(format: :json)
+ subject
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to eq(
diff --git a/spec/controllers/projects/group_links_controller_spec.rb b/spec/controllers/projects/group_links_controller_spec.rb
index ea15d483c90..96705d82ac5 100644
--- a/spec/controllers/projects/group_links_controller_spec.rb
+++ b/spec/controllers/projects/group_links_controller_spec.rb
@@ -18,136 +18,6 @@ RSpec.describe Projects::GroupLinksController do
travel_back
end
- describe '#create' do
- shared_context 'link project to group' do
- before do
- post(:create, params: {
- namespace_id: project.namespace,
- project_id: project,
- link_group_id: group.id,
- link_group_access: ProjectGroupLink.default_access
- })
- end
- end
-
- context 'when project is not allowed to be shared with a group' do
- before do
- group.update!(share_with_group_lock: false)
- end
-
- include_context 'link project to group'
-
- it 'responds with status 404' do
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
-
- context 'when user has access to group they want to link project to' do
- before do
- group.add_developer(user)
- end
-
- include_context 'link project to group'
-
- it 'links project with selected group' do
- expect(group.shared_projects).to include project
- end
-
- it 'redirects to project group links page' do
- expect(response).to redirect_to(
- project_project_members_path(project)
- )
- end
- end
-
- context 'when user doers not have access to group they want to link to' do
- include_context 'link project to group'
-
- it 'renders 404' do
- expect(response).to have_gitlab_http_status(:not_found)
- end
-
- it 'does not share project with that group' do
- expect(group.shared_projects).not_to include project
- end
- end
-
- context 'when user does not have access to the public group' do
- let(:group) { create(:group, :public) }
-
- include_context 'link project to group'
-
- it 'renders 404' do
- expect(response).to have_gitlab_http_status(:not_found)
- end
-
- it 'does not share project with that group' do
- expect(group.shared_projects).not_to include project
- end
- end
-
- context 'when project group id equal link group id' do
- before do
- group2.add_developer(user)
-
- post(:create, params: {
- namespace_id: project.namespace,
- project_id: project,
- link_group_id: group2.id,
- link_group_access: ProjectGroupLink.default_access
- })
- end
-
- it 'does not share project with selected group' do
- expect(group2.shared_projects).not_to include project
- end
-
- it 'redirects to project group links page' do
- expect(response).to redirect_to(
- project_project_members_path(project)
- )
- end
- end
-
- context 'when link group id is not present' do
- before do
- post(:create, params: {
- namespace_id: project.namespace,
- project_id: project,
- link_group_access: ProjectGroupLink.default_access
- })
- end
-
- it 'redirects to project group links page' do
- expect(response).to redirect_to(
- project_project_members_path(project)
- )
- expect(flash[:alert]).to eq('Please select a group.')
- end
- end
-
- context 'when link is not persisted in the database' do
- before do
- allow(::Projects::GroupLinks::CreateService).to receive_message_chain(:new, :execute)
- .and_return({ status: :error, http_status: 409, message: 'error' })
-
- post(:create, params: {
- namespace_id: project.namespace,
- project_id: project,
- link_group_id: group.id,
- link_group_access: ProjectGroupLink.default_access
- })
- end
-
- it 'redirects to project group links page' do
- expect(response).to redirect_to(
- project_project_members_path(project)
- )
- expect(flash[:alert]).to eq('error')
- end
- end
- end
-
describe '#update' do
let_it_be(:link) do
create(
diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb
index 9d3711d8a96..ce0af784cdf 100644
--- a/spec/controllers/projects/issues_controller_spec.rb
+++ b/spec/controllers/projects/issues_controller_spec.rb
@@ -148,6 +148,13 @@ RSpec.describe Projects::IssuesController do
allow(Kaminari.config).to receive(:default_per_page).and_return(1)
end
+ it 'redirects to last page when out of bounds on non-html requests' do
+ get :index, params: params.merge(page: last_page + 1), format: 'atom'
+
+ expect(response).to have_gitlab_http_status(:redirect)
+ expect(response).to redirect_to(action: 'index', format: 'atom', page: last_page, state: 'opened')
+ end
+
it 'does not use pagination if disabled' do
allow(controller).to receive(:pagination_disabled?).and_return(true)
diff --git a/spec/controllers/projects/jobs_controller_spec.rb b/spec/controllers/projects/jobs_controller_spec.rb
index ed68d6a87b8..e9f1232b5e7 100644
--- a/spec/controllers/projects/jobs_controller_spec.rb
+++ b/spec/controllers/projects/jobs_controller_spec.rb
@@ -796,7 +796,7 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state do
retried_build = Ci::Build.last
- Ci::RetryBuildService.clone_accessors.each do |accessor|
+ Ci::Build.clone_accessors.each do |accessor|
expect(job.read_attribute(accessor))
.to eq(retried_build.read_attribute(accessor)),
"Mismatched attribute on \"#{accessor}\". " \
diff --git a/spec/controllers/projects/notes_controller_spec.rb b/spec/controllers/projects/notes_controller_spec.rb
index 2df31904380..07874c8a8af 100644
--- a/spec/controllers/projects/notes_controller_spec.rb
+++ b/spec/controllers/projects/notes_controller_spec.rb
@@ -423,7 +423,21 @@ RSpec.describe Projects::NotesController do
end
context 'when creating a confidential note' do
- let(:extra_request_params) { { format: :json } }
+ let(:project) { create(:project) }
+ let(:note_params) do
+ { note: note_text, noteable_id: issue.id, noteable_type: 'Issue' }.merge(extra_note_params)
+ end
+
+ let(:request_params) do
+ {
+ note: note_params,
+ namespace_id: project.namespace,
+ project_id: project,
+ target_type: 'issue',
+ target_id: issue.id,
+ format: :json
+ }
+ end
context 'when `confidential` parameter is not provided' do
it 'sets `confidential` to `false` in JSON response' do
diff --git a/spec/controllers/projects/packages/infrastructure_registry_controller_spec.rb b/spec/controllers/projects/packages/infrastructure_registry_controller_spec.rb
index a655c742973..fc741d0f3f6 100644
--- a/spec/controllers/projects/packages/infrastructure_registry_controller_spec.rb
+++ b/spec/controllers/projects/packages/infrastructure_registry_controller_spec.rb
@@ -41,17 +41,5 @@ RSpec.describe Projects::Packages::InfrastructureRegistryController do
it_behaves_like 'returning response status', :not_found
end
-
- context 'with package file pending destruction' do
- let_it_be(:package_file_pending_destruction) { create(:package_file, :pending_destruction, package: terraform_module) }
-
- let(:terraform_module_package_file) { terraform_module.package_files.first }
-
- it 'does not return them' do
- subject
-
- expect(assigns(:package_files)).to contain_exactly(terraform_module_package_file)
- end
- end
end
end
diff --git a/spec/controllers/projects/pipelines/tests_controller_spec.rb b/spec/controllers/projects/pipelines/tests_controller_spec.rb
index e6ff3a487ac..113781bab7c 100644
--- a/spec/controllers/projects/pipelines/tests_controller_spec.rb
+++ b/spec/controllers/projects/pipelines/tests_controller_spec.rb
@@ -40,28 +40,56 @@ RSpec.describe Projects::Pipelines::TestsController do
let(:suite_name) { 'test' }
let(:build_ids) { pipeline.latest_builds.pluck(:id) }
- before do
- build = main_pipeline.builds.last
- build.update_column(:finished_at, 1.day.ago) # Just to be sure we are included in the report window
-
- # The JUnit fixture for the given build has 3 failures.
- # This service will create 1 test case failure record for each.
- Ci::TestFailureHistoryService.new(main_pipeline).execute
+ context 'when artifacts are expired' do
+ before do
+ pipeline.job_artifacts.first.update!(expire_at: Date.yesterday)
+ end
+
+ it 'renders not_found errors', :aggregate_failures do
+ get_tests_show_json(build_ids)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(json_response['errors']).to eq('Test report artifacts have expired')
+ end
+
+ context 'when ci_test_report_artifacts_expired is disabled' do
+ before do
+ stub_feature_flags(ci_test_report_artifacts_expired: false)
+ end
+ it 'renders test suite', :aggregate_failures do
+ get_tests_show_json(build_ids)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['name']).to eq('test')
+ end
+ end
end
- it 'renders test suite data' do
- get_tests_show_json(build_ids)
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response['name']).to eq('test')
-
- # Each test failure in this pipeline has a matching failure in the default branch
- recent_failures = json_response['test_cases'].map { |tc| tc['recent_failures'] }
- expect(recent_failures).to eq([
- { 'count' => 1, 'base_branch' => 'master' },
- { 'count' => 1, 'base_branch' => 'master' },
- { 'count' => 1, 'base_branch' => 'master' }
- ])
+ context 'when artifacts are not expired' do
+ before do
+ build = main_pipeline.builds.last
+ build.update_column(:finished_at, 1.day.ago) # Just to be sure we are included in the report window
+
+ # The JUnit fixture for the given build has 3 failures.
+ # This service will create 1 test case failure record for each.
+ Ci::TestFailureHistoryService.new(main_pipeline).execute
+ end
+
+ it 'renders test suite data', :aggregate_failures do
+ get_tests_show_json(build_ids)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['name']).to eq('test')
+ expect(json_response['artifacts_expired']).to be_falsey
+
+ # Each test failure in this pipeline has a matching failure in the default branch
+ recent_failures = json_response['test_cases'].map { |tc| tc['recent_failures'] }
+ expect(recent_failures).to eq([
+ { 'count' => 1, 'base_branch' => 'master' },
+ { 'count' => 1, 'base_branch' => 'master' },
+ { 'count' => 1, 'base_branch' => 'master' }
+ ])
+ end
end
end
diff --git a/spec/controllers/projects/services_controller_spec.rb b/spec/controllers/projects/services_controller_spec.rb
index 35e5422d072..7e96c59fbb1 100644
--- a/spec/controllers/projects/services_controller_spec.rb
+++ b/spec/controllers/projects/services_controller_spec.rb
@@ -359,10 +359,9 @@ RSpec.describe Projects::ServicesController do
def prometheus_integration_as_data
pi = project.prometheus_integration.reload
attrs = pi.attributes.except('encrypted_properties',
- 'encrypted_properties_iv',
- 'encrypted_properties_tmp')
+ 'encrypted_properties_iv')
- [attrs, pi.encrypted_properties_tmp]
+ [attrs, pi.properties]
end
end
diff --git a/spec/controllers/projects/static_site_editor_controller_spec.rb b/spec/controllers/projects/static_site_editor_controller_spec.rb
index 26161b5fb5c..e1f25589eeb 100644
--- a/spec/controllers/projects/static_site_editor_controller_spec.rb
+++ b/spec/controllers/projects/static_site_editor_controller_spec.rb
@@ -76,12 +76,11 @@ RSpec.describe Projects::StaticSiteEditorController do
get :show, params: default_params
end
- it 'increases the views counter' do
- expect(Gitlab::UsageDataCounters::StaticSiteEditorCounter).to have_received(:increment_views_count)
- end
+ it 'redirects to the Web IDE' do
+ get :show, params: default_params
- it 'renders the edit page' do
- expect(response).to render_template(:show)
+ expected_path_regex = %r[-/ide/project/#{project.full_path}/edit/master/-/README.md]
+ expect(response).to redirect_to(expected_path_regex)
end
it 'assigns ref and path variables' do
@@ -96,62 +95,6 @@ RSpec.describe Projects::StaticSiteEditorController do
expect(response).to have_gitlab_http_status(:not_found)
end
end
-
- context 'when invalid config file' do
- let(:service_response) { ServiceResponse.error(message: 'invalid') }
-
- it 'redirects to project page and flashes error message' do
- expect(response).to redirect_to(project_path(project))
- expect(controller).to set_flash[:alert].to('invalid')
- end
- end
-
- context 'with a service response payload containing multiple data types' do
- let(:data) do
- {
- a_string: 'string',
- an_array: [
- {
- foo: 'bar'
- }
- ],
- an_integer: 123,
- a_hash: {
- a_deeper_hash: {
- foo: 'bar'
- }
- },
- a_boolean: true,
- a_nil: nil
- }
- end
-
- let(:assigns_data) { assigns(:data) }
-
- it 'leaves data values which are strings as strings' do
- expect(assigns_data[:a_string]).to eq('string')
- end
-
- it 'leaves data values which are integers as integers' do
- expect(assigns_data[:an_integer]).to eq(123)
- end
-
- it 'serializes data values which are booleans to JSON' do
- expect(assigns_data[:a_boolean]).to eq('true')
- end
-
- it 'serializes data values which are arrays to JSON' do
- expect(assigns_data[:an_array]).to eq('[{"foo":"bar"}]')
- end
-
- it 'serializes data values which are hashes to JSON' do
- expect(assigns_data[:a_hash]).to eq('{"a_deeper_hash":{"foo":"bar"}}')
- end
-
- it 'serializes data values which are nil to an empty string' do
- expect(assigns_data[:a_nil]).to eq('')
- end
- end
end
end
end
diff --git a/spec/controllers/projects/todos_controller_spec.rb b/spec/controllers/projects/todos_controller_spec.rb
index 9a73417ffdb..d87f4258b9c 100644
--- a/spec/controllers/projects/todos_controller_spec.rb
+++ b/spec/controllers/projects/todos_controller_spec.rb
@@ -8,7 +8,7 @@ RSpec.describe Projects::TodosController do
let(:issue) { create(:issue, project: project) }
let(:merge_request) { create(:merge_request, source_project: project) }
- let(:design) { create(:design, project: project, issue: issue) }
+ let(:design) { create(:design, :with_versions, project: project, issue: issue) }
let(:parent) { project }
shared_examples 'issuable todo actions' do
diff --git a/spec/controllers/projects/usage_quotas_controller_spec.rb b/spec/controllers/projects/usage_quotas_controller_spec.rb
index 6125ba13f96..2831de00348 100644
--- a/spec/controllers/projects/usage_quotas_controller_spec.rb
+++ b/spec/controllers/projects/usage_quotas_controller_spec.rb
@@ -4,17 +4,44 @@ require 'spec_helper'
RSpec.describe Projects::UsageQuotasController do
let_it_be(:user) { create(:user) }
- let_it_be(:project) { create(:project, namespace: user.namespace) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, group: group) }
describe 'GET #index' do
render_views
- it 'does not render search settings partial' do
+ subject { get(:index, params: { namespace_id: project.namespace, project_id: project }) }
+
+ before do
sign_in(user)
- get(:index, params: { namespace_id: user.namespace, project_id: project })
+ end
+
+ context 'when user does not have read_usage_quotas permission' do
+ before do
+ project.add_developer(user)
+ end
+
+ it 'renders not_found' do
+ subject
+
+ expect(response).to render_template('errors/not_found')
+ expect(response).not_to render_template('shared/search_settings')
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'when user has read_usage_quotas permission' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ it 'renders index with 200 status code' do
+ subject
- expect(response).to render_template('index')
- expect(response).not_to render_template('shared/search_settings')
+ expect(response).to render_template('index')
+ expect(response).not_to render_template('shared/search_settings')
+ expect(response).to have_gitlab_http_status(:ok)
+ end
end
end
end
diff --git a/spec/controllers/projects_controller_spec.rb b/spec/controllers/projects_controller_spec.rb
index c098ea71f7a..07bd198137a 100644
--- a/spec/controllers/projects_controller_spec.rb
+++ b/spec/controllers/projects_controller_spec.rb
@@ -473,28 +473,6 @@ RSpec.describe ProjectsController do
end
end
end
-
- context 'with new_project_sast_enabled', :experiment do
- let(:params) do
- {
- path: 'foo',
- description: 'bar',
- namespace_id: user.namespace.id,
- initialize_with_sast: '1'
- }
- end
-
- it 'tracks an event on project creation' do
- expect(experiment(:new_project_sast_enabled)).to track(:created,
- property: 'blank',
- checked: true,
- project: an_instance_of(Project),
- namespace: user.namespace
- ).on_next_instance.with_context(user: user)
-
- post :create, params: { project: params }
- end
- end
end
describe 'GET edit' do
@@ -1159,16 +1137,15 @@ RSpec.describe ProjectsController do
context 'when gitaly is unavailable' do
before do
expect_next_instance_of(TagsFinder) do |finder|
- allow(finder).to receive(:execute).and_raise(Gitlab::Git::CommandError)
+ allow(finder).to receive(:execute).and_raise(Gitlab::Git::CommandError, 'something went wrong')
end
end
- it 'gets an empty list of tags' do
+ it 'responds with 503 error' do
get :refs, params: { namespace_id: project.namespace, id: project, ref: "123456" }
- expect(json_response["Branches"]).to include("master")
- expect(json_response["Tags"]).to eq([])
- expect(json_response["Commits"]).to include("123456")
+ expect(response).to have_gitlab_http_status(:service_unavailable)
+ expect(json_response['error']).to eq 'Unable to load refs'
end
end
@@ -1466,14 +1443,15 @@ RSpec.describe ProjectsController do
end
describe '#download_export', :clean_gitlab_redis_rate_limiting do
+ let(:project) { create(:project, :with_export, service_desk_enabled: false) }
let(:action) { :download_export }
context 'object storage enabled' do
context 'when project export is enabled' do
- it 'returns 302' do
+ it 'returns 200' do
get action, params: { namespace_id: project.namespace, id: project }
- expect(response).to have_gitlab_http_status(:found)
+ expect(response).to have_gitlab_http_status(:ok)
end
end
@@ -1513,14 +1491,37 @@ RSpec.describe ProjectsController do
expect(response.body).to eq('This endpoint has been requested too many times. Try again later.')
expect(response).to have_gitlab_http_status(:too_many_requests)
end
+ end
+
+ context 'applies correct scope when throttling', :clean_gitlab_redis_rate_limiting do
+ before do
+ stub_application_setting(project_download_export_limit: 1)
+ end
- it 'applies correct scope when throttling' do
+ it 'applies throttle per namespace' do
expect(Gitlab::ApplicationRateLimiter)
.to receive(:throttled?)
- .with(:project_download_export, scope: [user, project])
+ .with(:project_download_export, scope: [user, project.namespace])
post action, params: { namespace_id: project.namespace, id: project }
end
+
+ it 'throttles downloads within same namespaces' do
+ # simulate prior request to the same namespace, which increments the rate limit counter for that scope
+ Gitlab::ApplicationRateLimiter.throttled?(:project_download_export, scope: [user, project.namespace])
+
+ get action, params: { namespace_id: project.namespace, id: project }
+ expect(response).to have_gitlab_http_status(:too_many_requests)
+ end
+
+ it 'allows downloads from different namespaces' do
+ # simulate prior request to a different namespace, which increments the rate limit counter for that scope
+ Gitlab::ApplicationRateLimiter.throttled?(:project_download_export,
+ scope: [user, create(:project, :with_export).namespace])
+
+ get action, params: { namespace_id: project.namespace, id: project }
+ expect(response).to have_gitlab_http_status(:ok)
+ end
end
end
end
diff --git a/spec/controllers/search_controller_spec.rb b/spec/controllers/search_controller_spec.rb
index 9482448fc03..4abcd414e51 100644
--- a/spec/controllers/search_controller_spec.rb
+++ b/spec/controllers/search_controller_spec.rb
@@ -211,6 +211,7 @@ RSpec.describe SearchController do
:global_search_merge_requests_tab | 'merge_requests'
:global_search_wiki_tab | 'wiki_blobs'
:global_search_commits_tab | 'commits'
+ :global_search_users_tab | 'users'
end
with_them do
diff --git a/spec/controllers/sessions_controller_spec.rb b/spec/controllers/sessions_controller_spec.rb
index 03d053e6f97..877ca7cd6c6 100644
--- a/spec/controllers/sessions_controller_spec.rb
+++ b/spec/controllers/sessions_controller_spec.rb
@@ -193,6 +193,10 @@ RSpec.describe SessionsController do
end
context 'with reCAPTCHA' do
+ before do
+ stub_feature_flags(arkose_labs_login_challenge: false)
+ end
+
def unsuccesful_login(user_params, sesion_params: {})
# Without this, `verify_recaptcha` arbitrarily returns true in test env
Recaptcha.configuration.skip_verify_env.delete('test')
@@ -234,7 +238,7 @@ RSpec.describe SessionsController do
unsuccesful_login(user_params)
- expect(response).to render_template(:new)
+ expect(response).to redirect_to new_user_session_path
expect(flash[:alert]).to include _('There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.')
expect(subject.current_user).to be_nil
end
@@ -258,7 +262,7 @@ RSpec.describe SessionsController do
it 'displays an error when the reCAPTCHA is not solved' do
unsuccesful_login(user_params, sesion_params: { failed_login_attempts: 6 })
- expect(response).to render_template(:new)
+ expect(response).to redirect_to new_user_session_path
expect(flash[:alert]).to include _('There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.')
expect(subject.current_user).to be_nil
end
@@ -278,7 +282,7 @@ RSpec.describe SessionsController do
it 'displays an error when the reCAPTCHA is not solved' do
unsuccesful_login(user_params)
- expect(response).to render_template(:new)
+ expect(response).to redirect_to new_user_session_path
expect(flash[:alert]).to include _('There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.')
expect(subject.current_user).to be_nil
end
diff --git a/spec/controllers/uploads_controller_spec.rb b/spec/controllers/uploads_controller_spec.rb
index 8442c214cd3..ffcd759435c 100644
--- a/spec/controllers/uploads_controller_spec.rb
+++ b/spec/controllers/uploads_controller_spec.rb
@@ -701,6 +701,24 @@ RSpec.describe UploadsController do
end
end
end
+
+ context 'when viewing alert metric images' do
+ let!(:user) { create(:user) }
+ let!(:project) { create(:project) }
+ let(:alert) { create(:alert_management_alert, project: project) }
+ let(:metric_image) { create(:alert_metric_image, alert: alert) }
+
+ before do
+ project.add_developer(user)
+ sign_in(user)
+ end
+
+ it "responds with status 200" do
+ get :show, params: { model: "alert_management_metric_image", mounted_as: 'file', id: metric_image.id, filename: metric_image.filename }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
end
def post_authorize(verified: true)
diff --git a/spec/db/migration_spec.rb b/spec/db/migration_spec.rb
new file mode 100644
index 00000000000..ac649925751
--- /dev/null
+++ b/spec/db/migration_spec.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Migrations Validation' do
+ using RSpec::Parameterized::TableSyntax
+
+ # The range describes the timestamps that given migration helper can be used
+ let(:all_migration_classes) do
+ {
+ 2022_01_26_21_06_58.. => Gitlab::Database::Migration[2.0],
+ 2021_09_01_15_33_24.. => Gitlab::Database::Migration[1.0],
+ 2021_05_31_05_39_16..2021_09_01_15_33_24 => ActiveRecord::Migration[6.1],
+ ..2021_05_31_05_39_16 => ActiveRecord::Migration[6.0]
+ }
+ end
+
+ where(:migration) do
+ Gitlab::Database.database_base_models.flat_map do |_, model|
+ model.connection.migration_context.migrations
+ end.uniq
+ end
+
+ with_them do
+ let(:migration_instance) { migration.send(:migration) }
+ let(:allowed_migration_classes) { all_migration_classes.select { |r, _| r.include?(migration.version) }.values }
+
+ it 'uses one of the allowed migration classes' do
+ expect(allowed_migration_classes).to include(be > migration_instance.class)
+ end
+ end
+end
diff --git a/spec/db/schema_spec.rb b/spec/db/schema_spec.rb
index 177a565bbc0..04f73050ea5 100644
--- a/spec/db/schema_spec.rb
+++ b/spec/db/schema_spec.rb
@@ -10,6 +10,10 @@ RSpec.describe 'Database schema' do
let(:tables) { connection.tables }
let(:columns_name_with_jsonb) { retrieve_columns_name_with_jsonb }
+ IGNORED_INDEXES_ON_FKS = {
+ issues: %w[work_item_type_id]
+ }.with_indifferent_access.freeze
+
# List of columns historically missing a FK, don't add more columns
# See: https://docs.gitlab.com/ee/development/foreign_keys.html#naming-foreign-keys
IGNORED_FK_COLUMNS = {
@@ -18,7 +22,7 @@ RSpec.describe 'Database schema' do
approvals: %w[user_id],
approver_groups: %w[target_id],
approvers: %w[target_id user_id],
- analytics_cycle_analytics_aggregations: %w[last_full_run_issues_id last_full_run_merge_requests_id last_incremental_issues_id last_incremental_merge_requests_id],
+ analytics_cycle_analytics_aggregations: %w[last_full_issues_id last_full_merge_requests_id last_incremental_issues_id last_full_run_issues_id last_full_run_merge_requests_id last_incremental_merge_requests_id],
analytics_cycle_analytics_merge_request_stage_events: %w[author_id group_id merge_request_id milestone_id project_id stage_event_hash_id state_id],
analytics_cycle_analytics_issue_stage_events: %w[author_id group_id issue_id milestone_id project_id stage_event_hash_id state_id],
audit_events: %w[author_id entity_id target_id],
@@ -115,6 +119,7 @@ RSpec.describe 'Database schema' do
columns.first.chomp
end
foreign_keys_columns = all_foreign_keys.map(&:column)
+ required_indexed_columns = foreign_keys_columns - ignored_index_columns(table)
# Add the primary key column to the list of indexed columns because
# postgres and mysql both automatically create an index on the primary
@@ -122,7 +127,7 @@ RSpec.describe 'Database schema' do
# automatically generated indexes (like the primary key index).
first_indexed_column.push(primary_key_column)
- expect(first_indexed_column.uniq).to include(*foreign_keys_columns)
+ expect(first_indexed_column.uniq).to include(*required_indexed_columns)
end
end
@@ -175,18 +180,16 @@ RSpec.describe 'Database schema' do
'PrometheusAlert' => %w[operator]
}.freeze
- context 'for enums' do
- ApplicationRecord.descendants.each do |model|
- # skip model if it is an abstract class as it would not have an associated DB table
- next if model.abstract_class?
+ context 'for enums', :eager_load do
+ # skip model if it is an abstract class as it would not have an associated DB table
+ let(:models) { ApplicationRecord.descendants.reject(&:abstract_class?) }
- describe model do
- let(:ignored_enums) { ignored_limit_enums(model.name) }
- let(:enums) { model.defined_enums.keys - ignored_enums }
+ it 'uses smallint for enums in all models', :aggregate_failures do
+ models.each do |model|
+ ignored_enums = ignored_limit_enums(model.name)
+ enums = model.defined_enums.keys - ignored_enums
- it 'uses smallint for enums' do
- expect(model).to use_smallint_for_enums(enums)
- end
+ expect(model).to use_smallint_for_enums(enums)
end
end
end
@@ -305,8 +308,12 @@ RSpec.describe 'Database schema' do
@models_by_table_name ||= ApplicationRecord.descendants.reject(&:abstract_class).group_by(&:table_name)
end
- def ignored_fk_columns(column)
- IGNORED_FK_COLUMNS.fetch(column, [])
+ def ignored_fk_columns(table)
+ IGNORED_FK_COLUMNS.fetch(table, [])
+ end
+
+ def ignored_index_columns(table)
+ IGNORED_INDEXES_ON_FKS.fetch(table, [])
end
def ignored_limit_enums(model)
diff --git a/spec/deprecation_toolkit_env.rb b/spec/deprecation_toolkit_env.rb
index 5e7ff34463c..fa4fdf805ec 100644
--- a/spec/deprecation_toolkit_env.rb
+++ b/spec/deprecation_toolkit_env.rb
@@ -56,11 +56,8 @@ module DeprecationToolkitEnv
# In this case, we recommend to add a silence together with an issue to patch or update
# the dependency causing the problem.
# See https://gitlab.com/gitlab-org/gitlab/-/commit/aea37f506bbe036378998916d374966c031bf347#note_647515736
- #
- # - ruby/lib/grpc/generic/interceptors.rb: https://gitlab.com/gitlab-org/gitlab/-/issues/339305
def self.allowed_kwarg_warning_paths
%w[
- ruby/lib/grpc/generic/interceptors.rb
]
end
diff --git a/spec/events/ci/pipeline_created_event_spec.rb b/spec/events/ci/pipeline_created_event_spec.rb
new file mode 100644
index 00000000000..191c2e450dc
--- /dev/null
+++ b/spec/events/ci/pipeline_created_event_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::PipelineCreatedEvent do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:data, :valid) do
+ { pipeline_id: 1 } | true
+ { pipeline_id: nil } | false
+ { pipeline_id: "test" } | false
+ {} | false
+ { job_id: 1 } | false
+ end
+
+ with_them do
+ let(:event) { described_class.new(data: data) }
+
+ it 'validates the data according to the schema' do
+ if valid
+ expect { event }.not_to raise_error
+ else
+ expect { event }.to raise_error(Gitlab::EventStore::InvalidEvent)
+ end
+ end
+ end
+end
diff --git a/spec/experiments/ios_specific_templates_experiment_spec.rb b/spec/experiments/ios_specific_templates_experiment_spec.rb
new file mode 100644
index 00000000000..4d02381dbde
--- /dev/null
+++ b/spec/experiments/ios_specific_templates_experiment_spec.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe IosSpecificTemplatesExperiment do
+ subject do
+ described_class.new(actor: user, project: project) do |e|
+ e.candidate { true }
+ end.run
+ end
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project, :auto_devops_disabled) }
+
+ let!(:project_setting) { create(:project_setting, project: project, target_platforms: target_platforms) }
+ let(:target_platforms) { %w(ios) }
+
+ before do
+ stub_experiments(ios_specific_templates: :candidate)
+ project.add_developer(user) if user
+ end
+
+ it { is_expected.to be true }
+
+ describe 'skipping the experiment' do
+ context 'no actor' do
+ let_it_be(:user) { nil }
+
+ it { is_expected.to be_falsey }
+ end
+
+ context 'actor cannot create pipelines' do
+ before do
+ project.add_guest(user)
+ end
+
+ it { is_expected.to be_falsey }
+ end
+
+ context 'targeting a non iOS platform' do
+ let(:target_platforms) { [] }
+
+ it { is_expected.to be_falsey }
+ end
+
+ context 'project has a ci.yaml file' do
+ before do
+ allow(project).to receive(:has_ci?).and_return(true)
+ end
+
+ it { is_expected.to be_falsey }
+ end
+
+ context 'project has pipelines' do
+ before do
+ create(:ci_pipeline, project: project)
+ end
+
+ it { is_expected.to be_falsey }
+ end
+ end
+end
diff --git a/spec/experiments/new_project_sast_enabled_experiment_spec.rb b/spec/experiments/new_project_sast_enabled_experiment_spec.rb
deleted file mode 100644
index 041e5dfa469..00000000000
--- a/spec/experiments/new_project_sast_enabled_experiment_spec.rb
+++ /dev/null
@@ -1,20 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe NewProjectSastEnabledExperiment do
- it "defines the expected behaviors and variants" do
- expect(subject.variant_names).to match_array([
- :candidate,
- :free_indicator,
- :unchecked_candidate,
- :unchecked_free_indicator
- ])
- end
-
- it "publishes to the database" do
- expect(subject).to receive(:publish_to_database)
-
- subject.publish
- end
-end
diff --git a/spec/experiments/video_tutorials_continuous_onboarding_experiment_spec.rb b/spec/experiments/video_tutorials_continuous_onboarding_experiment_spec.rb
new file mode 100644
index 00000000000..596791308a4
--- /dev/null
+++ b/spec/experiments/video_tutorials_continuous_onboarding_experiment_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe VideoTutorialsContinuousOnboardingExperiment do
+ it "defines a control and candidate" do
+ expect(subject.behaviors.keys).to match_array(%w[control candidate])
+ end
+end
diff --git a/spec/factories/alert_management/metric_images.rb b/spec/factories/alert_management/metric_images.rb
new file mode 100644
index 00000000000..d7d8182af3e
--- /dev/null
+++ b/spec/factories/alert_management/metric_images.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :alert_metric_image, class: 'AlertManagement::MetricImage' do
+ association :alert, factory: :alert_management_alert
+ url { generate(:url) }
+
+ trait :local do
+ file_store { ObjectStorage::Store::LOCAL }
+ end
+
+ after(:build) do |image|
+ image.file = fixture_file_upload('spec/fixtures/rails_sample.jpg', 'image/jpg')
+ end
+ end
+end
diff --git a/spec/factories/application_settings.rb b/spec/factories/application_settings.rb
index 8ac003d0a98..c28d3c20a86 100644
--- a/spec/factories/application_settings.rb
+++ b/spec/factories/application_settings.rb
@@ -4,5 +4,6 @@ FactoryBot.define do
factory :application_setting do
default_projects_limit { 42 }
import_sources { [] }
+ restricted_visibility_levels { [] }
end
end
diff --git a/spec/factories/ci/builds.rb b/spec/factories/ci/builds.rb
index 011021f6320..56c12d73a3b 100644
--- a/spec/factories/ci/builds.rb
+++ b/spec/factories/ci/builds.rb
@@ -10,7 +10,7 @@ FactoryBot.define do
options do
{
- image: 'ruby:2.7',
+ image: 'image:1.0',
services: ['postgres'],
script: ['ls -a']
}
@@ -175,6 +175,58 @@ FactoryBot.define do
end
end
+ trait :prepare_staging do
+ name { 'prepare staging' }
+ environment { 'staging' }
+
+ options do
+ {
+ script: %w(ls),
+ environment: { name: 'staging', action: 'prepare' }
+ }
+ end
+
+ set_expanded_environment_name
+ end
+
+ trait :start_staging do
+ name { 'start staging' }
+ environment { 'staging' }
+
+ options do
+ {
+ script: %w(ls),
+ environment: { name: 'staging', action: 'start' }
+ }
+ end
+
+ set_expanded_environment_name
+ end
+
+ trait :stop_staging do
+ name { 'stop staging' }
+ environment { 'staging' }
+
+ options do
+ {
+ script: %w(ls),
+ environment: { name: 'staging', action: 'stop' }
+ }
+ end
+
+ set_expanded_environment_name
+ end
+
+ trait :set_expanded_environment_name do
+ after(:build) do |build, evaluator|
+ build.assign_attributes(
+ metadata_attributes: {
+ expanded_environment_name: build.expanded_environment_name
+ }
+ )
+ end
+ end
+
trait :allowed_to_fail do
allow_failure { true }
end
@@ -455,7 +507,7 @@ FactoryBot.define do
trait :extended_options do
options do
{
- image: { name: 'ruby:2.7', entrypoint: '/bin/sh' },
+ image: { name: 'image:1.0', entrypoint: '/bin/sh' },
services: ['postgres', { name: 'docker:stable-dind', entrypoint: '/bin/sh', command: 'sleep 30', alias: 'docker' }, { name: 'mysql:latest', variables: { MYSQL_ROOT_PASSWORD: 'root123.' } }],
script: %w(echo),
after_script: %w(ls date),
@@ -497,6 +549,22 @@ FactoryBot.define do
options { {} }
end
+ trait :coverage_report_cobertura do
+ options do
+ {
+ artifacts: {
+ expire_in: '7d',
+ reports: {
+ coverage_report: {
+ coverage_format: 'cobertura',
+ path: 'cobertura.xml'
+ }
+ }
+ }
+ }
+ end
+ end
+
# TODO: move Security traits to ee_ci_build
# https://gitlab.com/gitlab-org/gitlab/-/issues/210486
trait :dast do
diff --git a/spec/factories/ci/job_artifacts.rb b/spec/factories/ci/job_artifacts.rb
index 77b07c4a404..cdbcdced5f4 100644
--- a/spec/factories/ci/job_artifacts.rb
+++ b/spec/factories/ci/job_artifacts.rb
@@ -302,6 +302,56 @@ FactoryBot.define do
end
end
+ # Bandit reports are correctly de-duplicated when ran in the same pipeline
+ # as a corresponding semgrep report.
+ # This report does not include signature tracking.
+ trait :sast_bandit do
+ file_type { :sast }
+ file_format { :raw }
+
+ after(:build) do |artifact, _|
+ artifact.file = fixture_file_upload(
+ Rails.root.join('spec/fixtures/security_reports/master/gl-sast-report-bandit.json'), 'application/json')
+ end
+ end
+
+ # Equivalent Semgrep report for :sast_bandit report.
+ # This report includes signature tracking.
+ trait :sast_semgrep_for_bandit do
+ file_type { :sast }
+ file_format { :raw }
+
+ after(:build) do |artifact, _|
+ artifact.file = fixture_file_upload(
+ Rails.root.join('spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-bandit.json'), 'application/json')
+ end
+ end
+
+ # Gosec reports are not correctly de-duplicated when ran in the same pipeline
+ # as a corresponding semgrep report.
+ # This report includes signature tracking.
+ trait :sast_gosec do
+ file_type { :sast }
+ file_format { :raw }
+
+ after(:build) do |artifact, _|
+ artifact.file = fixture_file_upload(
+ Rails.root.join('spec/fixtures/security_reports/master/gl-sast-report-gosec.json'), 'application/json')
+ end
+ end
+
+ # Equivalent Semgrep report for :sast_gosec report.
+ # This report includes signature tracking.
+ trait :sast_semgrep_for_gosec do
+ file_type { :sast }
+ file_format { :raw }
+
+ after(:build) do |artifact, _|
+ artifact.file = fixture_file_upload(
+ Rails.root.join('spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-gosec.json'), 'application/json')
+ end
+ end
+
trait :common_security_report do
file_format { :raw }
file_type { :dependency_scanning }
diff --git a/spec/factories/custom_emoji.rb b/spec/factories/custom_emoji.rb
index 88e50eafa7c..09ac4a79a85 100644
--- a/spec/factories/custom_emoji.rb
+++ b/spec/factories/custom_emoji.rb
@@ -3,9 +3,8 @@
FactoryBot.define do
factory :custom_emoji, class: 'CustomEmoji' do
sequence(:name) { |n| "custom_emoji#{n}" }
- namespace
group
file { 'https://gitlab.com/images/partyparrot.png' }
- creator { namespace.owner }
+ creator factory: :user
end
end
diff --git a/spec/factories/events.rb b/spec/factories/events.rb
index d182dc9f95f..403165a3935 100644
--- a/spec/factories/events.rb
+++ b/spec/factories/events.rb
@@ -59,6 +59,11 @@ FactoryBot.define do
target { design }
end
+ factory :design_updated_event, traits: [:has_design] do
+ action { :updated }
+ target { design }
+ end
+
factory :project_created_event do
project factory: :project
action { :created }
diff --git a/spec/factories/gitlab/database/background_migration/batched_migrations.rb b/spec/factories/gitlab/database/background_migration/batched_migrations.rb
index 79b4447b76e..5ff90ff44b9 100644
--- a/spec/factories/gitlab/database/background_migration/batched_migrations.rb
+++ b/spec/factories/gitlab/database/background_migration/batched_migrations.rb
@@ -13,12 +13,24 @@ FactoryBot.define do
total_tuple_count { 10_000 }
pause_ms { 100 }
- trait :finished do
- status { :finished }
+ trait(:paused) do
+ status { 0 }
end
- trait :failed do
- status { :failed }
+ trait(:active) do
+ status { 1 }
+ end
+
+ trait(:finished) do
+ status { 3 }
+ end
+
+ trait(:failed) do
+ status { 4 }
+ end
+
+ trait(:finalizing) do
+ status { 5 }
end
end
end
diff --git a/spec/factories/go_module_versions.rb b/spec/factories/go_module_versions.rb
index 145e6c95921..bdbd5a4423a 100644
--- a/spec/factories/go_module_versions.rb
+++ b/spec/factories/go_module_versions.rb
@@ -5,12 +5,10 @@ FactoryBot.define do
skip_create
initialize_with do
- p = attributes[:params]
- s = Packages::SemVer.parse(p.semver, prefixed: true)
+ s = Packages::SemVer.parse(semver, prefixed: true)
+ raise ArgumentError, "invalid sematic version: #{semver.inspect}" if !s && semver
- raise ArgumentError, "invalid sematic version: '#{p.semver}'" if !s && p.semver
-
- new(p.mod, p.type, p.commit, name: p.name, semver: s, ref: p.ref)
+ new(mod, type, commit, name: name, semver: s, ref: ref)
end
mod { association(:go_module) }
@@ -20,8 +18,6 @@ FactoryBot.define do
semver { nil }
ref { nil }
- params { OpenStruct.new(mod: mod, type: type, commit: commit, name: name, semver: semver, ref: ref) }
-
trait :tagged do
ref { mod.project.repository.find_tag(name) }
commit { ref.dereferenced_target }
@@ -36,8 +32,8 @@ FactoryBot.define do
.max_by(&:to_s)
.to_s
end
-
- params { OpenStruct.new(mod: mod, type: :ref, commit: commit, semver: name, ref: ref) }
+ type { :ref }
+ semver { name }
end
end
end
diff --git a/spec/factories/groups.rb b/spec/factories/groups.rb
index aa264ad3377..152ae061605 100644
--- a/spec/factories/groups.rb
+++ b/spec/factories/groups.rb
@@ -118,14 +118,5 @@ FactoryBot.define do
create(:crm_settings, group: group, enabled: true)
end
end
-
- trait :test_group do
- path { "test-group-fulfillment#{SecureRandom.hex(4)}" }
- created_at { 4.days.ago }
-
- after(:create) do |group|
- group.add_owner(create(:user, email: "test-user-#{SecureRandom.hex(4)}@test.com"))
- end
- end
end
end
diff --git a/spec/factories/integrations.rb b/spec/factories/integrations.rb
index 0ffa15ad403..3945637c2c3 100644
--- a/spec/factories/integrations.rb
+++ b/spec/factories/integrations.rb
@@ -189,7 +189,7 @@ FactoryBot.define do
end
trait :chat_notification do
- webhook { 'https://example.com/webhook' }
+ sequence(:webhook) { |n| "https://example.com/webhook/#{n}" }
end
trait :inactive do
diff --git a/spec/factories/issues.rb b/spec/factories/issues.rb
index 26c858665a8..8c714f7736f 100644
--- a/spec/factories/issues.rb
+++ b/spec/factories/issues.rb
@@ -58,6 +58,11 @@ FactoryBot.define do
end
end
+ trait :task do
+ issue_type { :task }
+ association :work_item_type, :default, :task
+ end
+
factory :incident do
issue_type { :incident }
association :work_item_type, :default, :incident
diff --git a/spec/factories/keys.rb b/spec/factories/keys.rb
index 2af1c6cc62d..6b800e3d790 100644
--- a/spec/factories/keys.rb
+++ b/spec/factories/keys.rb
@@ -19,6 +19,12 @@ FactoryBot.define do
user
end
+ factory :personal_key_4096 do
+ user
+
+ key { SSHData::PrivateKey::RSA.generate(4096, unsafe_allow_small_key: true).public_key.openssh(comment: 'dummy@gitlab.com') }
+ end
+
factory :another_key do
factory :another_deploy_key, class: 'DeployKey'
end
@@ -74,6 +80,8 @@ FactoryBot.define do
qpPN5jAskkAUzOh5L/M+dmq2jNn03U9xwORCYPZj+fFM9bL99/0knsV0ypZDZyWH dummy@gitlab.com
KEY
end
+
+ factory :rsa_deploy_key_5120, class: 'DeployKey'
end
factory :rsa_key_8192 do
diff --git a/spec/factories/merge_requests.rb b/spec/factories/merge_requests.rb
index 26804b38db8..e897a5e022a 100644
--- a/spec/factories/merge_requests.rb
+++ b/spec/factories/merge_requests.rb
@@ -65,11 +65,12 @@ FactoryBot.define do
transient do
merged_by { author }
+ merged_at { nil }
end
after(:build) do |merge_request, evaluator|
metrics = merge_request.build_metrics
- metrics.merged_at = 1.week.from_now
+ metrics.merged_at = evaluator.merged_at || 1.week.from_now
metrics.merged_by = evaluator.merged_by
metrics.pipeline = create(:ci_empty_pipeline)
end
diff --git a/spec/factories/project_statistics.rb b/spec/factories/project_statistics.rb
index ee2ad507c2d..53107879d77 100644
--- a/spec/factories/project_statistics.rb
+++ b/spec/factories/project_statistics.rb
@@ -24,6 +24,7 @@ FactoryBot.define do
project_statistics.snippets_size = evaluator.size_multiplier * 6
project_statistics.pipeline_artifacts_size = evaluator.size_multiplier * 7
project_statistics.uploads_size = evaluator.size_multiplier * 8
+ project_statistics.container_registry_size = evaluator.size_multiplier * 9
end
end
end
diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb
index ef1313541f8..b3395758729 100644
--- a/spec/factories/projects.rb
+++ b/spec/factories/projects.rb
@@ -59,7 +59,7 @@ FactoryBot.define do
builds_access_level = [evaluator.builds_access_level, evaluator.repository_access_level].min
merge_requests_access_level = [evaluator.merge_requests_access_level, evaluator.repository_access_level].min
- hash = {
+ project_feature_hash = {
wiki_access_level: evaluator.wiki_access_level,
builds_access_level: builds_access_level,
snippets_access_level: evaluator.snippets_access_level,
@@ -75,7 +75,16 @@ FactoryBot.define do
security_and_compliance_access_level: evaluator.security_and_compliance_access_level
}
- project.build_project_feature(hash)
+ project_namespace_hash = {
+ name: evaluator.name,
+ path: evaluator.path,
+ parent: evaluator.namespace,
+ shared_runners_enabled: evaluator.shared_runners_enabled,
+ visibility_level: evaluator.visibility_level
+ }
+
+ project.build_project_namespace(project_namespace_hash)
+ project.build_project_feature(project_feature_hash)
end
after(:create) do |project, evaluator|
diff --git a/spec/factories/work_items/work_item_types.rb b/spec/factories/work_items/work_item_types.rb
index 0920b36bcbd..1b6137503d3 100644
--- a/spec/factories/work_items/work_item_types.rb
+++ b/spec/factories/work_items/work_item_types.rb
@@ -37,5 +37,10 @@ FactoryBot.define do
base_type { WorkItems::Type.base_types[:requirement] }
icon_name { 'issue-type-requirements' }
end
+
+ trait :task do
+ base_type { WorkItems::Type.base_types[:task] }
+ icon_name { 'issue-type-task' }
+ end
end
end
diff --git a/spec/fast_spec_helper.rb b/spec/fast_spec_helper.rb
index ce3c9af22f1..6cbe97fb3f3 100644
--- a/spec/fast_spec_helper.rb
+++ b/spec/fast_spec_helper.rb
@@ -1,6 +1,5 @@
# frozen_string_literal: true
-# $" is $LOADED_FEATURES, but RuboCop didn't like it
if $".include?(File.expand_path('spec_helper.rb', __dir__))
# There's no need to load anything here if spec_helper is already loaded
# because spec_helper is more extensive than fast_spec_helper
diff --git a/spec/features/admin/admin_broadcast_messages_spec.rb b/spec/features/admin/admin_broadcast_messages_spec.rb
index e40f4c4678c..875eb9dd0ce 100644
--- a/spec/features/admin/admin_broadcast_messages_spec.rb
+++ b/spec/features/admin/admin_broadcast_messages_spec.rb
@@ -22,9 +22,8 @@ RSpec.describe 'Admin Broadcast Messages' do
it 'creates a customized broadcast banner message' do
fill_in 'broadcast_message_message', with: 'Application update from **4:00 CST to 5:00 CST**'
- fill_in 'broadcast_message_color', with: '#f2dede'
fill_in 'broadcast_message_target_path', with: '*/user_onboarded'
- fill_in 'broadcast_message_font', with: '#b94a48'
+ select 'light-indigo', from: 'broadcast_message_theme'
select Date.today.next_year.year, from: 'broadcast_message_ends_at_1i'
check 'Guest'
check 'Owner'
@@ -35,7 +34,7 @@ RSpec.describe 'Admin Broadcast Messages' do
expect(page).to have_content 'Guest, Owner'
expect(page).to have_content '*/user_onboarded'
expect(page).to have_selector 'strong', text: '4:00 CST to 5:00 CST'
- expect(page).to have_selector %(div[style="background-color: #f2dede; color: #b94a48"])
+ expect(page).to have_selector %(.light-indigo[role=alert])
end
it 'creates a customized broadcast notification message' do
@@ -90,7 +89,7 @@ RSpec.describe 'Admin Broadcast Messages' do
fill_in 'broadcast_message_message', with: "Live **Markdown** previews. :tada:"
select 'Notification', from: 'broadcast_message_broadcast_type'
- page.within('.js-broadcast-notification-message-preview') do
+ page.within('#broadcast-message-preview') do
expect(page).to have_selector('strong', text: 'Markdown')
expect(page).to have_emoji('tada')
end
diff --git a/spec/features/admin/admin_dev_ops_report_spec.rb b/spec/features/admin/admin_dev_ops_reports_spec.rb
index cee79f8f440..bf32819cb52 100644
--- a/spec/features/admin/admin_dev_ops_report_spec.rb
+++ b/spec/features/admin/admin_dev_ops_reports_spec.rb
@@ -15,7 +15,7 @@ RSpec.describe 'DevOps Report page', :js do
end
it 'has dismissable intro callout' do
- visit admin_dev_ops_report_path
+ visit admin_dev_ops_reports_path
expect(page).to have_content 'Introducing Your DevOps Report'
@@ -32,13 +32,13 @@ RSpec.describe 'DevOps Report page', :js do
end
it 'shows empty state' do
- visit admin_dev_ops_report_path
+ visit admin_dev_ops_reports_path
expect(page).to have_text('Service ping is off')
end
it 'hides the intro callout' do
- visit admin_dev_ops_report_path
+ visit admin_dev_ops_reports_path
expect(page).not_to have_content 'Introducing Your DevOps Report'
end
@@ -48,7 +48,7 @@ RSpec.describe 'DevOps Report page', :js do
it 'shows empty state' do
stub_application_setting(usage_ping_enabled: true)
- visit admin_dev_ops_report_path
+ visit admin_dev_ops_reports_path
expect(page).to have_content('Data is still calculating')
end
@@ -59,7 +59,7 @@ RSpec.describe 'DevOps Report page', :js do
stub_application_setting(usage_ping_enabled: true)
create(:dev_ops_report_metric)
- visit admin_dev_ops_report_path
+ visit admin_dev_ops_reports_path
expect(page).to have_selector('[data-testid="devops-score-app"]')
end
diff --git a/spec/features/admin/admin_runners_spec.rb b/spec/features/admin/admin_runners_spec.rb
index 3f0c7e64a1f..7fe49c2571c 100644
--- a/spec/features/admin/admin_runners_spec.rb
+++ b/spec/features/admin/admin_runners_spec.rb
@@ -3,65 +3,71 @@
require 'spec_helper'
RSpec.describe "Admin Runners" do
- include StubENV
- include Spec::Support::Helpers::ModalHelpers
+ include Spec::Support::Helpers::Features::RunnersHelpers
+
+ let_it_be(:admin) { create(:admin) }
before do
- stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false')
- admin = create(:admin)
sign_in(admin)
gitlab_enable_admin_mode_sign_in(admin)
wait_for_requests
end
- describe "Runners page", :js do
+ describe "Admin Runners page", :js do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:namespace) { create(:namespace) }
let_it_be(:project) { create(:project, namespace: namespace, creator: user) }
- context "when there are runners" do
- it 'has all necessary texts' do
- create(:ci_runner, :instance, created_at: 1.year.ago, contacted_at: Time.zone.now)
- create(:ci_runner, :instance, created_at: 1.year.ago, contacted_at: 1.week.ago)
- create(:ci_runner, :instance, created_at: 1.year.ago, contacted_at: 1.year.ago)
-
+ context "runners registration" do
+ before do
visit admin_runners_path
-
- expect(page).to have_text "Register an instance runner"
- expect(page).to have_text "Online runners 1"
- expect(page).to have_text "Offline runners 2"
- expect(page).to have_text "Stale runners 1"
end
- it 'with an instance runner shows an instance badge' do
- runner = create(:ci_runner, :instance)
+ it_behaves_like "shows and resets runner registration token" do
+ let(:dropdown_text) { 'Register an instance runner' }
+ let(:registration_token) { Gitlab::CurrentSettings.runners_registration_token }
+ end
+ end
- visit admin_runners_path
+ context "when there are runners" do
+ context "with an instance runner" do
+ let!(:instance_runner) { create(:ci_runner, :instance) }
- within "[data-testid='runner-row-#{runner.id}']" do
- expect(page).to have_selector '.badge', text: 'shared'
+ before do
+ visit admin_runners_path
end
- end
- it 'with a group runner shows a group badge' do
- runner = create(:ci_runner, :group, groups: [group])
+ it_behaves_like 'shows runner in list' do
+ let(:runner) { instance_runner }
+ end
- visit admin_runners_path
+ it_behaves_like 'pauses, resumes and deletes a runner' do
+ let(:runner) { instance_runner }
+ end
- within "[data-testid='runner-row-#{runner.id}']" do
- expect(page).to have_selector '.badge', text: 'group'
+ it 'shows an instance badge' do
+ within_runner_row(instance_runner.id) do
+ expect(page).to have_selector '.badge', text: 'shared'
+ end
end
end
- it 'with a project runner shows a project badge' do
- runner = create(:ci_runner, :project, projects: [project])
+ context "with multiple runners" do
+ before do
+ create(:ci_runner, :instance, created_at: 1.year.ago, contacted_at: Time.zone.now)
+ create(:ci_runner, :instance, created_at: 1.year.ago, contacted_at: 1.week.ago)
+ create(:ci_runner, :instance, created_at: 1.year.ago, contacted_at: 1.year.ago)
- visit admin_runners_path
+ visit admin_runners_path
+ end
- within "[data-testid='runner-row-#{runner.id}']" do
- expect(page).to have_selector '.badge', text: 'specific'
+ it 'has all necessary texts' do
+ expect(page).to have_text "Register an instance runner"
+ expect(page).to have_text "Online runners 1"
+ expect(page).to have_text "Offline runners 2"
+ expect(page).to have_text "Stale runners 1"
end
end
@@ -73,44 +79,8 @@ RSpec.describe "Admin Runners" do
visit admin_runners_path
- within "[data-testid='runner-row-#{runner.id}'] [data-label='Jobs']" do
- expect(page).to have_content '2'
- end
- end
-
- describe 'delete runner' do
- let!(:runner) { create(:ci_runner, description: 'runner-foo') }
-
- before do
- visit admin_runners_path
-
- within "[data-testid='runner-row-#{runner.id}']" do
- click_on 'Delete runner'
- end
- end
-
- it 'shows a confirmation modal' do
- expect(page).to have_text "Delete runner ##{runner.id} (#{runner.short_sha})?"
- expect(page).to have_text "Are you sure you want to continue?"
- end
-
- it 'deletes a runner' do
- within '.modal' do
- click_on 'Delete runner'
- end
-
- expect(page.find('.gl-toast')).to have_text(/Runner .+ deleted/)
- expect(page).not_to have_content 'runner-foo'
- end
-
- it 'cancels runner deletion' do
- within '.modal' do
- click_on 'Cancel'
- end
-
- wait_for_requests
-
- expect(page).to have_content 'runner-foo'
+ within_runner_row(runner.id) do
+ expect(find("[data-label='Jobs']")).to have_content '2'
end
end
@@ -154,35 +124,69 @@ RSpec.describe "Admin Runners" do
end
end
+ describe 'filter by paused' do
+ before do
+ create(:ci_runner, :instance, description: 'runner-active')
+ create(:ci_runner, :instance, description: 'runner-paused', active: false)
+
+ visit admin_runners_path
+ end
+
+ it 'shows all runners' do
+ expect(page).to have_link('All 2')
+
+ expect(page).to have_content 'runner-active'
+ expect(page).to have_content 'runner-paused'
+ end
+
+ it 'shows paused runners' do
+ input_filtered_search_filter_is_only('Paused', 'Yes')
+
+ expect(page).to have_link('All 1')
+
+ expect(page).not_to have_content 'runner-active'
+ expect(page).to have_content 'runner-paused'
+ end
+
+ it 'shows active runners' do
+ input_filtered_search_filter_is_only('Paused', 'No')
+
+ expect(page).to have_link('All 1')
+
+ expect(page).to have_content 'runner-active'
+ expect(page).not_to have_content 'runner-paused'
+ end
+ end
+
describe 'filter by status' do
let!(:never_contacted) { create(:ci_runner, :instance, description: 'runner-never-contacted', contacted_at: nil) }
before do
create(:ci_runner, :instance, description: 'runner-1', contacted_at: Time.zone.now)
create(:ci_runner, :instance, description: 'runner-2', contacted_at: Time.zone.now)
- create(:ci_runner, :instance, description: 'runner-paused', active: false, contacted_at: Time.zone.now)
+ create(:ci_runner, :instance, description: 'runner-offline', contacted_at: 1.week.ago)
visit admin_runners_path
end
it 'shows all runners' do
+ expect(page).to have_link('All 4')
+
expect(page).to have_content 'runner-1'
expect(page).to have_content 'runner-2'
- expect(page).to have_content 'runner-paused'
+ expect(page).to have_content 'runner-offline'
expect(page).to have_content 'runner-never-contacted'
-
- expect(page).to have_link('All 4')
end
it 'shows correct runner when status matches' do
- input_filtered_search_filter_is_only('Status', 'Active')
+ input_filtered_search_filter_is_only('Status', 'Online')
- expect(page).to have_link('All 3')
+ expect(page).to have_link('All 2')
expect(page).to have_content 'runner-1'
expect(page).to have_content 'runner-2'
- expect(page).to have_content 'runner-never-contacted'
- expect(page).not_to have_content 'runner-paused'
+ expect(page).not_to have_content 'runner-offline'
+ expect(page).not_to have_content 'runner-never-contacted'
end
it 'shows no runner when status does not match' do
@@ -194,15 +198,15 @@ RSpec.describe "Admin Runners" do
end
it 'shows correct runner when status is selected and search term is entered' do
- input_filtered_search_filter_is_only('Status', 'Active')
+ input_filtered_search_filter_is_only('Status', 'Online')
input_filtered_search_keys('runner-1')
expect(page).to have_link('All 1')
expect(page).to have_content 'runner-1'
expect(page).not_to have_content 'runner-2'
+ expect(page).not_to have_content 'runner-offline'
expect(page).not_to have_content 'runner-never-contacted'
- expect(page).not_to have_content 'runner-paused'
end
it 'shows correct runner when status filter is entered' do
@@ -216,7 +220,7 @@ RSpec.describe "Admin Runners" do
expect(page).not_to have_content 'runner-paused'
expect(page).to have_content 'runner-never-contacted'
- within "[data-testid='runner-row-#{never_contacted.id}']" do
+ within_runner_row(never_contacted.id) do
expect(page).to have_selector '.badge', text: 'never contacted'
end
end
@@ -308,7 +312,7 @@ RSpec.describe "Admin Runners" do
visit admin_runners_path
- input_filtered_search_filter_is_only('Status', 'Active')
+ input_filtered_search_filter_is_only('Paused', 'No')
expect(page).to have_content 'runner-project'
expect(page).to have_content 'runner-group'
@@ -330,6 +334,17 @@ RSpec.describe "Admin Runners" do
create(:ci_runner, :instance, description: 'runner-red', tag_list: ['red'])
end
+ it 'shows tags suggestions' do
+ visit admin_runners_path
+
+ open_filtered_search_suggestions('Tags')
+
+ page.within(search_bar_selector) do
+ expect(page).to have_content 'blue'
+ expect(page).to have_content 'red'
+ end
+ end
+
it 'shows correct runner when tag matches' do
visit admin_runners_path
@@ -403,15 +418,7 @@ RSpec.describe "Admin Runners" do
visit admin_runners_path
end
- it 'has all necessary texts including no runner message' do
- expect(page).to have_text "Register an instance runner"
-
- expect(page).to have_text "Online runners 0"
- expect(page).to have_text "Offline runners 0"
- expect(page).to have_text "Stale runners 0"
-
- expect(page).to have_text 'No runners found'
- end
+ it_behaves_like "shows no runners"
it 'shows tabs with total counts equal to 0' do
expect(page).to have_link('All 0')
@@ -427,65 +434,17 @@ RSpec.describe "Admin Runners" do
expect(page).to have_current_path(admin_runners_path('status[]': 'NEVER_CONTACTED') )
end
- end
- describe 'runners registration' do
- let!(:token) { Gitlab::CurrentSettings.runners_registration_token }
-
- before do
- visit admin_runners_path
+ it 'updates ACTIVE runner status to paused=false' do
+ visit admin_runners_path('status[]': 'ACTIVE')
- click_on 'Register an instance runner'
+ expect(page).to have_current_path(admin_runners_path('paused[]': 'false') )
end
- describe 'show registration instructions' do
- before do
- click_on 'Show runner installation and registration instructions'
-
- wait_for_requests
- end
-
- it 'opens runner installation modal' do
- expect(page).to have_text "Install a runner"
-
- expect(page).to have_text "Environment"
- expect(page).to have_text "Architecture"
- expect(page).to have_text "Download and install binary"
- end
-
- it 'dismisses runner installation modal' do
- within_modal do
- click_button('Close', match: :first)
- end
-
- expect(page).not_to have_text "Install a runner"
- end
- end
+ it 'updates PAUSED runner status to paused=true' do
+ visit admin_runners_path('status[]': 'PAUSED')
- it 'has a registration token' do
- click_on 'Click to reveal'
- expect(page.find('[data-testid="token-value"]')).to have_content(token)
- end
-
- describe 'reset registration token' do
- let(:page_token) { find('[data-testid="token-value"]').text }
-
- before do
- click_on 'Reset registration token'
-
- within_modal do
- click_button('Reset token', match: :first)
- end
-
- wait_for_requests
- end
-
- it 'changes registration token' do
- click_on 'Register an instance runner'
-
- click_on 'Click to reveal'
- expect(page_token).not_to eq token
- end
+ expect(page).to have_current_path(admin_runners_path('paused[]': 'true') )
end
end
end
@@ -637,47 +596,4 @@ RSpec.describe "Admin Runners" do
end
end
end
-
- private
-
- def search_bar_selector
- '[data-testid="runners-filtered-search"]'
- end
-
- # The filters must be clicked first to be able to receive events
- # See: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1493
- def focus_filtered_search
- page.within(search_bar_selector) do
- page.find('.gl-filtered-search-term-token').click
- end
- end
-
- def input_filtered_search_keys(search_term)
- focus_filtered_search
-
- page.within(search_bar_selector) do
- page.find('input').send_keys(search_term)
- click_on 'Search'
- end
-
- wait_for_requests
- end
-
- def input_filtered_search_filter_is_only(filter, value)
- focus_filtered_search
-
- page.within(search_bar_selector) do
- click_on filter
-
- # For OPERATOR_IS_ONLY, clicking the filter
- # immediately preselects "=" operator
-
- page.find('input').send_keys(value)
- page.find('input').send_keys(:enter)
-
- click_on 'Search'
- end
-
- wait_for_requests
- end
end
diff --git a/spec/features/admin/admin_sees_background_migrations_spec.rb b/spec/features/admin/admin_sees_background_migrations_spec.rb
index d05a09a79ef..432721d63ad 100644
--- a/spec/features/admin/admin_sees_background_migrations_spec.rb
+++ b/spec/features/admin/admin_sees_background_migrations_spec.rb
@@ -5,9 +5,9 @@ require 'spec_helper'
RSpec.describe "Admin > Admin sees background migrations" do
let_it_be(:admin) { create(:admin) }
- let_it_be(:active_migration) { create(:batched_background_migration, table_name: 'active', status: :active) }
- let_it_be(:failed_migration) { create(:batched_background_migration, table_name: 'failed', status: :failed, total_tuple_count: 100) }
- let_it_be(:finished_migration) { create(:batched_background_migration, table_name: 'finished', status: :finished) }
+ let_it_be(:active_migration) { create(:batched_background_migration, :active, table_name: 'active') }
+ let_it_be(:failed_migration) { create(:batched_background_migration, :failed, table_name: 'failed', total_tuple_count: 100) }
+ let_it_be(:finished_migration) { create(:batched_background_migration, :finished, table_name: 'finished') }
before_all do
create(:batched_background_migration_job, :failed, batched_migration: failed_migration, batch_size: 10, min_value: 6, max_value: 15, attempts: 3)
@@ -81,7 +81,7 @@ RSpec.describe "Admin > Admin sees background migrations" do
expect(page).to have_content(failed_migration.job_class_name)
expect(page).to have_content(failed_migration.table_name)
expect(page).to have_content('0.00%')
- expect(page).to have_content(failed_migration.status.humanize)
+ expect(page).to have_content(failed_migration.status_name.to_s)
click_button('Retry')
expect(page).not_to have_content(failed_migration.job_class_name)
@@ -106,7 +106,7 @@ RSpec.describe "Admin > Admin sees background migrations" do
expect(page).to have_content(finished_migration.job_class_name)
expect(page).to have_content(finished_migration.table_name)
expect(page).to have_content('100.00%')
- expect(page).to have_content(finished_migration.status.humanize)
+ expect(page).to have_content(finished_migration.status_name.to_s)
end
end
end
diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb
index df93bd773a6..4cdc3df978d 100644
--- a/spec/features/admin/admin_settings_spec.rb
+++ b/spec/features/admin/admin_settings_spec.rb
@@ -34,16 +34,16 @@ RSpec.describe 'Admin updates settings' do
it 'uncheck all restricted visibility levels' do
page.within('.as-visibility-access') do
- find('#application_setting_visibility_level_0').set(false)
- find('#application_setting_visibility_level_10').set(false)
- find('#application_setting_visibility_level_20').set(false)
+ find('#application_setting_restricted_visibility_levels_0').set(false)
+ find('#application_setting_restricted_visibility_levels_10').set(false)
+ find('#application_setting_restricted_visibility_levels_20').set(false)
click_button 'Save changes'
end
expect(page).to have_content "Application settings saved successfully"
- expect(find('#application_setting_visibility_level_0')).not_to be_checked
- expect(find('#application_setting_visibility_level_10')).not_to be_checked
- expect(find('#application_setting_visibility_level_20')).not_to be_checked
+ expect(find('#application_setting_restricted_visibility_levels_0')).not_to be_checked
+ expect(find('#application_setting_restricted_visibility_levels_10')).not_to be_checked
+ expect(find('#application_setting_restricted_visibility_levels_20')).not_to be_checked
end
it 'modify import sources' do
@@ -311,7 +311,9 @@ RSpec.describe 'Admin updates settings' do
end
context 'CI/CD page' do
- it 'change CI/CD settings' do
+ let_it_be(:default_plan) { create(:default_plan) }
+
+ it 'changes CI/CD settings' do
visit ci_cd_admin_application_settings_path
page.within('.as-ci-cd') do
@@ -329,6 +331,33 @@ RSpec.describe 'Admin updates settings' do
expect(page).to have_content "Application settings saved successfully"
end
+ it 'changes CI/CD limits', :aggregate_failures do
+ visit ci_cd_admin_application_settings_path
+
+ page.within('.as-ci-cd') do
+ fill_in 'plan_limits_ci_pipeline_size', with: 10
+ fill_in 'plan_limits_ci_active_jobs', with: 20
+ fill_in 'plan_limits_ci_active_pipelines', with: 25
+ fill_in 'plan_limits_ci_project_subscriptions', with: 30
+ fill_in 'plan_limits_ci_pipeline_schedules', with: 40
+ fill_in 'plan_limits_ci_needs_size_limit', with: 50
+ fill_in 'plan_limits_ci_registered_group_runners', with: 60
+ fill_in 'plan_limits_ci_registered_project_runners', with: 70
+ click_button 'Save Default limits'
+ end
+
+ limits = default_plan.reload.limits
+ expect(limits.ci_pipeline_size).to eq(10)
+ expect(limits.ci_active_jobs).to eq(20)
+ expect(limits.ci_active_pipelines).to eq(25)
+ expect(limits.ci_project_subscriptions).to eq(30)
+ expect(limits.ci_pipeline_schedules).to eq(40)
+ expect(limits.ci_needs_size_limit).to eq(50)
+ expect(limits.ci_registered_group_runners).to eq(60)
+ expect(limits.ci_registered_project_runners).to eq(70)
+ expect(page).to have_content 'Application limits saved successfully'
+ end
+
context 'Runner Registration' do
context 'when feature is enabled' do
before do
@@ -421,7 +450,7 @@ RSpec.describe 'Admin updates settings' do
visit ci_cd_admin_application_settings_path
page.within('.as-registry') do
- find('#application_setting_container_registry_expiration_policies_caching.form-check-input').click
+ find('#application_setting_container_registry_expiration_policies_caching').click
click_button 'Save changes'
end
@@ -489,8 +518,8 @@ RSpec.describe 'Admin updates settings' do
page.within('.as-spam') do
fill_in 'reCAPTCHA site key', with: 'key'
fill_in 'reCAPTCHA private key', with: 'key'
- check 'Enable reCAPTCHA'
- check 'Enable reCAPTCHA for login'
+ find('#application_setting_recaptcha_enabled').set(true)
+ find('#application_setting_login_recaptcha_protection_enabled').set(true)
fill_in 'IP addresses per user', with: 15
check 'Enable Spam Check via external API endpoint'
fill_in 'URL of the external Spam Check endpoint', with: 'grpc://www.example.com/spamcheck'
@@ -825,31 +854,45 @@ RSpec.describe 'Admin updates settings' do
before do
stub_usage_data_connections
stub_database_flavor_check
-
- visit service_usage_data_admin_application_settings_path
end
- it 'loads usage ping payload on click', :js do
- expected_payload_content = /(?=.*"uuid")(?=.*"hostname")/m
+ context 'when service data cached', :clean_gitlab_redis_cache do
+ before do
+ allow(Rails.cache).to receive(:exist?).with('usage_data').and_return(true)
- expect(page).not_to have_content expected_payload_content
+ visit service_usage_data_admin_application_settings_path
+ end
- click_button('Preview payload')
+ it 'loads usage ping payload on click', :js do
+ expected_payload_content = /(?=.*"uuid")(?=.*"hostname")/m
- wait_for_requests
+ expect(page).not_to have_content expected_payload_content
- expect(page).to have_button 'Hide payload'
- expect(page).to have_content expected_payload_content
- end
+ click_button('Preview payload')
- it 'generates usage ping payload on button click', :js do
- expect_next_instance_of(Admin::ApplicationSettingsController) do |instance|
- expect(instance).to receive(:usage_data).and_call_original
+ wait_for_requests
+
+ expect(page).to have_button 'Hide payload'
+ expect(page).to have_content expected_payload_content
+ end
+
+ it 'generates usage ping payload on button click', :js do
+ expect_next_instance_of(Admin::ApplicationSettingsController) do |instance|
+ expect(instance).to receive(:usage_data).and_call_original
+ end
+
+ click_button('Download payload')
+
+ wait_for_requests
end
+ end
- click_button('Download payload')
+ context 'when service data not cached' do
+ it 'renders missing cache information' do
+ visit service_usage_data_admin_application_settings_path
- wait_for_requests
+ expect(page).to have_text('Service Ping payload not found in the application cache')
+ end
end
end
end
diff --git a/spec/features/admin/admin_users_impersonation_tokens_spec.rb b/spec/features/admin/admin_users_impersonation_tokens_spec.rb
index 6643ebe82e6..15bc2318022 100644
--- a/spec/features/admin/admin_users_impersonation_tokens_spec.rb
+++ b/spec/features/admin/admin_users_impersonation_tokens_spec.rb
@@ -36,14 +36,14 @@ RSpec.describe 'Admin > Users > Impersonation Tokens', :js do
click_on "1"
# Scopes
- check "api"
+ check "read_api"
check "read_user"
click_on "Create impersonation token"
expect(active_impersonation_tokens).to have_text(name)
expect(active_impersonation_tokens).to have_text('in')
- expect(active_impersonation_tokens).to have_text('api')
+ expect(active_impersonation_tokens).to have_text('read_api')
expect(active_impersonation_tokens).to have_text('read_user')
expect(PersonalAccessTokensFinder.new(impersonation: true).execute.count).to equal(1)
expect(created_impersonation_token).not_to be_empty
diff --git a/spec/features/admin/clusters/eks_spec.rb b/spec/features/admin/clusters/eks_spec.rb
index 71d2bba73b1..4667f9c20a1 100644
--- a/spec/features/admin/clusters/eks_spec.rb
+++ b/spec/features/admin/clusters/eks_spec.rb
@@ -15,8 +15,8 @@ RSpec.describe 'Instance-level AWS EKS Cluster', :js do
before do
visit admin_clusters_path
- click_button 'Actions'
- click_link 'Create a new cluster'
+ click_button(class: 'dropdown-toggle-split')
+ click_link 'Create a cluster (deprecated)'
end
context 'when user creates a cluster on AWS EKS' do
diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb
index 5dd627f3b76..bf976168bbe 100644
--- a/spec/features/boards/boards_spec.rb
+++ b/spec/features/boards/boards_spec.rb
@@ -282,7 +282,7 @@ RSpec.describe 'Project issue boards', :js do
it 'shows issue count on the list' do
page.within(find(".board:nth-child(2)")) do
expect(page.find('[data-testid="board-items-count"]')).to have_text(total_planning_issues)
- expect(page).not_to have_selector('.js-max-issue-size')
+ expect(page).not_to have_selector('.max-issue-size')
end
end
end
diff --git a/spec/features/boards/focus_mode_spec.rb b/spec/features/boards/focus_mode_spec.rb
index 2bd1e625236..453a8d8870b 100644
--- a/spec/features/boards/focus_mode_spec.rb
+++ b/spec/features/boards/focus_mode_spec.rb
@@ -12,6 +12,6 @@ RSpec.describe 'Issue Boards focus mode', :js do
end
it 'shows focus mode button to anonymous users' do
- expect(page).to have_selector('.js-focus-mode-btn')
+ expect(page).to have_button _('Toggle focus mode')
end
end
diff --git a/spec/features/boards/multi_select_spec.rb b/spec/features/boards/multi_select_spec.rb
index 9148fb23214..cad303a14e5 100644
--- a/spec/features/boards/multi_select_spec.rb
+++ b/spec/features/boards/multi_select_spec.rb
@@ -72,7 +72,7 @@ RSpec.describe 'Multi Select Issue', :js do
wait_for_requests
- page.within(all('.js-board-list')[2]) do
+ page.within(all('.board-list')[2]) do
expect(find('.board-card:nth-child(1)')).to have_content(issue1.title)
expect(find('.board-card:nth-child(2)')).to have_content(issue2.title)
end
@@ -87,7 +87,7 @@ RSpec.describe 'Multi Select Issue', :js do
wait_for_requests
- page.within(all('.js-board-list')[2]) do
+ page.within(all('.board-list')[2]) do
expect(find('.board-card:nth-child(1)')).to have_content(issue1.title)
expect(find('.board-card:nth-child(2)')).to have_content(issue2.title)
expect(find('.board-card:nth-child(3)')).to have_content(issue3.title)
@@ -102,7 +102,7 @@ RSpec.describe 'Multi Select Issue', :js do
wait_for_requests
- page.within(all('.js-board-list')[1]) do
+ page.within(all('.board-list')[1]) do
expect(find('.board-card:nth-child(1)')).to have_content(issue1.title)
expect(find('.board-card:nth-child(2)')).to have_content(issue2.title)
expect(find('.board-card:nth-child(3)')).to have_content(issue5.title)
diff --git a/spec/features/clusters/create_agent_spec.rb b/spec/features/clusters/create_agent_spec.rb
index e03126d344e..c7326204bf6 100644
--- a/spec/features/clusters/create_agent_spec.rb
+++ b/spec/features/clusters/create_agent_spec.rb
@@ -25,7 +25,7 @@ RSpec.describe 'Cluster agent registration', :js do
end
it 'allows the user to select an agent to install, and displays the resulting agent token' do
- click_button('Actions')
+ click_button('Connect a cluster')
expect(page).to have_content('Register')
click_button('Select an agent')
@@ -34,7 +34,7 @@ RSpec.describe 'Cluster agent registration', :js do
expect(page).to have_content('You cannot see this token again after you close this window.')
expect(page).to have_content('example-agent-token')
- expect(page).to have_content('docker run --pull=always --rm')
+ expect(page).to have_content('helm upgrade --install')
within find('.modal-footer') do
click_button('Close')
diff --git a/spec/features/commit_spec.rb b/spec/features/commit_spec.rb
index 3fd613ce393..c9fa10d58e6 100644
--- a/spec/features/commit_spec.rb
+++ b/spec/features/commit_spec.rb
@@ -33,6 +33,10 @@ RSpec.describe 'Commit' do
it "reports the correct number of total changes" do
expect(page).to have_content("Changes #{commit.diffs.size}")
end
+
+ it 'renders diff stats', :js do
+ expect(page).to have_selector(".diff-stats")
+ end
end
describe "pagination" do
diff --git a/spec/features/commits_spec.rb b/spec/features/commits_spec.rb
index db841ffc627..4b38df175e2 100644
--- a/spec/features/commits_spec.rb
+++ b/spec/features/commits_spec.rb
@@ -10,6 +10,7 @@ RSpec.describe 'Commits' do
before do
sign_in(user)
stub_ci_pipeline_to_return_yaml_file
+ stub_feature_flags(pipeline_tabs_vue: false)
end
let(:creator) { create(:user, developer_projects: [project]) }
@@ -93,6 +94,7 @@ RSpec.describe 'Commits' do
context 'Download artifacts', :js do
before do
+ stub_feature_flags(pipeline_tabs_vue: false)
create(:ci_job_artifact, :archive, file: artifacts_file, job: build)
end
@@ -122,6 +124,7 @@ RSpec.describe 'Commits' do
context "when logged as reporter", :js do
before do
+ stub_feature_flags(pipeline_tabs_vue: false)
project.add_reporter(user)
create(:ci_job_artifact, :archive, file: artifacts_file, job: build)
visit builds_project_pipeline_path(project, pipeline)
diff --git a/spec/features/error_tracking/user_searches_sentry_errors_spec.rb b/spec/features/error_tracking/user_searches_sentry_errors_spec.rb
index 89bf79ebb81..40718deed75 100644
--- a/spec/features/error_tracking/user_searches_sentry_errors_spec.rb
+++ b/spec/features/error_tracking/user_searches_sentry_errors_spec.rb
@@ -30,7 +30,7 @@ RSpec.describe 'When a user searches for Sentry errors', :js, :use_clean_rails_m
expect(results.count).to be(3)
end
- find('.gl-form-input').set('NotFound').native.send_keys(:return)
+ find('.filtered-search-input-container .gl-form-input').set('NotFound').native.send_keys(:return)
page.within(find('.gl-table')) do
results = page.all('.table-row')
diff --git a/spec/features/groups/clusters/eks_spec.rb b/spec/features/groups/clusters/eks_spec.rb
index 3cca2d0919c..0e64a2faf3e 100644
--- a/spec/features/groups/clusters/eks_spec.rb
+++ b/spec/features/groups/clusters/eks_spec.rb
@@ -20,8 +20,8 @@ RSpec.describe 'Group AWS EKS Cluster', :js do
before do
visit group_clusters_path(group)
- click_button 'Actions'
- click_link 'Create a new cluster'
+ click_button(class: 'dropdown-toggle-split')
+ click_link 'Create a cluster (deprecated)'
end
context 'when user creates a cluster on AWS EKS' do
diff --git a/spec/features/groups/clusters/user_spec.rb b/spec/features/groups/clusters/user_spec.rb
index 2ed6ddc09ab..74ea72b238f 100644
--- a/spec/features/groups/clusters/user_spec.rb
+++ b/spec/features/groups/clusters/user_spec.rb
@@ -25,7 +25,7 @@ RSpec.describe 'User Cluster', :js do
before do
visit group_clusters_path(group)
- click_link 'Connect with a certificate'
+ click_link 'Connect a cluster (deprecated)'
end
context 'when user filled form with valid parameters' do
@@ -119,7 +119,6 @@ RSpec.describe 'User Cluster', :js do
it 'user sees creation form with the successful message' do
expect(page).to have_content('Kubernetes cluster integration was successfully removed.')
- expect(page).to have_link('Connect with a certificate')
end
end
end
diff --git a/spec/features/groups/group_runners_spec.rb b/spec/features/groups/group_runners_spec.rb
new file mode 100644
index 00000000000..1d821edefa3
--- /dev/null
+++ b/spec/features/groups/group_runners_spec.rb
@@ -0,0 +1,168 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe "Group Runners" do
+ include Spec::Support::Helpers::Features::RunnersHelpers
+
+ let_it_be(:group_owner) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, group: group) }
+
+ before do
+ group.add_owner(group_owner)
+ sign_in(group_owner)
+ end
+
+ describe "Group runners page", :js do
+ let!(:group_registration_token) { group.runners_token }
+
+ context "runners registration" do
+ before do
+ visit group_runners_path(group)
+ end
+
+ it_behaves_like "shows and resets runner registration token" do
+ let(:dropdown_text) { 'Register a group runner' }
+ let(:registration_token) { group_registration_token }
+ end
+ end
+
+ context "with no runners" do
+ before do
+ visit group_runners_path(group)
+ end
+
+ it_behaves_like "shows no runners"
+
+ it 'shows tabs with total counts equal to 0' do
+ expect(page).to have_link('All 0')
+ expect(page).to have_link('Group 0')
+ expect(page).to have_link('Project 0')
+ end
+ end
+
+ context "with an online group runner" do
+ let!(:group_runner) do
+ create(:ci_runner, :group, groups: [group], description: 'runner-foo', contacted_at: Time.zone.now)
+ end
+
+ before do
+ visit group_runners_path(group)
+ end
+
+ it_behaves_like 'shows runner in list' do
+ let(:runner) { group_runner }
+ end
+
+ it_behaves_like 'pauses, resumes and deletes a runner' do
+ let(:runner) { group_runner }
+ end
+
+ it 'shows a group badge' do
+ within_runner_row(group_runner.id) do
+ expect(page).to have_selector '.badge', text: 'group'
+ end
+ end
+
+ it 'can edit runner information' do
+ within_runner_row(group_runner.id) do
+ expect(find_link('Edit')[:href]).to end_with(edit_group_runner_path(group, group_runner))
+ end
+ end
+ end
+
+ context "with an online project runner" do
+ let!(:project_runner) do
+ create(:ci_runner, :project, projects: [project], description: 'runner-bar', contacted_at: Time.zone.now)
+ end
+
+ before do
+ visit group_runners_path(group)
+ end
+
+ it_behaves_like 'shows runner in list' do
+ let(:runner) { project_runner }
+ end
+
+ it_behaves_like 'pauses, resumes and deletes a runner' do
+ let(:runner) { project_runner }
+ end
+
+ it 'shows a project (specific) badge' do
+ within_runner_row(project_runner.id) do
+ expect(page).to have_selector '.badge', text: 'specific'
+ end
+ end
+
+ it 'can edit runner information' do
+ within_runner_row(project_runner.id) do
+ expect(find_link('Edit')[:href]).to end_with(edit_group_runner_path(group, project_runner))
+ end
+ end
+ end
+
+ context 'with a multi-project runner' do
+ let(:project) { create(:project, group: group) }
+ let(:project_2) { create(:project, group: group) }
+ let!(:runner) { create(:ci_runner, :project, projects: [project, project_2], description: 'group-runner') }
+
+ it 'user cannot remove the project runner' do
+ visit group_runners_path(group)
+
+ within_runner_row(runner.id) do
+ expect(page).to have_button 'Delete runner', disabled: true
+ end
+ end
+ end
+
+ context 'filtered search' do
+ before do
+ visit group_runners_path(group)
+ end
+
+ it 'allows user to search by paused and status', :js do
+ focus_filtered_search
+
+ page.within(search_bar_selector) do
+ expect(page).to have_link('Paused')
+ expect(page).to have_content('Status')
+ end
+ end
+ end
+ end
+
+ describe "Group runner edit page", :js do
+ let!(:runner) do
+ create(:ci_runner, :group, groups: [group], description: 'runner-foo', contacted_at: Time.zone.now)
+ end
+
+ it 'user edits the runner to be protected' do
+ visit edit_group_runner_path(group, runner)
+
+ expect(page.find_field('runner[access_level]')).not_to be_checked
+
+ check 'runner_access_level'
+ click_button 'Save changes'
+
+ expect(page).to have_content 'Protected Yes'
+ end
+
+ context 'when a runner has a tag' do
+ before do
+ runner.update!(tag_list: ['tag'])
+ end
+
+ it 'user edits runner not to run untagged jobs' do
+ visit edit_group_runner_path(group, runner)
+
+ expect(page.find_field('runner[run_untagged]')).to be_checked
+
+ uncheck 'runner_run_untagged'
+ click_button 'Save changes'
+
+ expect(page).to have_content 'Can run untagged jobs No'
+ end
+ end
+ end
+end
diff --git a/spec/features/groups/import_export/export_file_spec.rb b/spec/features/groups/import_export/export_file_spec.rb
index 9feb8085e66..e3cb1ad77a7 100644
--- a/spec/features/groups/import_export/export_file_spec.rb
+++ b/spec/features/groups/import_export/export_file_spec.rb
@@ -26,22 +26,6 @@ RSpec.describe 'Group Export', :js do
end
end
- context 'when the group import/export FF is disabled' do
- before do
- stub_feature_flags(group_import_export: false)
-
- group.add_owner(user)
- sign_in(user)
- end
-
- it 'does not show the group export options' do
- visit edit_group_path(group)
-
- expect(page).to have_content('Advanced')
- expect(page).not_to have_content('Export group')
- end
- end
-
context 'when the signed in user does not have the required permission level' do
before do
group.add_guest(user)
diff --git a/spec/features/groups/members/manage_groups_spec.rb b/spec/features/groups/members/manage_groups_spec.rb
index 5ab5a7ea716..5a9223d9ee8 100644
--- a/spec/features/groups/members/manage_groups_spec.rb
+++ b/spec/features/groups/members/manage_groups_spec.rb
@@ -3,7 +3,6 @@
require 'spec_helper'
RSpec.describe 'Groups > Members > Manage groups', :js do
- include Select2Helper
include Spec::Support::Helpers::Features::MembersHelpers
include Spec::Support::Helpers::Features::InviteMembersModalHelper
include Spec::Support::Helpers::ModalHelpers
@@ -119,16 +118,92 @@ RSpec.describe 'Groups > Members > Manage groups', :js do
describe 'group search results' do
let_it_be(:group, refind: true) { create(:group) }
- let_it_be(:group_within_hierarchy) { create(:group, parent: group) }
- let_it_be(:group_outside_hierarchy) { create(:group) }
- before_all do
- group.add_owner(user)
- group_within_hierarchy.add_owner(user)
- group_outside_hierarchy.add_owner(user)
+ context 'with instance admin considerations' do
+ let_it_be(:group_to_share) { create(:group) }
+
+ context 'when user is an admin' do
+ let_it_be(:admin) { create(:admin) }
+
+ before do
+ sign_in(admin)
+ gitlab_enable_admin_mode_sign_in(admin)
+ end
+
+ it 'shows groups where the admin has no direct membership' do
+ visit group_group_members_path(group)
+
+ click_on 'Invite a group'
+ click_on 'Select a group'
+ wait_for_requests
+
+ page.within(group_dropdown_selector) do
+ expect_to_have_group(group_to_share)
+ expect_not_to_have_group(group)
+ end
+ end
+
+ it 'shows groups where the admin has at least guest level membership' do
+ group_to_share.add_guest(admin)
+
+ visit group_group_members_path(group)
+
+ click_on 'Invite a group'
+ click_on 'Select a group'
+ wait_for_requests
+
+ page.within(group_dropdown_selector) do
+ expect_to_have_group(group_to_share)
+ expect_not_to_have_group(group)
+ end
+ end
+ end
+
+ context 'when user is not an admin' do
+ before do
+ group.add_owner(user)
+ end
+
+ it 'shows groups where the user has no direct membership' do
+ visit group_group_members_path(group)
+
+ click_on 'Invite a group'
+ click_on 'Select a group'
+ wait_for_requests
+
+ page.within(group_dropdown_selector) do
+ expect_not_to_have_group(group_to_share)
+ expect_not_to_have_group(group)
+ end
+ end
+
+ it 'shows groups where the user has at least guest level membership' do
+ group_to_share.add_guest(user)
+
+ visit group_group_members_path(group)
+
+ click_on 'Invite a group'
+ click_on 'Select a group'
+ wait_for_requests
+
+ page.within(group_dropdown_selector) do
+ expect_to_have_group(group_to_share)
+ expect_not_to_have_group(group)
+ end
+ end
+ end
end
- context 'when the invite members group modal is enabled' do
+ context 'when user is not an admin and there are hierarchy considerations' do
+ let_it_be(:group_within_hierarchy) { create(:group, parent: group) }
+ let_it_be(:group_outside_hierarchy) { create(:group) }
+
+ before_all do
+ group.add_owner(user)
+ group_within_hierarchy.add_owner(user)
+ group_outside_hierarchy.add_owner(user)
+ end
+
it 'does not show self or ancestors', :aggregate_failures do
group_sibbling = create(:group, parent: group)
group_sibbling.add_owner(user)
@@ -139,46 +214,46 @@ RSpec.describe 'Groups > Members > Manage groups', :js do
click_on 'Select a group'
wait_for_requests
- page.within('[data-testid="group-select-dropdown"]') do
- expect(page).to have_selector("[entity-id='#{group_outside_hierarchy.id}']")
- expect(page).to have_selector("[entity-id='#{group_sibbling.id}']")
- expect(page).not_to have_selector("[entity-id='#{group.id}']")
- expect(page).not_to have_selector("[entity-id='#{group_within_hierarchy.id}']")
+ page.within(group_dropdown_selector) do
+ expect_to_have_group(group_outside_hierarchy)
+ expect_to_have_group(group_sibbling)
+ expect_not_to_have_group(group)
+ expect_not_to_have_group(group_within_hierarchy)
end
end
- end
- context 'when sharing with groups outside the hierarchy is enabled' do
- it 'shows groups within and outside the hierarchy in search results' do
- visit group_group_members_path(group)
+ context 'when sharing with groups outside the hierarchy is enabled' do
+ it 'shows groups within and outside the hierarchy in search results' do
+ visit group_group_members_path(group)
- click_on 'Invite a group'
- click_on 'Select a group'
+ click_on 'Invite a group'
+ click_on 'Select a group'
+ wait_for_requests
- expect(page).to have_text group_within_hierarchy.name
- expect(page).to have_text group_outside_hierarchy.name
+ page.within(group_dropdown_selector) do
+ expect_to_have_group(group_within_hierarchy)
+ expect_to_have_group(group_outside_hierarchy)
+ end
+ end
end
- end
- context 'when sharing with groups outside the hierarchy is disabled' do
- before do
- group.namespace_settings.update!(prevent_sharing_groups_outside_hierarchy: true)
- end
+ context 'when sharing with groups outside the hierarchy is disabled' do
+ before do
+ group.namespace_settings.update!(prevent_sharing_groups_outside_hierarchy: true)
+ end
- it 'shows only groups within the hierarchy in search results' do
- visit group_group_members_path(group)
+ it 'shows only groups within the hierarchy in search results' do
+ visit group_group_members_path(group)
- click_on 'Invite a group'
- click_on 'Select a group'
+ click_on 'Invite a group'
+ click_on 'Select a group'
- expect(page).to have_text group_within_hierarchy.name
- expect(page).not_to have_text group_outside_hierarchy.name
+ page.within(group_dropdown_selector) do
+ expect_to_have_group(group_within_hierarchy)
+ expect_not_to_have_group(group_outside_hierarchy)
+ end
+ end
end
end
end
-
- def click_groups_tab
- expect(page).to have_link 'Groups'
- click_link "Groups"
- end
end
diff --git a/spec/features/groups/members/manage_members_spec.rb b/spec/features/groups/members/manage_members_spec.rb
index 533d2118b30..468001c3be6 100644
--- a/spec/features/groups/members/manage_members_spec.rb
+++ b/spec/features/groups/members/manage_members_spec.rb
@@ -42,46 +42,6 @@ RSpec.describe 'Groups > Members > Manage members' do
end
end
- it 'add user to group', :js, :snowplow, :aggregate_failures do
- group.add_owner(user1)
-
- visit group_group_members_path(group)
-
- invite_member(user2.name, role: 'Reporter')
-
- page.within(second_row) do
- expect(page).to have_content(user2.name)
- expect(page).to have_button('Reporter')
- end
-
- expect_snowplow_event(
- category: 'Members::CreateService',
- action: 'create_member',
- label: 'group-members-page',
- property: 'existing_user',
- user: user1
- )
- end
-
- it 'do not disclose email addresses', :js do
- group.add_owner(user1)
- create(:user, email: 'undisclosed_email@gitlab.com', name: "Jane 'invisible' Doe")
-
- visit group_group_members_path(group)
-
- click_on 'Invite members'
- find('[data-testid="members-token-select-input"]').set('@gitlab.com')
-
- wait_for_requests
-
- expect(page).to have_content('No matches found')
-
- find('[data-testid="members-token-select-input"]').set('undisclosed_email@gitlab.com')
- wait_for_requests
-
- expect(page).to have_content('Invite "undisclosed_email@gitlab.com" by email')
- end
-
it 'remove user from group', :js do
group.add_owner(user1)
group.add_developer(user2)
@@ -106,43 +66,29 @@ RSpec.describe 'Groups > Members > Manage members' do
end
end
- it 'add yourself to group when already an owner', :js, :aggregate_failures do
- group.add_owner(user1)
-
- visit group_group_members_path(group)
-
- invite_member(user1.name, role: 'Reporter')
-
- page.within(first_row) do
- expect(page).to have_content(user1.name)
- expect(page).to have_content('Owner')
- end
- end
+ context 'when inviting' do
+ it 'add yourself to group when already an owner', :js do
+ group.add_owner(user1)
- it 'invite user to group', :js, :snowplow do
- group.add_owner(user1)
+ visit group_group_members_path(group)
- visit group_group_members_path(group)
+ invite_member(user1.name, role: 'Reporter', refresh: false)
- invite_member('test@example.com', role: 'Reporter')
+ expect(page).to have_selector(invite_modal_selector)
+ expect(page).to have_content("not authorized to update member")
- expect(page).to have_link 'Invited'
- click_link 'Invited'
+ page.refresh
- aggregate_failures do
- page.within(members_table) do
- expect(page).to have_content('test@example.com')
- expect(page).to have_content('Invited')
- expect(page).to have_button('Reporter')
+ page.within find_member_row(user1) do
+ expect(page).to have_content('Owner')
end
+ end
- expect_snowplow_event(
- category: 'Members::InviteService',
- action: 'create_member',
- label: 'group-members-page',
- property: 'net_new_user',
- user: user1
- )
+ it_behaves_like 'inviting members', 'group-members-page' do
+ let_it_be(:entity) { group }
+ let_it_be(:members_page_path) { group_group_members_path(entity) }
+ let_it_be(:subentity) { create(:group, parent: group) }
+ let_it_be(:subentity_members_page_path) { group_group_members_path(subentity) }
end
end
@@ -169,4 +115,57 @@ RSpec.describe 'Groups > Members > Manage members' do
end
end
end
+
+ describe 'member search results', :js do
+ before do
+ group.add_owner(user1)
+ end
+
+ it 'does not disclose email addresses' do
+ create(:user, email: 'undisclosed_email@gitlab.com', name: "Jane 'invisible' Doe")
+
+ visit group_group_members_path(group)
+
+ click_on 'Invite members'
+ find(member_dropdown_selector).set('@gitlab.com')
+
+ wait_for_requests
+
+ expect(page).to have_content('No matches found')
+
+ find(member_dropdown_selector).set('undisclosed_email@gitlab.com')
+ wait_for_requests
+
+ expect(page).to have_content('Invite "undisclosed_email@gitlab.com" by email')
+ end
+
+ it 'does not show project_bots', :aggregate_failures do
+ internal_project_bot = create(:user, :project_bot, name: '_internal_project_bot_')
+ project = create(:project, group: group)
+ project.add_maintainer(internal_project_bot)
+
+ external_group = create(:group)
+ external_project_bot = create(:user, :project_bot, name: '_external_project_bot_')
+ external_project = create(:project, group: external_group)
+ external_project.add_maintainer(external_project_bot)
+ external_project.add_maintainer(user1)
+
+ visit group_group_members_path(group)
+
+ click_on 'Invite members'
+
+ page.within invite_modal_selector do
+ field = find(member_dropdown_selector)
+ field.native.send_keys :tab
+ field.click
+
+ wait_for_requests
+
+ expect(page).to have_content(user1.name)
+ expect(page).to have_content(user2.name)
+ expect(page).not_to have_content(internal_project_bot.name)
+ expect(page).not_to have_content(external_project_bot.name)
+ end
+ end
+ end
end
diff --git a/spec/features/groups/members/sort_members_spec.rb b/spec/features/groups/members/sort_members_spec.rb
index 03758e0d401..bf8e64fa1e2 100644
--- a/spec/features/groups/members/sort_members_spec.rb
+++ b/spec/features/groups/members/sort_members_spec.rb
@@ -5,8 +5,8 @@ require 'spec_helper'
RSpec.describe 'Groups > Members > Sort members', :js do
include Spec::Support::Helpers::Features::MembersHelpers
- let(:owner) { create(:user, name: 'John Doe') }
- let(:developer) { create(:user, name: 'Mary Jane', last_sign_in_at: 5.days.ago) }
+ let(:owner) { create(:user, name: 'John Doe', created_at: 5.days.ago, last_activity_on: Date.today) }
+ let(:developer) { create(:user, name: 'Mary Jane', created_at: 1.day.ago, last_sign_in_at: 5.days.ago, last_activity_on: Date.today - 5) }
let(:group) { create(:group) }
before do
@@ -50,6 +50,42 @@ RSpec.describe 'Groups > Members > Sort members', :js do
expect_sort_by('Max role', :desc)
end
+ it 'sorts by user created on ascending' do
+ visit_members_list(sort: :oldest_created_user)
+
+ expect(first_row.text).to include(owner.name)
+ expect(second_row.text).to include(developer.name)
+
+ expect_sort_by('Created on', :asc)
+ end
+
+ it 'sorts by user created on descending' do
+ visit_members_list(sort: :recent_created_user)
+
+ expect(first_row.text).to include(developer.name)
+ expect(second_row.text).to include(owner.name)
+
+ expect_sort_by('Created on', :desc)
+ end
+
+ it 'sorts by last activity ascending' do
+ visit_members_list(sort: :oldest_last_activity)
+
+ expect(first_row.text).to include(developer.name)
+ expect(second_row.text).to include(owner.name)
+
+ expect_sort_by('Last activity', :asc)
+ end
+
+ it 'sorts by last activity descending' do
+ visit_members_list(sort: :recent_last_activity)
+
+ expect(first_row.text).to include(owner.name)
+ expect(second_row.text).to include(developer.name)
+
+ expect_sort_by('Last activity', :desc)
+ end
+
it 'sorts by access granted ascending' do
visit_members_list(sort: :last_joined)
diff --git a/spec/features/groups/milestone_spec.rb b/spec/features/groups/milestone_spec.rb
index 4edf27e8fa4..42eaa8358a1 100644
--- a/spec/features/groups/milestone_spec.rb
+++ b/spec/features/groups/milestone_spec.rb
@@ -66,7 +66,7 @@ RSpec.describe 'Group milestones' do
context 'when no milestones' do
it 'renders no milestones text' do
visit group_milestones_path(group)
- expect(page).to have_content('No milestones to show')
+ expect(page).to have_content('Use milestones to track issues and merge requests')
end
end
diff --git a/spec/features/groups/milestones_sorting_spec.rb b/spec/features/groups/milestones_sorting_spec.rb
index a06e64fdee0..22d7ff91d41 100644
--- a/spec/features/groups/milestones_sorting_spec.rb
+++ b/spec/features/groups/milestones_sorting_spec.rb
@@ -17,7 +17,7 @@ RSpec.describe 'Milestones sorting', :js do
sign_in(user)
end
- it 'visit group milestones and sort by due_date_asc' do
+ it 'visit group milestones and sort by due_date_asc', :js do
visit group_milestones_path(group)
expect(page).to have_button('Due soon')
@@ -27,13 +27,13 @@ RSpec.describe 'Milestones sorting', :js do
expect(page.all('ul.content-list > li strong > a').map(&:text)).to eq(['v2.0', 'v2.0', 'v3.0', 'v1.0', 'v1.0'])
end
- click_button 'Due soon'
+ within '[data-testid=milestone_sort_by_dropdown]' do
+ click_button 'Due soon'
+ expect(find('.gl-new-dropdown-contents').all('.gl-new-dropdown-item-text-wrapper p').map(&:text)).to eq(['Due soon', 'Due later', 'Start soon', 'Start later', 'Name, ascending', 'Name, descending'])
- expect(find('ul.dropdown-menu-sort li').all('a').map(&:text)).to eq(['Due soon', 'Due later', 'Start soon', 'Start later', 'Name, ascending', 'Name, descending'])
-
- click_link 'Due later'
-
- expect(page).to have_button('Due later')
+ click_button 'Due later'
+ expect(page).to have_button('Due later')
+ end
# assert descending sorting
within '.milestones' do
diff --git a/spec/features/groups/settings/ci_cd_spec.rb b/spec/features/groups/settings/ci_cd_spec.rb
index 8851aeb6381..c5ad524e647 100644
--- a/spec/features/groups/settings/ci_cd_spec.rb
+++ b/spec/features/groups/settings/ci_cd_spec.rb
@@ -5,52 +5,73 @@ require 'spec_helper'
RSpec.describe 'Group CI/CD settings' do
include WaitForRequests
- let(:user) { create(:user) }
- let(:group) { create(:group) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group, reload: true) { create(:group) }
- before do
+ before_all do
group.add_owner(user)
+ end
+
+ before do
sign_in(user)
end
- describe 'new group runners view banner' do
- it 'displays banner' do
- visit group_settings_ci_cd_path(group)
+ describe 'Runners section' do
+ let(:shared_runners_toggle) { page.find('[data-testid="enable-runners-toggle"]') }
+
+ context 'with runner_list_group_view_vue_ui enabled' do
+ before do
+ visit group_settings_ci_cd_path(group)
+ end
+
+ it 'displays the new group runners view banner' do
+ expect(page).to have_content(s_('Runners|New group runners view'))
+ expect(page).to have_link(href: group_runners_path(group))
+ end
- expect(page).to have_content(s_('Runners|New group runners view'))
- expect(page).to have_link(href: group_runners_path(group))
+ it 'has "Enable shared runners for this group" toggle', :js do
+ expect(shared_runners_toggle).to have_content(_('Enable shared runners for this group'))
+ end
end
- it 'does not display banner' do
- stub_feature_flags(runner_list_group_view_vue_ui: false)
+ context 'with runner_list_group_view_vue_ui disabled' do
+ before do
+ stub_feature_flags(runner_list_group_view_vue_ui: false)
- visit group_settings_ci_cd_path(group)
+ visit group_settings_ci_cd_path(group)
+ end
- expect(page).not_to have_content(s_('Runners|New group runners view'))
- expect(page).not_to have_link(href: group_runners_path(group))
- end
- end
+ it 'does not display the new group runners view banner' do
+ expect(page).not_to have_content(s_('Runners|New group runners view'))
+ expect(page).not_to have_link(href: group_runners_path(group))
+ end
- describe 'runners registration token' do
- let!(:token) { group.runners_token }
+ it 'has "Enable shared runners for this group" toggle', :js do
+ expect(shared_runners_toggle).to have_content(_('Enable shared runners for this group'))
+ end
- before do
- visit group_settings_ci_cd_path(group)
- end
+ context 'with runners registration token' do
+ let!(:token) { group.runners_token }
- it 'has a registration token' do
- expect(page.find('#registration_token')).to have_content(token)
- end
+ before do
+ visit group_settings_ci_cd_path(group)
+ end
- describe 'reload registration token' do
- let(:page_token) { find('#registration_token').text }
+ it 'displays the registration token' do
+ expect(page.find('#registration_token')).to have_content(token)
+ end
- before do
- click_button 'Reset registration token'
- end
+ describe 'reload registration token' do
+ let(:page_token) { find('#registration_token').text }
+
+ before do
+ click_button 'Reset registration token'
+ end
- it 'changes registration token' do
- expect(page_token).not_to eq token
+ it 'changes the registration token' do
+ expect(page_token).not_to eq token
+ end
+ end
end
end
end
diff --git a/spec/features/issuables/shortcuts_issuable_spec.rb b/spec/features/issuables/shortcuts_issuable_spec.rb
index 7e8f39c47a7..528420062dd 100644
--- a/spec/features/issuables/shortcuts_issuable_spec.rb
+++ b/spec/features/issuables/shortcuts_issuable_spec.rb
@@ -15,12 +15,20 @@ RSpec.describe 'Blob shortcuts', :js do
end
shared_examples "quotes the selected text" do
- it "quotes the selected text", :quarantine do
- select_element('.note-text')
+ it 'quotes the selected text in main comment form', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/356388' do
+ select_element('#notes-list .note:first-child .note-text')
find('body').native.send_key('r')
expect(find('.js-main-target-form .js-vue-comment-form').value).to include(note_text)
end
+
+ it 'quotes the selected text in the discussion reply form', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/356388' do
+ find('#notes-list .note:first-child .js-reply-button').click
+ select_element('#notes-list .note:first-child .note-text')
+ find('body').native.send_key('r')
+
+ expect(find('#notes-list .note:first-child .js-vue-markdown-field .js-gfm-input').value).to include(note_text)
+ end
end
describe 'pressing "r"' do
diff --git a/spec/features/issues/incident_issue_spec.rb b/spec/features/issues/incident_issue_spec.rb
index 2956ddede2e..a2519a44604 100644
--- a/spec/features/issues/incident_issue_spec.rb
+++ b/spec/features/issues/incident_issue_spec.rb
@@ -56,5 +56,37 @@ RSpec.describe 'Incident Detail', :js do
end
end
end
+
+ context 'when on summary tab' do
+ before do
+ click_link 'Summary'
+ end
+
+ it 'shows the summary tab with all components' do
+ page.within('.issuable-details') do
+ hidden_items = find_all('.js-issue-widgets')
+
+ # Linked Issues/MRs and comment box
+ expect(hidden_items.count).to eq(2)
+
+ expect(hidden_items).to all(be_visible)
+ end
+ end
+ end
+
+ context 'when on alert details tab' do
+ before do
+ click_link 'Alert details'
+ end
+
+ it 'does not show the linked issues and notes/comment components' do
+ page.within('.issuable-details') do
+ hidden_items = find_all('.js-issue-widgets')
+
+ # Linked Issues/MRs and comment box are hidden on page
+ expect(hidden_items.count).to eq(0)
+ end
+ end
+ end
end
end
diff --git a/spec/features/issues/user_creates_issue_spec.rb b/spec/features/issues/user_creates_issue_spec.rb
index 446f13dc4d0..8a5e33ba18c 100644
--- a/spec/features/issues/user_creates_issue_spec.rb
+++ b/spec/features/issues/user_creates_issue_spec.rb
@@ -71,6 +71,12 @@ RSpec.describe "User creates issue" do
expect(preview).to have_css("gl-emoji")
expect(textarea).not_to be_visible
+
+ click_button("Write")
+ fill_in("Description", with: "/confidential")
+ click_button("Preview")
+
+ expect(form).to have_content('Makes this issue confidential.')
end
end
end
diff --git a/spec/features/issues/user_edits_issue_spec.rb b/spec/features/issues/user_edits_issue_spec.rb
index 8c906e6a27c..3b440002cb5 100644
--- a/spec/features/issues/user_edits_issue_spec.rb
+++ b/spec/features/issues/user_edits_issue_spec.rb
@@ -35,6 +35,12 @@ RSpec.describe "Issues > User edits issue", :js do
end
expect(form).to have_button("Write")
+
+ click_button("Write")
+ fill_in("Description", with: "/confidential")
+ click_button("Preview")
+
+ expect(form).to have_content('Makes this issue confidential.')
end
it 'allows user to select unassigned' do
diff --git a/spec/features/issues/user_sees_sidebar_updates_in_realtime_spec.rb b/spec/features/issues/user_sees_sidebar_updates_in_realtime_spec.rb
index 6473fe01052..311818d2d15 100644
--- a/spec/features/issues/user_sees_sidebar_updates_in_realtime_spec.rb
+++ b/spec/features/issues/user_sees_sidebar_updates_in_realtime_spec.rb
@@ -6,6 +6,10 @@ RSpec.describe 'Issues > Real-time sidebar', :js do
let_it_be(:project) { create(:project, :public) }
let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:user) { create(:user) }
+ let_it_be(:label) { create(:label, project: project, name: 'Development') }
+
+ let(:labels_widget) { find('[data-testid="sidebar-labels"]') }
+ let(:labels_value) { find('[data-testid="value-wrapper"]') }
before_all do
project.add_developer(user)
@@ -32,4 +36,37 @@ RSpec.describe 'Issues > Real-time sidebar', :js do
expect(page.find('.assignee')).to have_content user.name
end
end
+
+ it 'updates the label in real-time' do
+ Capybara::Session.new(:other_session)
+
+ using_session :other_session do
+ visit project_issue_path(project, issue)
+ wait_for_requests
+ expect(labels_value).to have_content('None')
+ end
+
+ sign_in(user)
+
+ visit project_issue_path(project, issue)
+ wait_for_requests
+ expect(labels_value).to have_content('None')
+
+ page.within(labels_widget) do
+ click_on 'Edit'
+ end
+
+ wait_for_all_requests
+
+ click_button label.name
+ click_button 'Close'
+
+ wait_for_requests
+
+ expect(labels_value).to have_content(label.name)
+
+ using_session :other_session do
+ expect(labels_value).to have_content(label.name)
+ end
+ end
end
diff --git a/spec/features/jira_connect/subscriptions_spec.rb b/spec/features/jira_connect/subscriptions_spec.rb
index 0b7321bf271..94c293c88b9 100644
--- a/spec/features/jira_connect/subscriptions_spec.rb
+++ b/spec/features/jira_connect/subscriptions_spec.rb
@@ -36,7 +36,7 @@ RSpec.describe 'Subscriptions Content Security Policy' do
it 'appends to CSP directives' do
visit jira_connect_subscriptions_path(jwt: jwt)
- is_expected.to include("frame-ancestors 'self' https://*.atlassian.net")
+ is_expected.to include("frame-ancestors 'self' https://*.atlassian.net https://*.jira.com")
is_expected.to include("script-src 'self' https://some-cdn.test https://connect-cdn.atl-paas.net")
is_expected.to include("style-src 'self' https://some-cdn.test 'unsafe-inline'")
end
diff --git a/spec/features/jira_oauth_provider_authorize_spec.rb b/spec/features/jira_oauth_provider_authorize_spec.rb
index daecae56101..a216d2d44b2 100644
--- a/spec/features/jira_oauth_provider_authorize_spec.rb
+++ b/spec/features/jira_oauth_provider_authorize_spec.rb
@@ -4,13 +4,13 @@ require 'spec_helper'
RSpec.describe 'JIRA OAuth Provider' do
describe 'JIRA DVCS OAuth Authorization' do
- let(:application) { create(:oauth_application, redirect_uri: oauth_jira_callback_url, scopes: 'read_user') }
+ let(:application) { create(:oauth_application, redirect_uri: oauth_jira_dvcs_callback_url, scopes: 'read_user') }
before do
sign_in(user)
- visit oauth_jira_authorize_path(client_id: application.uid,
- redirect_uri: oauth_jira_callback_url,
+ visit oauth_jira_dvcs_authorize_path(client_id: application.uid,
+ redirect_uri: oauth_jira_dvcs_callback_url,
response_type: 'code',
state: 'my_state',
scope: 'read_user')
diff --git a/spec/features/merge_request/user_merges_merge_request_spec.rb b/spec/features/merge_request/user_merges_merge_request_spec.rb
index d1be93cae02..a861ca2eea5 100644
--- a/spec/features/merge_request/user_merges_merge_request_spec.rb
+++ b/spec/features/merge_request/user_merges_merge_request_spec.rb
@@ -10,7 +10,7 @@ RSpec.describe "User merges a merge request", :js do
end
shared_examples "fast forward merge a merge request" do
- it "merges a merge request", :sidekiq_might_not_need_inline do
+ it "merges a merge request", :sidekiq_inline do
expect(page).to have_content("Fast-forward merge without a merge commit").and have_button("Merge")
page.within(".mr-state-widget") do
@@ -42,4 +42,23 @@ RSpec.describe "User merges a merge request", :js do
it_behaves_like "fast forward merge a merge request"
end
end
+
+ context 'sidebar merge requests counter' do
+ let(:project) { create(:project, :public, :repository) }
+ let!(:merge_request) { create(:merge_request, source_project: project) }
+
+ it 'decrements the open MR count', :sidekiq_inline do
+ create(:merge_request, source_project: project, source_branch: 'branch-1')
+
+ visit(merge_request_path(merge_request))
+
+ expect(page).to have_css('.js-merge-counter', text: '2')
+
+ page.within(".mr-state-widget") do
+ click_button("Merge")
+ end
+
+ expect(page).to have_css('.js-merge-counter', text: '1')
+ end
+ end
end
diff --git a/spec/features/merge_request/user_posts_notes_spec.rb b/spec/features/merge_request/user_posts_notes_spec.rb
index 1779567624c..ad602afe68a 100644
--- a/spec/features/merge_request/user_posts_notes_spec.rb
+++ b/spec/features/merge_request/user_posts_notes_spec.rb
@@ -169,7 +169,7 @@ RSpec.describe 'Merge request > User posts notes', :js do
end
page.within('.modal') do
- click_button('OK', match: :first)
+ click_button('Cancel editing', match: :first)
end
expect(find('.js-note-text').text).to eq ''
diff --git a/spec/features/merge_request/user_sees_merge_widget_spec.rb b/spec/features/merge_request/user_sees_merge_widget_spec.rb
index 27f7c699c50..c9b21d4a4ae 100644
--- a/spec/features/merge_request/user_sees_merge_widget_spec.rb
+++ b/spec/features/merge_request/user_sees_merge_widget_spec.rb
@@ -17,6 +17,9 @@ RSpec.describe 'Merge request > User sees merge widget', :js do
project.add_maintainer(user)
project_only_mwps.add_maintainer(user)
sign_in(user)
+
+ stub_feature_flags(refactor_mr_widgets_extensions: false)
+ stub_feature_flags(refactor_mr_widgets_extensions_user: false)
end
context 'new merge request', :sidekiq_might_not_need_inline do
diff --git a/spec/features/merge_request/user_suggests_changes_on_diff_spec.rb b/spec/features/merge_request/user_suggests_changes_on_diff_spec.rb
index beb658bb7a0..f77a42ee506 100644
--- a/spec/features/merge_request/user_suggests_changes_on_diff_spec.rb
+++ b/spec/features/merge_request/user_suggests_changes_on_diff_spec.rb
@@ -379,4 +379,41 @@ RSpec.describe 'User comments on a diff', :js do
end
end
end
+
+ context 'failed to load metadata' do
+ let(:dummy_controller) do
+ Class.new(Projects::MergeRequests::DiffsController) do
+ def diffs_metadata
+ render json: '', status: :internal_server_error
+ end
+ end
+ end
+
+ before do
+ stub_const('Projects::MergeRequests::DiffsController', dummy_controller)
+
+ click_diff_line(find_by_scrolling("[id='#{sample_compare.changes[1][:line_code]}']"))
+
+ page.within('.js-discussion-note-form') do
+ fill_in('note_note', with: "```suggestion\n# change to a comment\n```")
+ click_button('Add comment now')
+ end
+
+ wait_for_requests
+
+ visit(project_merge_request_path(project, merge_request))
+
+ wait_for_requests
+ end
+
+ it 'displays an error' do
+ page.within('.discussion-notes') do
+ click_button('Apply suggestion')
+
+ wait_for_requests
+
+ expect(page).to have_content('Unable to fully load the default commit message. You can still apply this suggestion and the commit message will be correct.')
+ end
+ end
+ end
end
diff --git a/spec/features/merge_requests/user_mass_updates_spec.rb b/spec/features/merge_requests/user_mass_updates_spec.rb
index f781ba0827c..a15b6072e70 100644
--- a/spec/features/merge_requests/user_mass_updates_spec.rb
+++ b/spec/features/merge_requests/user_mass_updates_spec.rb
@@ -70,7 +70,7 @@ RSpec.describe 'Merge requests > User mass updates', :js do
it 'updates merge request with assignee' do
change_assignee(user.name)
- expect(find('.issuable-meta a.author-link')[:title]).to eq "Attention requested from assignee #{user.name}, go to their profile."
+ expect(find('.issuable-meta a.author-link')[:title]).to eq "Attention requested from assignee #{user.name}"
end
end
end
diff --git a/spec/features/milestones/user_deletes_milestone_spec.rb b/spec/features/milestones/user_deletes_milestone_spec.rb
index ede9faed876..40626407642 100644
--- a/spec/features/milestones/user_deletes_milestone_spec.rb
+++ b/spec/features/milestones/user_deletes_milestone_spec.rb
@@ -21,7 +21,7 @@ RSpec.describe "User deletes milestone", :js do
click_button("Delete")
click_button("Delete milestone")
- expect(page).to have_content("No milestones to show")
+ expect(page).to have_content("Use milestones to track issues and merge requests over a fixed period of time")
visit(activity_project_path(project))
diff --git a/spec/features/oauth_login_spec.rb b/spec/features/oauth_login_spec.rb
index 93674057fed..ea5bb8c33b2 100644
--- a/spec/features/oauth_login_spec.rb
+++ b/spec/features/oauth_login_spec.rb
@@ -16,7 +16,7 @@ RSpec.describe 'OAuth Login', :js, :allow_forgery_protection do
end
providers = [:github, :twitter, :bitbucket, :gitlab, :google_oauth2,
- :facebook, :cas3, :auth0, :authentiq, :salesforce, :dingtalk]
+ :facebook, :cas3, :auth0, :authentiq, :salesforce, :dingtalk, :alicloud]
around do |example|
with_omniauth_full_host { example.run }
diff --git a/spec/features/profiles/personal_access_tokens_spec.rb b/spec/features/profiles/personal_access_tokens_spec.rb
index f1e5658cd7b..8cbc0491441 100644
--- a/spec/features/profiles/personal_access_tokens_spec.rb
+++ b/spec/features/profiles/personal_access_tokens_spec.rb
@@ -47,14 +47,14 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do
click_on "1"
# Scopes
- check "api"
+ check "read_api"
check "read_user"
click_on "Create personal access token"
expect(active_personal_access_tokens).to have_text(name)
expect(active_personal_access_tokens).to have_text('in')
- expect(active_personal_access_tokens).to have_text('api')
+ expect(active_personal_access_tokens).to have_text('read_api')
expect(active_personal_access_tokens).to have_text('read_user')
expect(created_personal_access_token).not_to be_empty
end
diff --git a/spec/features/profiles/user_edit_profile_spec.rb b/spec/features/profiles/user_edit_profile_spec.rb
index 026da5814e3..4b6ed458c68 100644
--- a/spec/features/profiles/user_edit_profile_spec.rb
+++ b/spec/features/profiles/user_edit_profile_spec.rb
@@ -8,8 +8,6 @@ RSpec.describe 'User edit profile' do
let(:user) { create(:user) }
before do
- stub_feature_flags(improved_emoji_picker: false)
-
sign_in(user)
visit(profile_path)
end
@@ -169,10 +167,9 @@ RSpec.describe 'User edit profile' do
context 'user status', :js do
def select_emoji(emoji_name, is_modal = false)
- emoji_menu_class = is_modal ? '.js-modal-status-emoji-menu' : '.js-status-emoji-menu'
- toggle_button = find('.js-toggle-emoji-menu')
+ toggle_button = find('.emoji-menu-toggle-button')
toggle_button.click
- emoji_button = find(%Q{#{emoji_menu_class} .js-emoji-btn gl-emoji[data-name="#{emoji_name}"]})
+ emoji_button = find("gl-emoji[data-name=\"#{emoji_name}\"]")
emoji_button.click
end
@@ -207,7 +204,7 @@ RSpec.describe 'User edit profile' do
end
it 'adds message and emoji to user status' do
- emoji = 'tanabata_tree'
+ emoji = '8ball'
message = 'Playing outside'
select_emoji(emoji)
fill_in 'js-status-message-field', with: message
@@ -356,7 +353,7 @@ RSpec.describe 'User edit profile' do
end
it 'adds emoji to user status' do
- emoji = 'biohazard'
+ emoji = '8ball'
open_user_status_modal
select_emoji(emoji, true)
set_user_status_in_modal
@@ -387,18 +384,18 @@ RSpec.describe 'User edit profile' do
it 'opens the emoji modal again after closing it' do
open_user_status_modal
- select_emoji('biohazard', true)
+ select_emoji('8ball', true)
- find('.js-toggle-emoji-menu').click
+ find('.emoji-menu-toggle-button').click
- expect(page).to have_selector('.emoji-menu')
+ expect(page).to have_selector('.emoji-picker-emoji')
end
it 'does not update the awards panel emoji' do
project.add_maintainer(user)
visit(project_issue_path(project, issue))
- emoji = 'biohazard'
+ emoji = '8ball'
open_user_status_modal
select_emoji(emoji, true)
@@ -420,7 +417,7 @@ RSpec.describe 'User edit profile' do
end
it 'adds message and emoji to user status' do
- emoji = 'tanabata_tree'
+ emoji = '8ball'
message = 'Playing outside'
open_user_status_modal
select_emoji(emoji, true)
@@ -495,9 +492,7 @@ RSpec.describe 'User edit profile' do
open_user_status_modal
find('.js-status-message-field').native.send_keys(message)
- within('.js-toggle-emoji-menu') do
- expect(page).to have_emoji('speech_balloon')
- end
+ expect(page).to have_emoji('speech_balloon')
end
context 'note header' do
diff --git a/spec/features/profiles/user_visits_profile_preferences_page_spec.rb b/spec/features/profiles/user_visits_profile_preferences_page_spec.rb
index e19e29bf63a..4c61e8d45e4 100644
--- a/spec/features/profiles/user_visits_profile_preferences_page_spec.rb
+++ b/spec/features/profiles/user_visits_profile_preferences_page_spec.rb
@@ -18,14 +18,6 @@ RSpec.describe 'User visits the profile preferences page', :js do
end
describe 'User changes their syntax highlighting theme', :js do
- it 'creates a flash message' do
- choose 'user_color_scheme_id_5'
-
- wait_for_requests
-
- expect_preferences_saved_message
- end
-
it 'updates their preference' do
choose 'user_color_scheme_id_5'
diff --git a/spec/features/projects/blobs/balsamiq_spec.rb b/spec/features/projects/blobs/balsamiq_spec.rb
deleted file mode 100644
index bce60856544..00000000000
--- a/spec/features/projects/blobs/balsamiq_spec.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'Balsamiq file blob', :js do
- let(:project) { create(:project, :public, :repository) }
-
- before do
- visit project_blob_path(project, 'add-balsamiq-file/files/images/balsamiq.bmpr')
-
- wait_for_requests
- end
-
- it 'displays Balsamiq file content' do
- expect(page).to have_content("Mobile examples")
- end
-end
diff --git a/spec/features/projects/blobs/blob_line_permalink_updater_spec.rb b/spec/features/projects/blobs/blob_line_permalink_updater_spec.rb
index 11e2d24c36a..9b0edcd09d2 100644
--- a/spec/features/projects/blobs/blob_line_permalink_updater_spec.rb
+++ b/spec/features/projects/blobs/blob_line_permalink_updater_spec.rb
@@ -39,7 +39,7 @@ RSpec.describe 'Blob button line permalinks (BlobLinePermalinkUpdater)', :js do
find('#L3').click
find("#L5").click
- expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(project_blob_path(project, tree_join(sha, path), anchor: "LC5")))
+ expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(project_blob_path(project, tree_join(sha, path), anchor: "L5")))
end
it 'with initial fragment hash, changes fragment hash if line number clicked' do
@@ -50,7 +50,7 @@ RSpec.describe 'Blob button line permalinks (BlobLinePermalinkUpdater)', :js do
find('#L3').click
find("#L5").click
- expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(project_blob_path(project, tree_join(sha, path), anchor: "LC5")))
+ expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(project_blob_path(project, tree_join(sha, path), anchor: "L5")))
end
end
@@ -75,7 +75,7 @@ RSpec.describe 'Blob button line permalinks (BlobLinePermalinkUpdater)', :js do
find('#L3').click
find("#L5").click
- expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(project_blame_path(project, tree_join('master', path), anchor: "LC5")))
+ expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(project_blame_path(project, tree_join('master', path), anchor: "L5")))
end
it 'with initial fragment hash, changes fragment hash if line number clicked' do
@@ -86,7 +86,7 @@ RSpec.describe 'Blob button line permalinks (BlobLinePermalinkUpdater)', :js do
find('#L3').click
find("#L5").click
- expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(project_blame_path(project, tree_join('master', path), anchor: "LC5")))
+ expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(project_blame_path(project, tree_join('master', path), anchor: "L5")))
end
end
end
diff --git a/spec/features/projects/branches_spec.rb b/spec/features/projects/branches_spec.rb
index 363d08da024..d906bb396be 100644
--- a/spec/features/projects/branches_spec.rb
+++ b/spec/features/projects/branches_spec.rb
@@ -36,6 +36,8 @@ RSpec.describe 'Branches' do
expect(page).to have_content(sorted_branches(repository, count: 5, sort_by: :updated_desc, state: 'active'))
expect(page).to have_content(sorted_branches(repository, count: 4, sort_by: :updated_asc, state: 'stale'))
+ expect(page).to have_button('Copy branch name')
+
expect(page).to have_link('Show more active branches', href: project_branches_filtered_path(project, state: 'active'))
expect(page).not_to have_content('Show more stale branches')
end
@@ -197,14 +199,6 @@ RSpec.describe 'Branches' do
project.add_maintainer(user)
end
- describe 'Initial branches page' do
- it 'shows description for admin' do
- visit project_branches_filtered_path(project, state: 'all')
-
- expect(page).to have_content("Protected branches can be managed in project settings")
- end
- end
-
it 'shows the merge request button' do
visit project_branches_path(project)
diff --git a/spec/features/projects/cluster_agents_spec.rb b/spec/features/projects/cluster_agents_spec.rb
index e9162359940..5d931afe4a7 100644
--- a/spec/features/projects/cluster_agents_spec.rb
+++ b/spec/features/projects/cluster_agents_spec.rb
@@ -27,7 +27,6 @@ RSpec.describe 'ClusterAgents', :js do
end
it 'displays empty state', :aggregate_failures do
- expect(page).to have_content('Install a new agent')
expect(page).to have_selector('.empty-state')
end
end
diff --git a/spec/features/projects/clusters/eks_spec.rb b/spec/features/projects/clusters/eks_spec.rb
index 0dd6effe551..7e599ff1198 100644
--- a/spec/features/projects/clusters/eks_spec.rb
+++ b/spec/features/projects/clusters/eks_spec.rb
@@ -20,7 +20,7 @@ RSpec.describe 'AWS EKS Cluster', :js do
visit project_clusters_path(project)
click_button(class: 'dropdown-toggle-split')
- click_link 'Create a new cluster'
+ click_link 'Create a cluster (certificate - deprecated)'
end
context 'when user creates a cluster on AWS EKS' do
diff --git a/spec/features/projects/clusters/gcp_spec.rb b/spec/features/projects/clusters/gcp_spec.rb
index 90d7e2d02e9..491121a3743 100644
--- a/spec/features/projects/clusters/gcp_spec.rb
+++ b/spec/features/projects/clusters/gcp_spec.rb
@@ -135,7 +135,7 @@ RSpec.describe 'Gcp Cluster', :js do
visit project_clusters_path(project)
click_button(class: 'dropdown-toggle-split')
- click_link 'Connect with a certificate'
+ click_link 'Connect a cluster (certificate - deprecated)'
end
it 'user sees the "Environment scope" field' do
@@ -154,7 +154,6 @@ RSpec.describe 'Gcp Cluster', :js do
it 'user sees creation form with the successful message' do
expect(page).to have_content('Kubernetes cluster integration was successfully removed.')
- expect(page).to have_link('Connect with a certificate')
end
end
end
@@ -220,6 +219,6 @@ RSpec.describe 'Gcp Cluster', :js do
def visit_create_cluster_page
click_button(class: 'dropdown-toggle-split')
- click_link 'Create a new cluster'
+ click_link 'Create a cluster (certificate - deprecated)'
end
end
diff --git a/spec/features/projects/clusters/user_spec.rb b/spec/features/projects/clusters/user_spec.rb
index 3fd78d338da..b6bfaa3a9b9 100644
--- a/spec/features/projects/clusters/user_spec.rb
+++ b/spec/features/projects/clusters/user_spec.rb
@@ -25,8 +25,8 @@ RSpec.describe 'User Cluster', :js do
before do
visit project_clusters_path(project)
- click_link 'Certificate'
- click_link 'Connect with a certificate'
+ click_button(class: 'dropdown-toggle-split')
+ click_link 'Connect a cluster (certificate - deprecated)'
end
context 'when user filled form with valid parameters' do
@@ -108,7 +108,6 @@ RSpec.describe 'User Cluster', :js do
it 'user sees creation form with the successful message' do
expect(page).to have_content('Kubernetes cluster integration was successfully removed.')
- expect(page).to have_link('Connect with a certificate')
end
end
end
diff --git a/spec/features/projects/clusters_spec.rb b/spec/features/projects/clusters_spec.rb
index b9a544144c3..0ecd7795964 100644
--- a/spec/features/projects/clusters_spec.rb
+++ b/spec/features/projects/clusters_spec.rb
@@ -20,7 +20,6 @@ RSpec.describe 'Clusters', :js do
end
it 'sees empty state' do
- expect(page).to have_link('Connect with a certificate')
expect(page).to have_selector('.empty-state')
end
end
@@ -222,11 +221,11 @@ RSpec.describe 'Clusters', :js do
visit project_clusters_path(project)
click_button(class: 'dropdown-toggle-split')
- click_link 'Create a new cluster'
+ click_link 'Create a cluster (certificate - deprecated)'
end
def visit_connect_cluster_page
click_button(class: 'dropdown-toggle-split')
- click_link 'Connect with a certificate'
+ click_link 'Connect a cluster (certificate - deprecated)'
end
end
diff --git a/spec/features/projects/commits/multi_view_diff_spec.rb b/spec/features/projects/commits/multi_view_diff_spec.rb
index ecdd398c739..009dd05c6d1 100644
--- a/spec/features/projects/commits/multi_view_diff_spec.rb
+++ b/spec/features/projects/commits/multi_view_diff_spec.rb
@@ -27,17 +27,11 @@ RSpec.describe 'Multiple view Diffs', :js do
context 'when :rendered_diffs_viewer is off' do
context 'and diff does not have ipynb' do
- include_examples "no multiple viewers", 'ddd0f15ae83993f5cb66a927a28673882e99100b'
+ it_behaves_like "no multiple viewers", 'ddd0f15ae83993f5cb66a927a28673882e99100b'
end
context 'and diff has ipynb' do
- include_examples "no multiple viewers", '5d6ed1503801ca9dc28e95eeb85a7cf863527aee'
-
- it 'shows the transformed diff' do
- diff = page.find('.diff-file, .file-holder', match: :first)
-
- expect(diff['innerHTML']).to include('%% Cell type:markdown id:0aac5da7-745c-4eda-847a-3d0d07a1bb9b tags:')
- end
+ it_behaves_like "no multiple viewers", '5d6ed1503801ca9dc28e95eeb85a7cf863527aee'
end
end
@@ -45,14 +39,28 @@ RSpec.describe 'Multiple view Diffs', :js do
let(:feature_flag_on) { true }
context 'and diff does not include ipynb' do
- include_examples "no multiple viewers", 'ddd0f15ae83993f5cb66a927a28673882e99100b'
- end
+ it_behaves_like "no multiple viewers", 'ddd0f15ae83993f5cb66a927a28673882e99100b'
- context 'and opening a diff with ipynb' do
- context 'but the changes are not renderable' do
- include_examples "no multiple viewers", 'a867a602d2220e5891b310c07d174fbe12122830'
+ context 'and in inline diff' do
+ let(:ref) { '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51' }
+
+ it 'does not change display for non-ipynb' do
+ expect(page).to have_selector line_with_content('new', 1)
+ end
end
+ context 'and in parallel diff' do
+ let(:ref) { '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51' }
+
+ it 'does not change display for non-ipynb' do
+ page.find('#parallel-diff-btn').click
+
+ expect(page).to have_selector line_with_content('new', 1)
+ end
+ end
+ end
+
+ context 'and opening a diff with ipynb' do
it 'loads the rendered diff as hidden' do
diff = page.find('.diff-file, .file-holder', match: :first)
@@ -76,10 +84,55 @@ RSpec.describe 'Multiple view Diffs', :js do
expect(classes_for_element(diff, 'toHideBtn')).not_to include('selected')
expect(classes_for_element(diff, 'toShowBtn')).to include('selected')
end
+
+ it 'transforms the diff' do
+ diff = page.find('.diff-file, .file-holder', match: :first)
+
+ expect(diff['innerHTML']).to include('%% Cell type:markdown id:0aac5da7-745c-4eda-847a-3d0d07a1bb9b tags:')
+ end
+
+ context 'on parallel view' do
+ before do
+ page.find('#parallel-diff-btn').click
+ end
+
+ it 'lines without mapping cannot receive comments' do
+ expect(page).not_to have_selector('td.line_content.nomappinginraw ~ td.diff-line-num > .add-diff-note')
+ expect(page).to have_selector('td.line_content:not(.nomappinginraw) ~ td.diff-line-num > .add-diff-note')
+ end
+
+ it 'lines numbers without mapping are empty' do
+ expect(page).not_to have_selector('td.nomappinginraw + td.diff-line-num')
+ expect(page).to have_selector('td.nomappinginraw + td.diff-line-num', visible: false)
+ end
+
+ it 'transforms the diff' do
+ diff = page.find('.diff-file, .file-holder', match: :first)
+
+ expect(diff['innerHTML']).to include('%% Cell type:markdown id:0aac5da7-745c-4eda-847a-3d0d07a1bb9b tags:')
+ end
+ end
+
+ context 'on inline view' do
+ it 'lines without mapping cannot receive comments' do
+ expect(page).not_to have_selector('tr.line_holder[class$="nomappinginraw"] > td.diff-line-num > .add-diff-note')
+ expect(page).to have_selector('tr.line_holder:not([class$="nomappinginraw"]) > td.diff-line-num > .add-diff-note')
+ end
+
+ it 'lines numbers without mapping are empty' do
+ elements = page.all('tr.line_holder[class$="nomappinginraw"] > td.diff-line-num').map { |e| e.text(:all) }
+
+ expect(elements).to all(be == "")
+ end
+ end
end
end
def classes_for_element(node, data_diff_entity, visible: true)
node.find("[data-diff-toggle-entity=\"#{data_diff_entity}\"]", visible: visible)[:class]
end
+
+ def line_with_content(old_or_new, line_number)
+ "td.#{old_or_new}_line.diff-line-num[data-linenumber=\"#{line_number}\"] > a[data-linenumber=\"#{line_number}\"]"
+ end
end
diff --git a/spec/features/projects/environments/environments_spec.rb b/spec/features/projects/environments/environments_spec.rb
index 99137018d6b..6cf59394af7 100644
--- a/spec/features/projects/environments/environments_spec.rb
+++ b/spec/features/projects/environments/environments_spec.rb
@@ -70,7 +70,7 @@ RSpec.describe 'Environments page', :js do
it 'shows no environments' do
visit_environments(project, scope: 'stopped')
- expect(page).to have_content('You don\'t have any environments right now')
+ expect(page).to have_content(s_('Environments|You don\'t have any stopped environments.'))
end
end
@@ -99,7 +99,7 @@ RSpec.describe 'Environments page', :js do
it 'shows no environments' do
visit_environments(project, scope: 'available')
- expect(page).to have_content('You don\'t have any environments right now')
+ expect(page).to have_content(s_('Environments|You don\'t have any environments.'))
end
end
@@ -120,7 +120,7 @@ RSpec.describe 'Environments page', :js do
end
it 'does not show environments and counters are set to zero' do
- expect(page).to have_content('You don\'t have any environments right now')
+ expect(page).to have_content(s_('Environments|You don\'t have any environments.'))
expect(page).to have_link("#{_('Available')} 0")
expect(page).to have_link("#{_('Stopped')} 0")
diff --git a/spec/features/projects/import_export/import_file_spec.rb b/spec/features/projects/import_export/import_file_spec.rb
index 1e5c5d33ad9..c7fbaa85483 100644
--- a/spec/features/projects/import_export/import_file_spec.rb
+++ b/spec/features/projects/import_export/import_file_spec.rb
@@ -24,9 +24,9 @@ RSpec.describe 'Import/Export - project import integration test', :js do
context 'when selecting the namespace' do
let(:user) { create(:admin) }
let!(:namespace) { user.namespace }
- let(:randomHex) { SecureRandom.hex }
- let(:project_name) { 'Test Project Name' + randomHex }
- let(:project_path) { 'test-project-name' + randomHex }
+ let(:random_hex) { SecureRandom.hex }
+ let(:project_name) { 'Test Project Name' + random_hex }
+ let(:project_path) { 'test-project-name' + random_hex }
it 'user imports an exported project successfully', :sidekiq_might_not_need_inline do
visit new_project_path
diff --git a/spec/features/projects/issues/design_management/user_uploads_designs_spec.rb b/spec/features/projects/issues/design_management/user_uploads_designs_spec.rb
index 762f9c33510..48ae70d3ec9 100644
--- a/spec/features/projects/issues/design_management/user_uploads_designs_spec.rb
+++ b/spec/features/projects/issues/design_management/user_uploads_designs_spec.rb
@@ -10,8 +10,8 @@ RSpec.describe 'User uploads new design', :js do
let(:issue) { create(:issue, project: project) }
before do
- # Cause of raising query limiting threshold https://gitlab.com/gitlab-org/gitlab/-/issues/347334
- stub_const("Gitlab::QueryLimiting::Transaction::THRESHOLD", 102)
+ # Cause of raising query limiting threshold https://gitlab.com/gitlab-org/gitlab/-/issues/358845
+ stub_const("Gitlab::QueryLimiting::Transaction::THRESHOLD", 106)
sign_in(user)
enable_design_management(feature_enabled)
diff --git a/spec/features/projects/jobs/user_browses_jobs_spec.rb b/spec/features/projects/jobs/user_browses_jobs_spec.rb
index fde6240d373..3b70d177fce 100644
--- a/spec/features/projects/jobs/user_browses_jobs_spec.rb
+++ b/spec/features/projects/jobs/user_browses_jobs_spec.rb
@@ -67,19 +67,8 @@ RSpec.describe 'User browses jobs' do
expect(page.find('[data-testid="jobs-all-tab"] .badge').text).to include('0')
end
- it 'shows a tab for Pending jobs and count' do
- expect(page.find('[data-testid="jobs-pending-tab"]').text).to include('Pending')
- expect(page.find('[data-testid="jobs-pending-tab"] .badge').text).to include('0')
- end
-
- it 'shows a tab for Running jobs and count' do
- expect(page.find('[data-testid="jobs-running-tab"]').text).to include('Running')
- expect(page.find('[data-testid="jobs-running-tab"] .badge').text).to include('0')
- end
-
it 'shows a tab for Finished jobs and count' do
expect(page.find('[data-testid="jobs-finished-tab"]').text).to include('Finished')
- expect(page.find('[data-testid="jobs-finished-tab"] .badge').text).to include('0')
end
it 'updates the content when tab is clicked' do
diff --git a/spec/features/projects/members/groups_with_access_list_spec.rb b/spec/features/projects/members/groups_with_access_list_spec.rb
index 6adc3503492..9bd6476f836 100644
--- a/spec/features/projects/members/groups_with_access_list_spec.rb
+++ b/spec/features/projects/members/groups_with_access_list_spec.rb
@@ -5,6 +5,7 @@ require 'spec_helper'
RSpec.describe 'Projects > Members > Groups with access list', :js do
include Spec::Support::Helpers::Features::MembersHelpers
include Spec::Support::Helpers::ModalHelpers
+ include Spec::Support::Helpers::Features::InviteMembersModalHelper
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group, :public) }
@@ -95,8 +96,4 @@ RSpec.describe 'Projects > Members > Groups with access list', :js do
expect(members_table).to have_content(group.full_name)
end
end
-
- def click_groups_tab
- click_link 'Groups'
- end
end
diff --git a/spec/features/projects/members/invite_group_spec.rb b/spec/features/projects/members/invite_group_spec.rb
index 9c256504934..a48229249e0 100644
--- a/spec/features/projects/members/invite_group_spec.rb
+++ b/spec/features/projects/members/invite_group_spec.rb
@@ -17,20 +17,18 @@ RSpec.describe 'Project > Members > Invite group', :js do
visit project_project_members_path(project)
- expect(page).to have_selector('button[data-test-id="invite-group-button"]')
+ expect(page).to have_selector(invite_group_selector)
end
- it 'does not display the button when visiting the page not signed in' do
+ it 'does not display the button when visiting the page not signed in' do
project = create(:project, namespace: create(:group))
visit project_project_members_path(project)
- expect(page).not_to have_selector('button[data-test-id="invite-group-button"]')
+ expect(page).not_to have_selector(invite_group_selector)
end
describe 'Share with group lock' do
- let(:invite_group_selector) { 'button[data-test-id="invite-group-button"]' }
-
shared_examples 'the project can be shared with groups' do
it 'the "Invite a group" button exists' do
visit project_project_members_path(project)
@@ -158,21 +156,95 @@ RSpec.describe 'Project > Members > Invite group', :js do
describe 'the groups dropdown' do
let_it_be(:parent_group) { create(:group, :public) }
let_it_be(:project_group) { create(:group, :public, parent: parent_group) }
- let_it_be(:public_sub_subgroup) { create(:group, :public, parent: project_group) }
- let_it_be(:public_sibbling_group) { create(:group, :public, parent: parent_group) }
- let_it_be(:private_sibbling_group) { create(:group, :private, parent: parent_group) }
- let_it_be(:private_membership_group) { create(:group, :private) }
- let_it_be(:public_membership_group) { create(:group, :public) }
let_it_be(:project) { create(:project, group: project_group) }
- before do
- private_membership_group.add_guest(maintainer)
- public_membership_group.add_maintainer(maintainer)
+ context 'with instance admin considerations' do
+ let_it_be(:group_to_share) { create(:group) }
- sign_in(maintainer)
+ context 'when user is an admin' do
+ let_it_be(:admin) { create(:admin) }
+
+ before do
+ sign_in(admin)
+ gitlab_enable_admin_mode_sign_in(admin)
+ end
+
+ it 'shows groups where the admin has no direct membership' do
+ visit project_project_members_path(project)
+
+ click_on 'Invite a group'
+ click_on 'Select a group'
+ wait_for_requests
+
+ page.within(group_dropdown_selector) do
+ expect_to_have_group(group_to_share)
+ end
+ end
+
+ it 'shows groups where the admin has at least guest level membership' do
+ group_to_share.add_guest(admin)
+
+ visit project_project_members_path(project)
+
+ click_on 'Invite a group'
+ click_on 'Select a group'
+ wait_for_requests
+
+ page.within(group_dropdown_selector) do
+ expect_to_have_group(group_to_share)
+ end
+ end
+ end
+
+ context 'when user is not an admin' do
+ before do
+ project.add_maintainer(maintainer)
+ sign_in(maintainer)
+ end
+
+ it 'does not show groups where the user has no direct membership' do
+ visit project_project_members_path(project)
+
+ click_on 'Invite a group'
+ click_on 'Select a group'
+ wait_for_requests
+
+ page.within(group_dropdown_selector) do
+ expect_not_to_have_group(group_to_share)
+ end
+ end
+
+ it 'shows groups where the user has at least guest level membership' do
+ group_to_share.add_guest(maintainer)
+
+ visit project_project_members_path(project)
+
+ click_on 'Invite a group'
+ click_on 'Select a group'
+ wait_for_requests
+
+ page.within(group_dropdown_selector) do
+ expect_to_have_group(group_to_share)
+ end
+ end
+ end
end
context 'for a project in a nested group' do
+ let_it_be(:public_sub_subgroup) { create(:group, :public, parent: project_group) }
+ let_it_be(:public_sibbling_group) { create(:group, :public, parent: parent_group) }
+ let_it_be(:private_sibbling_group) { create(:group, :private, parent: parent_group) }
+ let_it_be(:private_membership_group) { create(:group, :private) }
+ let_it_be(:public_membership_group) { create(:group, :public) }
+ let_it_be(:project) { create(:project, group: project_group) }
+
+ before do
+ private_membership_group.add_guest(maintainer)
+ public_membership_group.add_maintainer(maintainer)
+
+ sign_in(maintainer)
+ end
+
it 'does not show the groups inherited from projects' do
project.add_maintainer(maintainer)
public_sibbling_group.add_maintainer(maintainer)
@@ -183,7 +255,7 @@ RSpec.describe 'Project > Members > Invite group', :js do
click_on 'Select a group'
wait_for_requests
- page.within('[data-testid="group-select-dropdown"]') do
+ page.within(group_dropdown_selector) do
expect_to_have_group(public_membership_group)
expect_to_have_group(public_sibbling_group)
expect_to_have_group(private_membership_group)
@@ -204,7 +276,7 @@ RSpec.describe 'Project > Members > Invite group', :js do
click_on 'Select a group'
wait_for_requests
- page.within('[data-testid="group-select-dropdown"]') do
+ page.within(group_dropdown_selector) do
expect_to_have_group(public_membership_group)
expect_to_have_group(public_sibbling_group)
expect_to_have_group(private_membership_group)
@@ -215,14 +287,10 @@ RSpec.describe 'Project > Members > Invite group', :js do
expect_not_to_have_group(project_group)
end
end
-
- def expect_to_have_group(group)
- expect(page).to have_selector("[entity-id='#{group.id}']")
- end
-
- def expect_not_to_have_group(group)
- expect(page).not_to have_selector("[entity-id='#{group.id}']")
- end
end
end
+
+ def invite_group_selector
+ 'button[data-test-id="invite-group-button"]'
+ end
end
diff --git a/spec/features/projects/members/list_spec.rb b/spec/features/projects/members/manage_members_spec.rb
index f2424a4acc3..0f4120e88e0 100644
--- a/spec/features/projects/members/list_spec.rb
+++ b/spec/features/projects/members/manage_members_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'Project members list', :js do
+RSpec.describe 'Projects > Members > Manage members', :js do
include Spec::Support::Helpers::Features::MembersHelpers
include Spec::Support::Helpers::Features::InviteMembersModalHelper
include Spec::Support::Helpers::ModalHelpers
@@ -48,24 +48,6 @@ RSpec.describe 'Project members list', :js do
end
end
- it 'add user to project', :snowplow, :aggregate_failures do
- visit_members_page
-
- invite_member(user2.name, role: 'Reporter')
-
- page.within find_member_row(user2) do
- expect(page).to have_button('Reporter')
- end
-
- expect_snowplow_event(
- category: 'Members::CreateService',
- action: 'create_member',
- label: 'project-members-page',
- property: 'existing_user',
- user: user1
- )
- end
-
it 'uses ProjectMember access_level_roles for the invite members modal access option', :aggregate_failures do
visit_members_page
@@ -104,24 +86,41 @@ RSpec.describe 'Project members list', :js do
expect(members_table).not_to have_content(other_user.name)
end
- it 'invite user to project', :snowplow, :aggregate_failures do
- visit_members_page
+ it_behaves_like 'inviting members', 'project-members-page' do
+ let_it_be(:entity) { project }
+ let_it_be(:members_page_path) { project_project_members_path(entity) }
+ let_it_be(:subentity) { project }
+ let_it_be(:subentity_members_page_path) { project_project_members_path(entity) }
+ end
- invite_member('test@example.com', role: 'Reporter')
+ describe 'member search results' do
+ it 'does not show project_bots', :aggregate_failures do
+ internal_project_bot = create(:user, :project_bot, name: '_internal_project_bot_')
+ project.add_maintainer(internal_project_bot)
- click_link 'Invited'
+ external_group = create(:group)
+ external_project_bot = create(:user, :project_bot, name: '_external_project_bot_')
+ external_project = create(:project, group: external_group)
+ external_project.add_maintainer(external_project_bot)
+ external_project.add_maintainer(user1)
- page.within find_invited_member_row('test@example.com') do
- expect(page).to have_button('Reporter')
- end
+ visit_members_page
+
+ click_on 'Invite members'
- expect_snowplow_event(
- category: 'Members::InviteService',
- action: 'create_member',
- label: 'project-members-page',
- property: 'net_new_user',
- user: user1
- )
+ page.within invite_modal_selector do
+ field = find(member_dropdown_selector)
+ field.native.send_keys :tab
+ field.click
+
+ wait_for_requests
+
+ expect(page).to have_content(user1.name)
+ expect(page).to have_content(user2.name)
+ expect(page).not_to have_content(internal_project_bot.name)
+ expect(page).not_to have_content(external_project_bot.name)
+ end
+ end
end
context 'as a signed out visitor viewing a public project' do
diff --git a/spec/features/projects/members/sorting_spec.rb b/spec/features/projects/members/sorting_spec.rb
index 653564d1566..8aadd6302d0 100644
--- a/spec/features/projects/members/sorting_spec.rb
+++ b/spec/features/projects/members/sorting_spec.rb
@@ -5,8 +5,8 @@ require 'spec_helper'
RSpec.describe 'Projects > Members > Sorting', :js do
include Spec::Support::Helpers::Features::MembersHelpers
- let(:maintainer) { create(:user, name: 'John Doe') }
- let(:developer) { create(:user, name: 'Mary Jane', last_sign_in_at: 5.days.ago) }
+ let(:maintainer) { create(:user, name: 'John Doe', created_at: 5.days.ago, last_activity_on: Date.today) }
+ let(:developer) { create(:user, name: 'Mary Jane', created_at: 1.day.ago, last_sign_in_at: 5.days.ago, last_activity_on: Date.today - 5) }
let(:project) { create(:project, namespace: maintainer.namespace, creator: maintainer) }
before do
@@ -42,6 +42,42 @@ RSpec.describe 'Projects > Members > Sorting', :js do
expect_sort_by('Max role', :desc)
end
+ it 'sorts by user created on ascending' do
+ visit_members_list(sort: :oldest_created_user)
+
+ expect(first_row.text).to have_content(maintainer.name)
+ expect(second_row.text).to have_content(developer.name)
+
+ expect_sort_by('Created on', :asc)
+ end
+
+ it 'sorts by user created on descending' do
+ visit_members_list(sort: :recent_created_user)
+
+ expect(first_row.text).to have_content(developer.name)
+ expect(second_row.text).to have_content(maintainer.name)
+
+ expect_sort_by('Created on', :desc)
+ end
+
+ it 'sorts by last activity ascending' do
+ visit_members_list(sort: :oldest_last_activity)
+
+ expect(first_row.text).to have_content(developer.name)
+ expect(second_row.text).to have_content(maintainer.name)
+
+ expect_sort_by('Last activity', :asc)
+ end
+
+ it 'sorts by last activity descending' do
+ visit_members_list(sort: :recent_last_activity)
+
+ expect(first_row.text).to have_content(maintainer.name)
+ expect(second_row.text).to have_content(developer.name)
+
+ expect_sort_by('Last activity', :desc)
+ end
+
it 'sorts by access granted ascending' do
visit_members_list(sort: :last_joined)
diff --git a/spec/features/projects/milestones/milestones_sorting_spec.rb b/spec/features/projects/milestones/milestones_sorting_spec.rb
index 565c61cfaa0..2ad820e4a06 100644
--- a/spec/features/projects/milestones/milestones_sorting_spec.rb
+++ b/spec/features/projects/milestones/milestones_sorting_spec.rb
@@ -5,49 +5,55 @@ require 'spec_helper'
RSpec.describe 'Milestones sorting', :js do
let(:user) { create(:user) }
let(:project) { create(:project, name: 'test', namespace: user.namespace) }
+ let(:milestones_for_sort_by) do
+ {
+ 'Due later' => %w[b c a],
+ 'Name, ascending' => %w[a b c],
+ 'Name, descending' => %w[c b a],
+ 'Start later' => %w[a c b],
+ 'Start soon' => %w[b c a],
+ 'Due soon' => %w[a c b]
+ }
+ end
+
+ let(:ordered_milestones) do
+ ['Due soon', 'Due later', 'Start soon', 'Start later', 'Name, ascending', 'Name, descending']
+ end
before do
- # Milestones
- create(:milestone,
- due_date: 10.days.from_now,
- created_at: 2.hours.ago,
- title: "aaa", project: project)
- create(:milestone,
- due_date: 11.days.from_now,
- created_at: 1.hour.ago,
- title: "bbb", project: project)
+ create(:milestone, start_date: 7.days.from_now, due_date: 10.days.from_now, title: "a", project: project)
+ create(:milestone, start_date: 6.days.from_now, due_date: 11.days.from_now, title: "c", project: project)
+ create(:milestone, start_date: 5.days.from_now, due_date: 12.days.from_now, title: "b", project: project)
sign_in(user)
end
- it 'visit project milestones and sort by due_date_asc' do
+ it 'visit project milestones and sort by various orders' do
visit project_milestones_path(project)
expect(page).to have_button('Due soon')
- # assert default sorting
+ # assert default sorting order
within '.milestones' do
- expect(page.all('ul.content-list > li').first.text).to include('aaa')
- expect(page.all('ul.content-list > li').last.text).to include('bbb')
+ expect(page.all('ul.content-list > li strong > a').map(&:text)).to eq(%w[a c b])
end
- click_button 'Due soon'
+ # assert milestones listed for given sort order
+ selected_sort_order = 'Due soon'
+ milestones_for_sort_by.each do |sort_by, expected_milestones|
+ within '[data-testid=milestone_sort_by_dropdown]' do
+ click_button selected_sort_order
+ milestones = find('.gl-new-dropdown-contents').all('.gl-new-dropdown-item-text-wrapper p').map(&:text)
+ expect(milestones).to eq(ordered_milestones)
- sort_options = find('ul.dropdown-menu-sort li').all('a').collect(&:text)
+ click_button sort_by
+ expect(page).to have_button(sort_by)
+ end
- expect(sort_options[0]).to eq('Due soon')
- expect(sort_options[1]).to eq('Due later')
- expect(sort_options[2]).to eq('Start soon')
- expect(sort_options[3]).to eq('Start later')
- expect(sort_options[4]).to eq('Name, ascending')
- expect(sort_options[5]).to eq('Name, descending')
+ within '.milestones' do
+ expect(page.all('ul.content-list > li strong > a').map(&:text)).to eq(expected_milestones)
+ end
- click_link 'Due later'
-
- expect(page).to have_button('Due later')
-
- within '.milestones' do
- expect(page.all('ul.content-list > li').first.text).to include('bbb')
- expect(page.all('ul.content-list > li').last.text).to include('aaa')
+ selected_sort_order = sort_by
end
end
end
diff --git a/spec/features/projects/new_project_spec.rb b/spec/features/projects/new_project_spec.rb
index c57e39b6508..0046dfe436f 100644
--- a/spec/features/projects/new_project_spec.rb
+++ b/spec/features/projects/new_project_spec.rb
@@ -191,7 +191,8 @@ RSpec.describe 'New project', :js do
click_link 'Create blank project'
end
- it 'selects the user namespace' do
+ it 'does not select the user namespace' do
+ click_on 'Pick a group or namespace'
expect(page).to have_button user.username
end
end
@@ -328,6 +329,14 @@ RSpec.describe 'New project', :js do
click_on 'Create project'
+ expect(page).to have_content(
+ s_('ProjectsNew|Pick a group or namespace where you want to create this project.')
+ )
+
+ click_on 'Pick a group or namespace'
+ click_on user.username
+ click_on 'Create project'
+
expect(page).to have_css('#import-project-pane.active')
expect(page).not_to have_css('.toggle-import-form.hide')
end
diff --git a/spec/features/projects/pipelines/pipeline_spec.rb b/spec/features/projects/pipelines/pipeline_spec.rb
index 6b9dfdf3a7b..219c8ec0070 100644
--- a/spec/features/projects/pipelines/pipeline_spec.rb
+++ b/spec/features/projects/pipelines/pipeline_spec.rb
@@ -15,6 +15,7 @@ RSpec.describe 'Pipeline', :js do
before do
sign_in(user)
project.add_role(user, role)
+ stub_feature_flags(pipeline_tabs_vue: false)
end
shared_context 'pipeline builds' do
@@ -356,6 +357,7 @@ RSpec.describe 'Pipeline', :js do
context 'page tabs' do
before do
+ stub_feature_flags(pipeline_tabs_vue: false)
visit_pipeline
end
@@ -388,6 +390,7 @@ RSpec.describe 'Pipeline', :js do
let(:pipeline) { create(:ci_pipeline, :with_test_reports, :with_report_results, project: project) }
before do
+ stub_feature_flags(pipeline_tabs_vue: false)
visit_pipeline
wait_for_requests
end
@@ -924,6 +927,7 @@ RSpec.describe 'Pipeline', :js do
let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) }
before do
+ stub_feature_flags(pipeline_tabs_vue: false)
visit builds_project_pipeline_path(project, pipeline)
end
@@ -944,6 +948,10 @@ RSpec.describe 'Pipeline', :js do
end
context 'page tabs' do
+ before do
+ stub_feature_flags(pipeline_tabs_vue: false)
+ end
+
it 'shows Pipeline, Jobs and DAG tabs with link' do
expect(page).to have_link('Pipeline')
expect(page).to have_link('Jobs')
@@ -1014,6 +1022,10 @@ RSpec.describe 'Pipeline', :js do
end
describe 'GET /:project/-/pipelines/:id/failures' do
+ before do
+ stub_feature_flags(pipeline_tabs_vue: false)
+ end
+
let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: '1234') }
let(:pipeline_failures_page) { failures_project_pipeline_path(project, pipeline) }
let!(:failed_build) { create(:ci_build, :failed, pipeline: pipeline) }
@@ -1139,6 +1151,7 @@ RSpec.describe 'Pipeline', :js do
let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) }
before do
+ stub_feature_flags(pipeline_tabs_vue: false)
visit dag_project_pipeline_path(project, pipeline)
end
diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb
index 0e1728858ec..8b1a22ae05a 100644
--- a/spec/features/projects/pipelines/pipelines_spec.rb
+++ b/spec/features/projects/pipelines/pipelines_spec.rb
@@ -623,6 +623,7 @@ RSpec.describe 'Pipelines', :js do
create(:generic_commit_status, pipeline: pipeline, stage: 'external', name: 'jenkins', stage_idx: 3, ref: 'master')
+ stub_feature_flags(pipeline_tabs_vue: false)
visit project_pipeline_path(project, pipeline)
wait_for_requests
end
diff --git a/spec/features/projects/releases/user_views_releases_spec.rb b/spec/features/projects/releases/user_views_releases_spec.rb
index 98935fdf872..a7348b62fc0 100644
--- a/spec/features/projects/releases/user_views_releases_spec.rb
+++ b/spec/features/projects/releases/user_views_releases_spec.rb
@@ -24,129 +24,111 @@ RSpec.describe 'User views releases', :js do
stub_default_url_options(host: 'localhost')
end
- shared_examples 'releases index page' do
- context('when the user is a maintainer') do
- before do
- sign_in(maintainer)
+ context('when the user is a maintainer') do
+ before do
+ sign_in(maintainer)
- visit project_releases_path(project)
+ visit project_releases_path(project)
- wait_for_requests
- end
+ wait_for_requests
+ end
- it 'sees the release' do
- page.within("##{release_v1.tag}") do
- expect(page).to have_content(release_v1.name)
- expect(page).to have_content(release_v1.tag)
- expect(page).not_to have_content('Upcoming Release')
- end
+ it 'sees the release' do
+ page.within("##{release_v1.tag}") do
+ expect(page).to have_content(release_v1.name)
+ expect(page).to have_content(release_v1.tag)
+ expect(page).not_to have_content('Upcoming Release')
end
+ end
- it 'renders the correct links', :aggregate_failures do
- page.within("##{release_v1.tag} .js-assets-list") do
- external_link_indicator_selector = '[data-testid="external-link-indicator"]'
+ it 'renders the correct links', :aggregate_failures do
+ page.within("##{release_v1.tag} .js-assets-list") do
+ external_link_indicator_selector = '[data-testid="external-link-indicator"]'
- expect(page).to have_link internal_link.name, href: internal_link.url
- expect(find_link(internal_link.name)).not_to have_css(external_link_indicator_selector)
+ expect(page).to have_link internal_link.name, href: internal_link.url
+ expect(find_link(internal_link.name)).not_to have_css(external_link_indicator_selector)
- expect(page).to have_link internal_link_with_redirect.name, href: Gitlab::Routing.url_helpers.project_release_url(project, release_v1) << "/downloads#{internal_link_with_redirect.filepath}"
- expect(find_link(internal_link_with_redirect.name)).not_to have_css(external_link_indicator_selector)
+ expect(page).to have_link internal_link_with_redirect.name, href: Gitlab::Routing.url_helpers.project_release_url(project, release_v1) << "/downloads#{internal_link_with_redirect.filepath}"
+ expect(find_link(internal_link_with_redirect.name)).not_to have_css(external_link_indicator_selector)
- expect(page).to have_link external_link.name, href: external_link.url
- expect(find_link(external_link.name)).to have_css(external_link_indicator_selector)
- end
+ expect(page).to have_link external_link.name, href: external_link.url
+ expect(find_link(external_link.name)).to have_css(external_link_indicator_selector)
end
+ end
- context 'with an upcoming release' do
- it 'sees the upcoming tag' do
- page.within("##{release_v3.tag}") do
- expect(page).to have_content('Upcoming Release')
- end
+ context 'with an upcoming release' do
+ it 'sees the upcoming tag' do
+ page.within("##{release_v3.tag}") do
+ expect(page).to have_content('Upcoming Release')
end
end
+ end
- context 'with a tag containing a slash' do
- it 'sees the release' do
- page.within("##{release_v2.tag.parameterize}") do
- expect(page).to have_content(release_v2.name)
- expect(page).to have_content(release_v2.tag)
- end
+ context 'with a tag containing a slash' do
+ it 'sees the release' do
+ page.within("##{release_v2.tag.parameterize}") do
+ expect(page).to have_content(release_v2.name)
+ expect(page).to have_content(release_v2.tag)
end
end
+ end
- context 'sorting' do
- def sort_page(by:, direction:)
- within '[data-testid="releases-sort"]' do
- find('.dropdown-toggle').click
-
- click_button(by, class: 'dropdown-item')
-
- find('.sorting-direction-button').click if direction == :ascending
- end
- end
-
- shared_examples 'releases sort order' do
- it "sorts the releases #{description}" do
- card_titles = page.all('.release-block .card-title', minimum: expected_releases.count)
-
- card_titles.each_with_index do |title, index|
- expect(title).to have_content(expected_releases[index].name)
- end
- end
- end
+ context 'sorting' do
+ def sort_page(by:, direction:)
+ within '[data-testid="releases-sort"]' do
+ find('.dropdown-toggle').click
- context "when the page is sorted by the default sort order" do
- let(:expected_releases) { [release_v3, release_v2, release_v1] }
+ click_button(by, class: 'dropdown-item')
- it_behaves_like 'releases sort order'
+ find('.sorting-direction-button').click if direction == :ascending
end
+ end
- context "when the page is sorted by created_at ascending " do
- let(:expected_releases) { [release_v2, release_v1, release_v3] }
+ shared_examples 'releases sort order' do
+ it "sorts the releases #{description}" do
+ card_titles = page.all('.release-block .card-title', minimum: expected_releases.count)
- before do
- sort_page by: 'Created date', direction: :ascending
+ card_titles.each_with_index do |title, index|
+ expect(title).to have_content(expected_releases[index].name)
end
-
- it_behaves_like 'releases sort order'
end
end
- end
- context('when the user is a guest') do
- before do
- sign_in(guest)
- end
+ context "when the page is sorted by the default sort order" do
+ let(:expected_releases) { [release_v3, release_v2, release_v1] }
- it 'renders release info except for Git-related data' do
- visit project_releases_path(project)
+ it_behaves_like 'releases sort order'
+ end
- within('.release-block', match: :first) do
- expect(page).to have_content(release_v3.description)
- expect(page).to have_content(release_v3.tag)
- expect(page).to have_content(release_v3.name)
+ context "when the page is sorted by created_at ascending " do
+ let(:expected_releases) { [release_v2, release_v1, release_v3] }
- # The following properties (sometimes) include Git info,
- # so they are not rendered for Guest users
- expect(page).not_to have_content(release_v3.commit.short_id)
+ before do
+ sort_page by: 'Created date', direction: :ascending
end
+
+ it_behaves_like 'releases sort order'
end
end
end
- context 'when the releases_index_apollo_client feature flag is enabled' do
+ context('when the user is a guest') do
before do
- stub_feature_flags(releases_index_apollo_client: true)
+ sign_in(guest)
end
- it_behaves_like 'releases index page'
- end
+ it 'renders release info except for Git-related data' do
+ visit project_releases_path(project)
- context 'when the releases_index_apollo_client feature flag is disabled' do
- before do
- stub_feature_flags(releases_index_apollo_client: false)
- end
+ within('.release-block', match: :first) do
+ expect(page).to have_content(release_v3.description)
+ expect(page).to have_content(release_v3.tag)
+ expect(page).to have_content(release_v3.name)
- it_behaves_like 'releases index page'
+ # The following properties (sometimes) include Git info,
+ # so they are not rendered for Guest users
+ expect(page).not_to have_content(release_v3.commit.short_id)
+ end
+ end
end
end
diff --git a/spec/features/projects/terraform_spec.rb b/spec/features/projects/terraform_spec.rb
index 2c63f2bfc02..d9e45b5e78e 100644
--- a/spec/features/projects/terraform_spec.rb
+++ b/spec/features/projects/terraform_spec.rb
@@ -22,7 +22,7 @@ RSpec.describe 'Terraform', :js do
end
it 'sees an empty state' do
- expect(page).to have_content('Get started with Terraform')
+ expect(page).to have_content("Your project doesn't have any Terraform state files")
end
end
diff --git a/spec/features/projects/user_creates_project_spec.rb b/spec/features/projects/user_creates_project_spec.rb
index 6491a7425f7..b07f2d12660 100644
--- a/spec/features/projects/user_creates_project_spec.rb
+++ b/spec/features/projects/user_creates_project_spec.rb
@@ -33,29 +33,6 @@ RSpec.describe 'User creates a project', :js do
end
it 'creates a new project that is not blank' do
- stub_experiments(new_project_sast_enabled: 'candidate')
-
- visit(new_project_path)
-
- click_link 'Create blank project'
- fill_in(:project_name, with: 'With initial commits')
-
- expect(page).to have_checked_field 'Initialize repository with a README'
- expect(page).to have_checked_field 'Enable Static Application Security Testing (SAST)'
-
- click_button('Create project')
-
- project = Project.last
-
- expect(page).to have_current_path(project_path(project), ignore_query: true)
- expect(page).to have_content('With initial commits')
- expect(page).to have_content('Configure SAST in `.gitlab-ci.yml`, creating this file if it does not already exist')
- expect(page).to have_content('README.md Initial commit')
- end
-
- it 'allows creating a new project when the new_project_sast_enabled is assigned the unchecked candidate' do
- stub_experiments(new_project_sast_enabled: 'unchecked_candidate')
-
visit(new_project_path)
click_link 'Create blank project'
@@ -93,7 +70,7 @@ RSpec.describe 'User creates a project', :js do
fill_in :project_name, with: 'A Subgroup Project'
fill_in :project_path, with: 'a-subgroup-project'
- click_button user.username
+ click_on 'Pick a group or namespace'
click_button subgroup.full_path
click_button('Create project')
@@ -120,9 +97,6 @@ RSpec.describe 'User creates a project', :js do
fill_in :project_name, with: 'a-new-project'
fill_in :project_path, with: 'a-new-project'
- click_button user.username
- click_button group.full_path
-
page.within('#content-body') do
click_button('Create project')
end
diff --git a/spec/features/projects/user_sorts_projects_spec.rb b/spec/features/projects/user_sorts_projects_spec.rb
index 71e43467a39..7c970f7ee3d 100644
--- a/spec/features/projects/user_sorts_projects_spec.rb
+++ b/spec/features/projects/user_sorts_projects_spec.rb
@@ -14,25 +14,29 @@ RSpec.describe 'User sorts projects and order persists' do
it "is set on the dashboard_projects_path" do
visit(dashboard_projects_path)
- expect(find('.dropdown-menu a.is-active', text: project_paths_label)).to have_content(project_paths_label)
+ expect(find('#sort-projects-dropdown')).to have_content(project_paths_label)
end
it "is set on the explore_projects_path" do
visit(explore_projects_path)
- expect(find('.dropdown-menu a.is-active', text: project_paths_label)).to have_content(project_paths_label)
+ expect(find('#sort-projects-dropdown')).to have_content(project_paths_label)
end
it "is set on the group_canonical_path" do
visit(group_canonical_path(group))
- expect(find('.dropdown-menu a.is-active', text: group_paths_label)).to have_content(group_paths_label)
+ within '[data-testid=group_sort_by_dropdown]' do
+ expect(find('.gl-dropdown-toggle')).to have_content(group_paths_label)
+ end
end
it "is set on the details_group_path" do
visit(details_group_path(group))
- expect(find('.dropdown-menu a.is-active', text: group_paths_label)).to have_content(group_paths_label)
+ within '[data-testid=group_sort_by_dropdown]' do
+ expect(find('.gl-dropdown-toggle')).to have_content(group_paths_label)
+ end
end
end
@@ -58,23 +62,27 @@ RSpec.describe 'User sorts projects and order persists' do
it_behaves_like "sort order persists across all views", "Name", "Name"
end
- context 'from group homepage' do
+ context 'from group homepage', :js do
before do
sign_in(user)
visit(group_canonical_path(group))
- find('button.dropdown-menu-toggle').click
- first(:link, 'Last created').click
+ within '[data-testid=group_sort_by_dropdown]' do
+ find('button.gl-dropdown-toggle').click
+ first(:button, 'Last created').click
+ end
end
it_behaves_like "sort order persists across all views", "Created date", "Last created"
end
- context 'from group details' do
+ context 'from group details', :js do
before do
sign_in(user)
visit(details_group_path(group))
- find('button.dropdown-menu-toggle').click
- first(:link, 'Most stars').click
+ within '[data-testid=group_sort_by_dropdown]' do
+ find('button.gl-dropdown-toggle').click
+ first(:button, 'Most stars').click
+ end
end
it_behaves_like "sort order persists across all views", "Stars", "Most stars"
diff --git a/spec/features/projects_spec.rb b/spec/features/projects_spec.rb
index 1049f8bc18f..db64f84aa76 100644
--- a/spec/features/projects_spec.rb
+++ b/spec/features/projects_spec.rb
@@ -15,6 +15,12 @@ RSpec.describe 'Project' do
end
shared_examples 'creates from template' do |template, sub_template_tab = nil|
+ let(:selected_template) { page.find('.project-fields-form .selected-template') }
+
+ choose_template_selector = '.choose-template'
+ template_option_selector = '.template-option'
+ template_name_selector = '.description strong'
+
it "is created from template", :js do
click_link 'Create from template'
find(".project-template #{sub_template_tab}").click if sub_template_tab
@@ -27,6 +33,39 @@ RSpec.describe 'Project' do
expect(page).to have_content template.name
end
+
+ it 'is created using keyboard navigation', :js do
+ click_link 'Create from template'
+
+ first_template = first(template_option_selector)
+ first_template_name = first_template.find(template_name_selector).text
+ first_template.find(choose_template_selector).click
+
+ expect(selected_template).to have_text(first_template_name)
+
+ click_button "Change template"
+ find("#built-in").click
+
+ # Jumps down 1 template, skipping the `preview` buttons
+ 2.times do
+ page.send_keys :tab
+ end
+
+ # Ensure the template with focus is selected
+ project_name = "project from template"
+ focused_template = page.find(':focus').ancestor(template_option_selector)
+ focused_template_name = focused_template.find(template_name_selector).text
+ focused_template.find(choose_template_selector).send_keys :enter
+ fill_in "project_name", with: project_name
+
+ expect(selected_template).to have_text(focused_template_name)
+
+ page.within '#content-body' do
+ click_button "Create project"
+ end
+
+ expect(page).to have_content project_name
+ end
end
context 'create with project template' do
diff --git a/spec/features/refactor_blob_viewer_disabled/projects/blobs/balsamiq_spec.rb b/spec/features/refactor_blob_viewer_disabled/projects/blobs/balsamiq_spec.rb
deleted file mode 100644
index 3638e98a08a..00000000000
--- a/spec/features/refactor_blob_viewer_disabled/projects/blobs/balsamiq_spec.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'Balsamiq file blob', :js do
- let(:project) { create(:project, :public, :repository) }
-
- before do
- stub_feature_flags(refactor_blob_viewer: false)
- visit project_blob_path(project, 'add-balsamiq-file/files/images/balsamiq.bmpr')
-
- wait_for_requests
- end
-
- it 'displays Balsamiq file content' do
- expect(page).to have_content("Mobile examples")
- end
-end
diff --git a/spec/features/runners_spec.rb b/spec/features/runners_spec.rb
index 49c468976b9..2dddcd62a6c 100644
--- a/spec/features/runners_spec.rb
+++ b/spec/features/runners_spec.rb
@@ -352,6 +352,7 @@ RSpec.describe 'Runners' do
before do
group.add_owner(user)
+ stub_feature_flags(runner_list_group_view_vue_ui: false)
end
context 'group with no runners' do
diff --git a/spec/features/search/user_searches_for_projects_spec.rb b/spec/features/search/user_searches_for_projects_spec.rb
index c38ad077cd0..562da56275c 100644
--- a/spec/features/search/user_searches_for_projects_spec.rb
+++ b/spec/features/search/user_searches_for_projects_spec.rb
@@ -8,6 +8,8 @@ RSpec.describe 'User searches for projects', :js do
context 'when signed out' do
context 'when block_anonymous_global_searches is disabled' do
before do
+ allow(Gitlab::ApplicationRateLimiter).to receive(:threshold).with(:search_rate_limit).and_return(1000)
+ allow(Gitlab::ApplicationRateLimiter).to receive(:threshold).with(:search_rate_limit_unauthenticated).and_return(1000)
stub_feature_flags(block_anonymous_global_searches: false)
end
diff --git a/spec/features/search/user_uses_header_search_field_spec.rb b/spec/features/search/user_uses_header_search_field_spec.rb
index 8736f16b991..7350a54e8df 100644
--- a/spec/features/search/user_uses_header_search_field_spec.rb
+++ b/spec/features/search/user_uses_header_search_field_spec.rb
@@ -17,12 +17,15 @@ RSpec.describe 'User uses header search field', :js do
end
before do
+ allow(Gitlab::ApplicationRateLimiter).to receive(:threshold).with(:search_rate_limit).and_return(1000)
+ allow(Gitlab::ApplicationRateLimiter).to receive(:threshold).with(:search_rate_limit_unauthenticated).and_return(1000)
sign_in(user)
end
shared_examples 'search field examples' do
before do
visit(url)
+ wait_for_all_requests
end
it 'starts searching by pressing the enter key' do
@@ -37,7 +40,6 @@ RSpec.describe 'User uses header search field', :js do
before do
find('#search')
find('body').native.send_keys('s')
-
wait_for_all_requests
end
@@ -49,6 +51,7 @@ RSpec.describe 'User uses header search field', :js do
context 'when clicking the search field' do
before do
page.find('#search').click
+ wait_for_all_requests
end
it 'shows category search dropdown', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/250285' do
@@ -59,7 +62,7 @@ RSpec.describe 'User uses header search field', :js do
let!(:issue) { create(:issue, project: project, author: user, assignees: [user]) }
it 'shows assigned issues' do
- find('.search-input-container .dropdown-menu').click_link('Issues assigned to me')
+ find('[data-testid="header-search-dropdown-menu"]').click_link('Issues assigned to me')
expect(page).to have_selector('.issues-list .issue')
expect_tokens([assignee_token(user.name)])
@@ -67,7 +70,7 @@ RSpec.describe 'User uses header search field', :js do
end
it 'shows created issues' do
- find('.search-input-container .dropdown-menu').click_link("Issues I've created")
+ find('[data-testid="header-search-dropdown-menu"]').click_link("Issues I've created")
expect(page).to have_selector('.issues-list .issue')
expect_tokens([author_token(user.name)])
@@ -79,7 +82,7 @@ RSpec.describe 'User uses header search field', :js do
let!(:merge_request) { create(:merge_request, source_project: project, author: user, assignees: [user]) }
it 'shows assigned merge requests' do
- find('.search-input-container .dropdown-menu').click_link('Merge requests assigned to me')
+ find('[data-testid="header-search-dropdown-menu"]').click_link('Merge requests assigned to me')
expect(page).to have_selector('.mr-list .merge-request')
expect_tokens([assignee_token(user.name)])
@@ -87,7 +90,7 @@ RSpec.describe 'User uses header search field', :js do
end
it 'shows created merge requests' do
- find('.search-input-container .dropdown-menu').click_link("Merge requests I've created")
+ find('[data-testid="header-search-dropdown-menu"]').click_link("Merge requests I've created")
expect(page).to have_selector('.mr-list .merge-request')
expect_tokens([author_token(user.name)])
@@ -150,10 +153,9 @@ RSpec.describe 'User uses header search field', :js do
it 'displays search options' do
fill_in_search('test')
-
- expect(page).to have_selector(scoped_search_link('test'))
- expect(page).to have_selector(scoped_search_link('test', group_id: group.id))
- expect(page).to have_selector(scoped_search_link('test', project_id: project.id, group_id: group.id))
+ expect(page).to have_selector(scoped_search_link('test', search_code: true))
+ expect(page).to have_selector(scoped_search_link('test', group_id: group.id, search_code: true))
+ expect(page).to have_selector(scoped_search_link('test', project_id: project.id, group_id: group.id, search_code: true))
end
end
@@ -165,10 +167,9 @@ RSpec.describe 'User uses header search field', :js do
it 'displays search options' do
fill_in_search('test')
-
- expect(page).to have_selector(scoped_search_link('test'))
- expect(page).not_to have_selector(scoped_search_link('test', group_id: project.namespace_id))
- expect(page).to have_selector(scoped_search_link('test', project_id: project.id))
+ expect(page).to have_selector(scoped_search_link('test', search_code: true, repository_ref: 'master'))
+ expect(page).not_to have_selector(scoped_search_link('test', search_code: true, group_id: project.namespace_id, repository_ref: 'master'))
+ expect(page).to have_selector(scoped_search_link('test', search_code: true, project_id: project.id, repository_ref: 'master'))
end
it 'displays a link to project merge requests' do
@@ -217,7 +218,6 @@ RSpec.describe 'User uses header search field', :js do
it 'displays search options' do
fill_in_search('test')
-
expect(page).to have_selector(scoped_search_link('test'))
expect(page).to have_selector(scoped_search_link('test', group_id: group.id))
expect(page).not_to have_selector(scoped_search_link('test', project_id: project.id))
@@ -248,18 +248,20 @@ RSpec.describe 'User uses header search field', :js do
end
end
- def scoped_search_link(term, project_id: nil, group_id: nil)
+ def scoped_search_link(term, project_id: nil, group_id: nil, search_code: nil, repository_ref: nil)
# search_path will accept group_id and project_id but the order does not match
# what is expected in the href, so the variable must be built manually
href = search_path(search: term)
+ href.concat("&nav_source=navbar")
href.concat("&project_id=#{project_id}") if project_id
href.concat("&group_id=#{group_id}") if group_id
- href.concat("&nav_source=navbar")
+ href.concat("&search_code=true") if search_code
+ href.concat("&repository_ref=#{repository_ref}") if repository_ref
- ".dropdown a[href='#{href}']"
+ "[data-testid='header-search-dropdown-menu'] a[href='#{href}']"
end
def dashboard_search_options_popup_menu
- "div[data-testid='dashboard-search-options']"
+ "[data-testid='header-search-dropdown-menu'] .header-search-dropdown-content"
end
end
diff --git a/spec/features/static_site_editor_spec.rb b/spec/features/static_site_editor_spec.rb
deleted file mode 100644
index 98313905a33..00000000000
--- a/spec/features/static_site_editor_spec.rb
+++ /dev/null
@@ -1,113 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'Static Site Editor' do
- include ContentSecurityPolicyHelpers
-
- let_it_be(:user) { create(:user) }
- let_it_be(:project) { create(:project, :public, :repository) }
-
- let(:sse_path) { project_show_sse_path(project, 'master/README.md') }
-
- before_all do
- project.add_developer(user)
- end
-
- before do
- sign_in(user)
- end
-
- context "when no config file is present" do
- before do
- visit sse_path
- end
-
- it 'renders SSE page with all generated config values and default config file values' do
- node = page.find('#static-site-editor')
-
- # assert generated config values are present
- expect(node['data-base-url']).to eq("/#{project.full_path}/-/sse/master%2FREADME.md")
- expect(node['data-branch']).to eq('master')
- expect(node['data-commit-id']).to match(/\A[0-9a-f]{40}\z/)
- expect(node['data-is-supported-content']).to eq('true')
- expect(node['data-merge-requests-illustration-path'])
- .to match(%r{/assets/illustrations/merge_requests-.*\.svg})
- expect(node['data-namespace']).to eq(project.namespace.full_path)
- expect(node['data-project']).to eq(project.path)
- expect(node['data-project-id']).to eq(project.id.to_s)
-
- # assert default config file values are present
- expect(node['data-image-upload-path']).to eq('source/images')
- expect(node['data-mounts']).to eq('[{"source":"source","target":""}]')
- expect(node['data-static-site-generator']).to eq('middleman')
- end
- end
-
- context "when a config file is present" do
- let(:config_file_yml) do
- <<~YAML
- image_upload_path: custom-image-upload-path
- mounts:
- - source: source1
- target: ""
- - source: source2
- target: target2
- static_site_generator: middleman
- YAML
- end
-
- before do
- allow_next_instance_of(Repository) do |repository|
- allow(repository).to receive(:blob_data_at).and_return(config_file_yml)
- end
-
- visit sse_path
- end
-
- it 'renders Static Site Editor page values read from config file' do
- node = page.find('#static-site-editor')
-
- # assert user-specified config file values are present
- expected_mounts = '[{"source":"source1","target":""},{"source":"source2","target":"target2"}]'
- expect(node['data-image-upload-path']).to eq('custom-image-upload-path')
- expect(node['data-mounts']).to eq(expected_mounts)
- expect(node['data-static-site-generator']).to eq('middleman')
- end
- end
-
- describe 'Static Site Editor Content Security Policy' do
- subject { response_headers['Content-Security-Policy'] }
-
- context 'when no global CSP config exists' do
- before do
- setup_csp_for_controller(Projects::StaticSiteEditorController)
- end
-
- it 'does not add CSP directives' do
- visit sse_path
-
- is_expected.to be_blank
- end
- end
-
- context 'when a global CSP config exists' do
- let_it_be(:cdn_url) { 'https://some-cdn.test' }
- let_it_be(:youtube_url) { 'https://www.youtube.com' }
-
- before do
- csp = ActionDispatch::ContentSecurityPolicy.new do |p|
- p.frame_src :self, cdn_url
- end
-
- setup_existing_csp_for_controller(Projects::StaticSiteEditorController, csp)
- end
-
- it 'appends youtube to the CSP frame-src policy' do
- visit sse_path
-
- is_expected.to eql("frame-src 'self' #{cdn_url} #{youtube_url}")
- end
- end
- end
-end
diff --git a/spec/features/task_lists_spec.rb b/spec/features/task_lists_spec.rb
index 0f8daaf8e15..6907701de9c 100644
--- a/spec/features/task_lists_spec.rb
+++ b/spec/features/task_lists_spec.rb
@@ -22,7 +22,7 @@ RSpec.describe 'Task Lists', :js do
MARKDOWN
end
- let(:singleIncompleteMarkdown) do
+ let(:single_incomplete_markdown) do
<<-MARKDOWN.strip_heredoc
This is a task list:
@@ -30,7 +30,7 @@ RSpec.describe 'Task Lists', :js do
MARKDOWN
end
- let(:singleCompleteMarkdown) do
+ let(:single_complete_markdown) do
<<-MARKDOWN.strip_heredoc
This is a task list:
@@ -94,7 +94,7 @@ RSpec.describe 'Task Lists', :js do
end
describe 'single incomplete task' do
- let!(:issue) { create(:issue, description: singleIncompleteMarkdown, author: user, project: project) }
+ let!(:issue) { create(:issue, description: single_incomplete_markdown, author: user, project: project) }
it 'renders' do
visit_issue(project, issue)
@@ -113,7 +113,7 @@ RSpec.describe 'Task Lists', :js do
end
describe 'single complete task' do
- let!(:issue) { create(:issue, description: singleCompleteMarkdown, author: user, project: project) }
+ let!(:issue) { create(:issue, description: single_complete_markdown, author: user, project: project) }
it 'renders' do
visit_issue(project, issue)
@@ -171,7 +171,7 @@ RSpec.describe 'Task Lists', :js do
describe 'single incomplete task' do
let!(:note) do
- create(:note, note: singleIncompleteMarkdown, noteable: issue,
+ create(:note, note: single_incomplete_markdown, noteable: issue,
project: project, author: user)
end
@@ -186,7 +186,7 @@ RSpec.describe 'Task Lists', :js do
describe 'single complete task' do
let!(:note) do
- create(:note, note: singleCompleteMarkdown, noteable: issue,
+ create(:note, note: single_complete_markdown, noteable: issue,
project: project, author: user)
end
@@ -264,7 +264,7 @@ RSpec.describe 'Task Lists', :js do
end
describe 'single incomplete task' do
- let!(:merge) { create(:merge_request, :simple, description: singleIncompleteMarkdown, author: user, source_project: project) }
+ let!(:merge) { create(:merge_request, :simple, description: single_incomplete_markdown, author: user, source_project: project) }
it 'renders for description' do
visit_merge_request(project, merge)
@@ -283,7 +283,7 @@ RSpec.describe 'Task Lists', :js do
end
describe 'single complete task' do
- let!(:merge) { create(:merge_request, :simple, description: singleCompleteMarkdown, author: user, source_project: project) }
+ let!(:merge) { create(:merge_request, :simple, description: single_complete_markdown, author: user, source_project: project) }
it 'renders for description' do
visit_merge_request(project, merge)
diff --git a/spec/features/users/login_spec.rb b/spec/features/users/login_spec.rb
index 8610cae58a4..822bf898034 100644
--- a/spec/features/users/login_spec.rb
+++ b/spec/features/users/login_spec.rb
@@ -818,7 +818,6 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions do
context 'when 2FA is required for the user' do
before do
- stub_feature_flags(mr_attention_requests: false)
group = create(:group, require_two_factor_authentication: true)
group.add_developer(user)
end
@@ -840,7 +839,15 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions do
expect(page).to have_current_path(profile_two_factor_auth_path, ignore_query: true)
- fill_in 'pin_code', with: user.reload.current_otp
+ # Use the secret shown on the page to generate the OTP that will be entered.
+ # This detects issues wherein a new secret gets generated after the
+ # page is shown.
+ wait_for_requests
+
+ otp_secret = page.find('.two-factor-secret').text.gsub('Key:', '').delete(' ')
+ current_otp = ROTP::TOTP.new(otp_secret).now
+
+ fill_in 'pin_code', with: current_otp
fill_in 'current_password', with: user.password
click_button 'Register with two-factor app'
diff --git a/spec/finders/bulk_imports/entities_finder_spec.rb b/spec/finders/bulk_imports/entities_finder_spec.rb
index e053011b60d..54c792cb4d8 100644
--- a/spec/finders/bulk_imports/entities_finder_spec.rb
+++ b/spec/finders/bulk_imports/entities_finder_spec.rb
@@ -51,7 +51,7 @@ RSpec.describe BulkImports::EntitiesFinder do
end
context 'when status is specified' do
- subject { described_class.new(user: user, status: 'failed') }
+ subject { described_class.new(user: user, params: { status: 'failed' }) }
it 'returns a list of import entities filtered by status' do
expect(subject.execute)
@@ -61,7 +61,7 @@ RSpec.describe BulkImports::EntitiesFinder do
end
context 'when invalid status is specified' do
- subject { described_class.new(user: user, status: 'invalid') }
+ subject { described_class.new(user: user, params: { status: 'invalid' }) }
it 'does not filter entities by status' do
expect(subject.execute)
@@ -74,11 +74,37 @@ RSpec.describe BulkImports::EntitiesFinder do
end
context 'when bulk import and status are specified' do
- subject { described_class.new(user: user, bulk_import: user_import_2, status: 'finished') }
+ subject { described_class.new(user: user, bulk_import: user_import_2, params: { status: 'finished' }) }
it 'returns matched import entities' do
expect(subject.execute).to contain_exactly(finished_entity_2)
end
end
+
+ context 'when order is specifed' do
+ subject { described_class.new(user: user, params: { sort: order }) }
+
+ context 'when order is specified as asc' do
+ let(:order) { :asc }
+
+ it 'returns entities sorted ascending' do
+ expect(subject.execute).to eq([
+ started_entity_1, finished_entity_1, failed_entity_1,
+ started_entity_2, finished_entity_2, failed_entity_2
+ ])
+ end
+ end
+
+ context 'when order is specified as desc' do
+ let(:order) { :desc }
+
+ it 'returns entities sorted descending' do
+ expect(subject.execute).to eq([
+ failed_entity_2, finished_entity_2, started_entity_2,
+ failed_entity_1, finished_entity_1, started_entity_1
+ ])
+ end
+ end
+ end
end
end
diff --git a/spec/finders/bulk_imports/imports_finder_spec.rb b/spec/finders/bulk_imports/imports_finder_spec.rb
index aac83c86c84..2f550514a33 100644
--- a/spec/finders/bulk_imports/imports_finder_spec.rb
+++ b/spec/finders/bulk_imports/imports_finder_spec.rb
@@ -16,19 +16,39 @@ RSpec.describe BulkImports::ImportsFinder do
end
context 'when status is specified' do
- subject { described_class.new(user: user, status: 'started') }
+ subject { described_class.new(user: user, params: { status: 'started' }) }
it 'returns a list of import entities filtered by status' do
expect(subject.execute).to contain_exactly(started_import)
end
context 'when invalid status is specified' do
- subject { described_class.new(user: user, status: 'invalid') }
+ subject { described_class.new(user: user, params: { status: 'invalid' }) }
it 'does not filter entities by status' do
expect(subject.execute).to contain_exactly(started_import, finished_import)
end
end
end
+
+ context 'when order is specifed' do
+ subject { described_class.new(user: user, params: { sort: order }) }
+
+ context 'when order is specified as asc' do
+ let(:order) { :asc }
+
+ it 'returns entities sorted ascending' do
+ expect(subject.execute).to eq([started_import, finished_import])
+ end
+ end
+
+ context 'when order is specified as desc' do
+ let(:order) { :desc }
+
+ it 'returns entities sorted descending' do
+ expect(subject.execute).to eq([finished_import, started_import])
+ end
+ end
+ end
end
end
diff --git a/spec/finders/ci/jobs_finder_spec.rb b/spec/finders/ci/jobs_finder_spec.rb
index 959716b1fd3..45e8cf5a582 100644
--- a/spec/finders/ci/jobs_finder_spec.rb
+++ b/spec/finders/ci/jobs_finder_spec.rb
@@ -7,9 +7,9 @@ RSpec.describe Ci::JobsFinder, '#execute' do
let_it_be(:admin) { create(:user, :admin) }
let_it_be(:project) { create(:project, :private, public_builds: false) }
let_it_be(:pipeline) { create(:ci_pipeline, project: project) }
- let_it_be(:job_1) { create(:ci_build) }
- let_it_be(:job_2) { create(:ci_build, :running) }
- let_it_be(:job_3) { create(:ci_build, :success, pipeline: pipeline, name: 'build') }
+ let_it_be(:pending_job) { create(:ci_build, :pending) }
+ let_it_be(:running_job) { create(:ci_build, :running) }
+ let_it_be(:successful_job) { create(:ci_build, :success, pipeline: pipeline, name: 'build') }
let(:params) { {} }
@@ -17,7 +17,7 @@ RSpec.describe Ci::JobsFinder, '#execute' do
subject { described_class.new(current_user: admin, params: params).execute }
it 'returns all jobs' do
- expect(subject).to match_array([job_1, job_2, job_3])
+ expect(subject).to match_array([pending_job, running_job, successful_job])
end
context 'non admin user' do
@@ -37,7 +37,7 @@ RSpec.describe Ci::JobsFinder, '#execute' do
end
context 'scope is present' do
- let(:jobs) { [job_1, job_2, job_3] }
+ let(:jobs) { [pending_job, running_job, successful_job] }
where(:scope, :index) do
[
@@ -55,11 +55,11 @@ RSpec.describe Ci::JobsFinder, '#execute' do
end
context 'scope is an array' do
- let(:jobs) { [job_1, job_2, job_3] }
- let(:params) {{ scope: ['running'] }}
+ let(:jobs) { [pending_job, running_job, successful_job, canceled_job] }
+ let(:params) {{ scope: %w'running success' }}
it 'filters by the job statuses in the scope' do
- expect(subject).to match_array([job_2])
+ expect(subject).to contain_exactly(running_job, successful_job)
end
end
end
@@ -73,7 +73,7 @@ RSpec.describe Ci::JobsFinder, '#execute' do
end
it 'returns jobs for the specified project' do
- expect(subject).to match_array([job_3])
+ expect(subject).to match_array([successful_job])
end
end
@@ -99,7 +99,7 @@ RSpec.describe Ci::JobsFinder, '#execute' do
context 'when pipeline is present' do
before_all do
project.add_maintainer(user)
- job_3.update!(retried: true)
+ successful_job.update!(retried: true)
end
let_it_be(:job_4) { create(:ci_build, :success, pipeline: pipeline, name: 'build') }
@@ -122,7 +122,7 @@ RSpec.describe Ci::JobsFinder, '#execute' do
let(:params) { { include_retried: true } }
it 'returns retried jobs' do
- expect(subject).to match_array([job_3, job_4])
+ expect(subject).to match_array([successful_job, job_4])
end
end
end
diff --git a/spec/finders/concerns/finder_methods_spec.rb b/spec/finders/concerns/finder_methods_spec.rb
index 195449d70c3..09ec8110129 100644
--- a/spec/finders/concerns/finder_methods_spec.rb
+++ b/spec/finders/concerns/finder_methods_spec.rb
@@ -12,7 +12,7 @@ RSpec.describe FinderMethods do
end
def execute
- Project.all.order(id: :desc)
+ Project.where.not(name: 'foo').order(id: :desc)
end
private
@@ -21,22 +21,30 @@ RSpec.describe FinderMethods do
end
end
- let(:user) { create(:user) }
- let(:finder) { finder_class.new(user) }
- let(:authorized_project) { create(:project) }
- let(:unauthorized_project) { create(:project) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:authorized_project) { create(:project) }
+ let_it_be(:unmatched_project) { create(:project, name: 'foo') }
+ let_it_be(:unauthorized_project) { create(:project) }
- before do
+ subject(:finder) { finder_class.new(user) }
+
+ before_all do
authorized_project.add_developer(user)
+ unmatched_project.add_developer(user)
end
+ # rubocop:disable Rails/FindById
describe '#find_by!' do
it 'returns the project if the user has access' do
expect(finder.find_by!(id: authorized_project.id)).to eq(authorized_project)
end
- it 'raises not found when the project is not found' do
- expect { finder.find_by!(id: 0) }.to raise_error(ActiveRecord::RecordNotFound)
+ it 'raises not found when the project is not found by id' do
+ expect { finder.find_by!(id: non_existing_record_id) }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+
+ it 'raises not found when the project is not found by filter' do
+ expect { finder.find_by!(id: unmatched_project.id) }.to raise_error(ActiveRecord::RecordNotFound)
end
it 'raises not found the user does not have access' do
@@ -53,19 +61,34 @@ RSpec.describe FinderMethods do
finder.find_by!(id: authorized_project.id)
end
end
+ # rubocop:enable Rails/FindById
describe '#find' do
it 'returns the project if the user has access' do
expect(finder.find(authorized_project.id)).to eq(authorized_project)
end
- it 'raises not found when the project is not found' do
- expect { finder.find(0) }.to raise_error(ActiveRecord::RecordNotFound)
+ it 'raises not found when the project is not found by id' do
+ expect { finder.find(non_existing_record_id) }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+
+ it 'raises not found when the project is not found by filter' do
+ expect { finder.find(unmatched_project.id) }.to raise_error(ActiveRecord::RecordNotFound)
end
it 'raises not found the user does not have access' do
expect { finder.find(unauthorized_project.id) }.to raise_error(ActiveRecord::RecordNotFound)
end
+
+ it 'ignores ordering' do
+ # Memoise the finder result so we can add message expectations to it
+ relation = finder.execute
+ allow(finder).to receive(:execute).and_return(relation)
+
+ expect(relation).to receive(:reorder).with(nil).and_call_original
+
+ finder.find(authorized_project.id)
+ end
end
describe '#find_by' do
@@ -73,8 +96,12 @@ RSpec.describe FinderMethods do
expect(finder.find_by(id: authorized_project.id)).to eq(authorized_project)
end
- it 'returns nil when the project is not found' do
- expect(finder.find_by(id: 0)).to be_nil
+ it 'returns nil when the project is not found by id' do
+ expect(finder.find_by(id: non_existing_record_id)).to be_nil
+ end
+
+ it 'returns nil when the project is not found by filter' do
+ expect(finder.find_by(id: unmatched_project.id)).to be_nil
end
it 'returns nil when the user does not have access' do
diff --git a/spec/finders/concerns/finder_with_cross_project_access_spec.rb b/spec/finders/concerns/finder_with_cross_project_access_spec.rb
index 116b523bd99..0798528c200 100644
--- a/spec/finders/concerns/finder_with_cross_project_access_spec.rb
+++ b/spec/finders/concerns/finder_with_cross_project_access_spec.rb
@@ -93,11 +93,11 @@ RSpec.describe FinderWithCrossProjectAccess do
it 'checks the accessibility of the subject directly' do
expect_access_check_on_result
- finder.find_by!(id: result.id)
+ finder.find(result.id)
end
it 're-enables the check after the find failed' do
- finder.find_by!(id: non_existing_record_id) rescue ActiveRecord::RecordNotFound
+ finder.find(non_existing_record_id) rescue ActiveRecord::RecordNotFound
expect(finder.instance_variable_get(:@should_skip_cross_project_check))
.to eq(false)
diff --git a/spec/finders/keys_finder_spec.rb b/spec/finders/keys_finder_spec.rb
index 277c852c953..332aa7afde1 100644
--- a/spec/finders/keys_finder_spec.rb
+++ b/spec/finders/keys_finder_spec.rb
@@ -5,23 +5,22 @@ require 'spec_helper'
RSpec.describe KeysFinder do
subject { described_class.new(params).execute }
- let(:user) { create(:user) }
- let(:params) { {} }
-
- let!(:key_1) do
- create(:personal_key,
+ let_it_be(:user) { create(:user) }
+ let_it_be(:key_1) do
+ create(:rsa_key_4096,
last_used_at: 7.days.ago,
user: user,
- key: 'ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt1016k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0=',
- fingerprint: 'ba:81:59:68:d7:6c:cd:02:02:bf:6a:9b:55:4e:af:d1',
- fingerprint_sha256: 'nUhzNyftwADy8AH3wFY31tAKs7HufskYTte2aXo/lCg')
+ fingerprint: 'df:73:db:29:3c:a5:32:cf:09:17:7e:8e:9d:de:d7:f7',
+ fingerprint_sha256: 'ByDU7hQ1JB95l6p53rHrffc4eXvEtqGUtQhS+Dhyy7g')
end
- let!(:key_2) { create(:personal_key, last_used_at: nil, user: user) }
- let!(:key_3) { create(:personal_key, last_used_at: 2.days.ago) }
+ let_it_be(:key_2) { create(:personal_key_4096, last_used_at: nil, user: user) }
+ let_it_be(:key_3) { create(:personal_key_4096, last_used_at: 2.days.ago) }
+
+ let(:params) { {} }
context 'key_type' do
- let!(:deploy_key) { create(:deploy_key) }
+ let_it_be(:deploy_key) { create(:deploy_key) }
context 'when `key_type` is `ssh`' do
before do
@@ -64,35 +63,41 @@ RSpec.describe KeysFinder do
end
context 'with valid fingerprints' do
- let!(:deploy_key) do
- create(:deploy_key,
- user: user,
- key: 'ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt1017k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0=',
- fingerprint: '8a:4a:12:92:0b:50:47:02:d4:5a:8e:a9:44:4e:08:b4',
- fingerprint_sha256: '4DPHOVNh53i9dHb5PpY2vjfyf5qniTx1/pBFPoZLDdk')
- end
+ let_it_be(:deploy_key) { create(:rsa_deploy_key_5120, user: user) }
context 'personal key with valid MD5 params' do
context 'with an existent fingerprint' do
before do
- params[:fingerprint] = 'ba:81:59:68:d7:6c:cd:02:02:bf:6a:9b:55:4e:af:d1'
+ params[:fingerprint] = 'df:73:db:29:3c:a5:32:cf:09:17:7e:8e:9d:de:d7:f7'
end
it 'returns the key' do
expect(subject).to eq(key_1)
expect(subject.user).to eq(user)
end
+
+ context 'with FIPS mode', :fips_mode do
+ it 'raises InvalidFingerprint' do
+ expect { subject }.to raise_error(KeysFinder::InvalidFingerprint)
+ end
+ end
end
context 'deploy key with an existent fingerprint' do
before do
- params[:fingerprint] = '8a:4a:12:92:0b:50:47:02:d4:5a:8e:a9:44:4e:08:b4'
+ params[:fingerprint] = 'fe:fa:3a:4d:7d:51:ec:bf:c7:64:0c:96:d0:17:8a:d0'
end
it 'returns the key' do
expect(subject).to eq(deploy_key)
expect(subject.user).to eq(user)
end
+
+ context 'with FIPS mode', :fips_mode do
+ it 'raises InvalidFingerprint' do
+ expect { subject }.to raise_error(KeysFinder::InvalidFingerprint)
+ end
+ end
end
context 'with a non-existent fingerprint' do
@@ -103,13 +108,19 @@ RSpec.describe KeysFinder do
it 'returns nil' do
expect(subject).to be_nil
end
+
+ context 'with FIPS mode', :fips_mode do
+ it 'raises InvalidFingerprint' do
+ expect { subject }.to raise_error(KeysFinder::InvalidFingerprint)
+ end
+ end
end
end
context 'personal key with valid SHA256 params' do
context 'with an existent fingerprint' do
before do
- params[:fingerprint] = 'SHA256:nUhzNyftwADy8AH3wFY31tAKs7HufskYTte2aXo/lCg'
+ params[:fingerprint] = 'SHA256:ByDU7hQ1JB95l6p53rHrffc4eXvEtqGUtQhS+Dhyy7g'
end
it 'returns key' do
@@ -120,7 +131,7 @@ RSpec.describe KeysFinder do
context 'deploy key with an existent fingerprint' do
before do
- params[:fingerprint] = 'SHA256:4DPHOVNh53i9dHb5PpY2vjfyf5qniTx1/pBFPoZLDdk'
+ params[:fingerprint] = 'SHA256:PCCupLbFHScm4AbEufbGDvhBU27IM0MVAor715qKQK8'
end
it 'returns key' do
diff --git a/spec/finders/packages/build_infos_for_many_packages_finder_spec.rb b/spec/finders/packages/build_infos_for_many_packages_finder_spec.rb
new file mode 100644
index 00000000000..f3c79d0c825
--- /dev/null
+++ b/spec/finders/packages/build_infos_for_many_packages_finder_spec.rb
@@ -0,0 +1,136 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::Packages::BuildInfosForManyPackagesFinder do
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be(:package) { create(:package) }
+ let_it_be(:build_infos) { create_list(:package_build_info, 5, :with_pipeline, package: package) }
+ let_it_be(:build_info_with_empty_pipeline) { create(:package_build_info, package: package) }
+
+ let_it_be(:other_package) { create(:package) }
+ let_it_be(:other_build_infos) { create_list(:package_build_info, 5, :with_pipeline, package: other_package) }
+ let_it_be(:other_build_info_with_empty_pipeline) { create(:package_build_info, package: other_package) }
+
+ let_it_be(:all_build_infos) { build_infos + other_build_infos }
+
+ let(:finder) { described_class.new(packages, params) }
+ let(:packages) { nil }
+ let(:first) { nil }
+ let(:last) { nil }
+ let(:after) { nil }
+ let(:before) { nil }
+ let(:max_page_size) { nil }
+ let(:support_next_page) { false }
+ let(:params) do
+ {
+ first: first,
+ last: last,
+ after: after,
+ before: before,
+ max_page_size: max_page_size,
+ support_next_page: support_next_page
+ }
+ end
+
+ describe '#execute' do
+ subject { finder.execute }
+
+ shared_examples 'returning the expected build infos' do
+ let(:expected_build_infos) do
+ expected_build_infos_indexes.map do |idx|
+ all_build_infos[idx]
+ end
+ end
+
+ let(:after) do
+ all_build_infos[after_index].pipeline_id if after_index
+ end
+
+ let(:before) do
+ all_build_infos[before_index].pipeline_id if before_index
+ end
+
+ it { is_expected.to eq(expected_build_infos) }
+ end
+
+ context 'with nil packages' do
+ let(:packages) { nil }
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'with [] packages' do
+ let(:packages) { [] }
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'with empy scope packages' do
+ let(:packages) { Packages::Package.none }
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'with a single package' do
+ let(:packages) { package.id }
+
+ # rubocop: disable Layout/LineLength
+ where(:first, :last, :after_index, :before_index, :max_page_size, :support_next_page, :expected_build_infos_indexes) do
+ # F L AI BI MPS SNP
+ nil | nil | nil | nil | nil | false | [4, 3, 2, 1, 0]
+ nil | nil | nil | nil | 10 | false | [4, 3, 2, 1, 0]
+ nil | nil | nil | nil | 2 | false | [4, 3]
+ 2 | nil | nil | nil | nil | false | [4, 3]
+ 2 | nil | nil | nil | nil | true | [4, 3, 2]
+ 2 | nil | 3 | nil | nil | false | [2, 1]
+ 2 | nil | 3 | nil | nil | true | [2, 1, 0]
+ 3 | nil | 4 | nil | 2 | false | [3, 2]
+ 3 | nil | 4 | nil | 2 | true | [3, 2, 1]
+ nil | 2 | nil | nil | nil | false | [1, 0]
+ nil | 2 | nil | nil | nil | true | [2, 1, 0]
+ nil | 2 | nil | 1 | nil | false | [3, 2]
+ nil | 2 | nil | 1 | nil | true | [4, 3, 2]
+ nil | 3 | nil | 0 | 2 | false | [2, 1]
+ nil | 3 | nil | 0 | 2 | true | [3, 2, 1]
+ end
+ # rubocop: enable Layout/LineLength
+
+ with_them do
+ it_behaves_like 'returning the expected build infos'
+ end
+ end
+
+ context 'with many packages' do
+ let(:packages) { [package.id, other_package.id] }
+
+ # using after_index/before_index when receiving multiple packages doesn't
+ # make sense but we still verify here that the behavior is coherent.
+ # rubocop: disable Layout/LineLength
+ where(:first, :last, :after_index, :before_index, :max_page_size, :support_next_page, :expected_build_infos_indexes) do
+ # F L AI BI MPS SNP
+ nil | nil | nil | nil | nil | false | [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
+ nil | nil | nil | nil | 10 | false | [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
+ nil | nil | nil | nil | 2 | false | [9, 8, 4, 3]
+ 2 | nil | nil | nil | nil | false | [9, 8, 4, 3]
+ 2 | nil | nil | nil | nil | true | [9, 8, 7, 4, 3, 2]
+ 2 | nil | 3 | nil | nil | false | [2, 1]
+ 2 | nil | 3 | nil | nil | true | [2, 1, 0]
+ 3 | nil | 4 | nil | 2 | false | [3, 2]
+ 3 | nil | 4 | nil | 2 | true | [3, 2, 1]
+ nil | 2 | nil | nil | nil | false | [6, 5, 1, 0]
+ nil | 2 | nil | nil | nil | true | [7, 6, 5, 2, 1, 0]
+ nil | 2 | nil | 1 | nil | false | [6, 5, 3, 2]
+ nil | 2 | nil | 1 | nil | true | [7, 6, 5, 4, 3, 2]
+ nil | 3 | nil | 0 | 2 | false | [6, 5, 2, 1]
+ nil | 3 | nil | 0 | 2 | true | [7, 6, 5, 3, 2, 1]
+ end
+
+ with_them do
+ it_behaves_like 'returning the expected build infos'
+ end
+ # rubocop: enable Layout/LineLength
+ end
+ end
+end
diff --git a/spec/finders/packages/group_packages_finder_spec.rb b/spec/finders/packages/group_packages_finder_spec.rb
index c2dbfb59eb2..954db6481cd 100644
--- a/spec/finders/packages/group_packages_finder_spec.rb
+++ b/spec/finders/packages/group_packages_finder_spec.rb
@@ -149,6 +149,22 @@ RSpec.describe Packages::GroupPackagesFinder do
it { is_expected.to match_array([package1, package2]) }
end
+ context 'preload_pipelines' do
+ it 'preloads pipelines by default' do
+ expect(Packages::Package).to receive(:preload_pipelines).and_call_original
+ expect(subject).to match_array([package1, package2])
+ end
+
+ context 'set to false' do
+ let(:params) { { preload_pipelines: false } }
+
+ it 'does not preload pipelines' do
+ expect(Packages::Package).not_to receive(:preload_pipelines)
+ expect(subject).to match_array([package1, package2])
+ end
+ end
+ end
+
context 'with package_name' do
let_it_be(:named_package) { create(:maven_package, project: project, name: 'maven') }
diff --git a/spec/finders/packages/packages_finder_spec.rb b/spec/finders/packages/packages_finder_spec.rb
index b72f4aab3ec..6cea0a44541 100644
--- a/spec/finders/packages/packages_finder_spec.rb
+++ b/spec/finders/packages/packages_finder_spec.rb
@@ -81,6 +81,22 @@ RSpec.describe ::Packages::PackagesFinder do
it { is_expected.to match_array([conan_package, maven_package]) }
end
+ context 'preload_pipelines' do
+ it 'preloads pipelines by default' do
+ expect(Packages::Package).to receive(:preload_pipelines).and_call_original
+ expect(subject).to match_array([maven_package, conan_package])
+ end
+
+ context 'set to false' do
+ let(:params) { { preload_pipelines: false } }
+
+ it 'does not preload pipelines' do
+ expect(Packages::Package).not_to receive(:preload_pipelines)
+ expect(subject).to match_array([maven_package, conan_package])
+ end
+ end
+ end
+
it_behaves_like 'concerning versionless param'
it_behaves_like 'concerning package statuses'
end
diff --git a/spec/finders/releases/group_releases_finder_spec.rb b/spec/finders/releases/group_releases_finder_spec.rb
index b8899a8ee40..5eac6f4fbdc 100644
--- a/spec/finders/releases/group_releases_finder_spec.rb
+++ b/spec/finders/releases/group_releases_finder_spec.rb
@@ -95,8 +95,6 @@ RSpec.describe Releases::GroupReleasesFinder do
end
describe 'with subgroups' do
- let(:params) { { include_subgroups: true } }
-
subject(:releases) { described_class.new(group, user, params).execute(**args) }
context 'with a single-level subgroup' do
@@ -164,22 +162,12 @@ RSpec.describe Releases::GroupReleasesFinder do
end
end
- context 'when the user a guest on the group' do
- before do
- group.add_guest(user)
- end
-
- it 'returns all releases' do
- expect(releases).to match_array([v1_1_1, v1_1_0, v6, v1_0_0, p3])
- end
- end
-
context 'performance testing' do
shared_examples 'avoids N+1 queries' do |query_params = {}|
context 'with subgroups' do
let(:params) { query_params }
- it 'include_subgroups avoids N+1 queries' do
+ it 'subgroups avoids N+1 queries' do
control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do
releases
end.count
@@ -196,7 +184,6 @@ RSpec.describe Releases::GroupReleasesFinder do
end
it_behaves_like 'avoids N+1 queries'
- it_behaves_like 'avoids N+1 queries', { simple: true }
end
end
end
diff --git a/spec/finders/user_recent_events_finder_spec.rb b/spec/finders/user_recent_events_finder_spec.rb
index 6019d22059d..d7f7bb9cebe 100644
--- a/spec/finders/user_recent_events_finder_spec.rb
+++ b/spec/finders/user_recent_events_finder_spec.rb
@@ -8,9 +8,9 @@ RSpec.describe UserRecentEventsFinder do
let_it_be(:private_project) { create(:project, :private, creator: project_owner) }
let_it_be(:internal_project) { create(:project, :internal, creator: project_owner) }
let_it_be(:public_project) { create(:project, :public, creator: project_owner) }
- let!(:private_event) { create(:event, project: private_project, author: project_owner) }
- let!(:internal_event) { create(:event, project: internal_project, author: project_owner) }
- let!(:public_event) { create(:event, project: public_project, author: project_owner) }
+ let_it_be(:private_event) { create(:event, project: private_project, author: project_owner) }
+ let_it_be(:internal_event) { create(:event, project: internal_project, author: project_owner) }
+ let_it_be(:public_event) { create(:event, project: public_project, author: project_owner) }
let_it_be(:issue) { create(:issue, project: public_project) }
let(:limit) { nil }
@@ -18,210 +18,266 @@ RSpec.describe UserRecentEventsFinder do
subject(:finder) { described_class.new(current_user, project_owner, nil, params) }
- describe '#execute' do
- context 'when profile is public' do
- it 'returns all the events' do
- expect(finder.execute).to include(private_event, internal_event, public_event)
+ shared_examples 'UserRecentEventsFinder examples' do
+ describe '#execute' do
+ context 'when profile is public' do
+ it 'returns all the events' do
+ expect(finder.execute).to include(private_event, internal_event, public_event)
+ end
end
- end
- context 'when profile is private' do
- it 'returns no event' do
- allow(Ability).to receive(:allowed?).and_call_original
- allow(Ability).to receive(:allowed?).with(current_user, :read_user_profile, project_owner).and_return(false)
+ context 'when profile is private' do
+ it 'returns no event' do
+ allow(Ability).to receive(:allowed?).and_call_original
+ allow(Ability).to receive(:allowed?).with(current_user, :read_user_profile, project_owner).and_return(false)
- expect(finder.execute).to be_empty
+ expect(finder.execute).to be_empty
+ end
end
- end
- it 'does not include the events if the user cannot read cross project' do
- allow(Ability).to receive(:allowed?).and_call_original
- expect(Ability).to receive(:allowed?).with(current_user, :read_cross_project) { false }
+ it 'does not include the events if the user cannot read cross project' do
+ allow(Ability).to receive(:allowed?).and_call_original
+ expect(Ability).to receive(:allowed?).with(current_user, :read_cross_project) { false }
- expect(finder.execute).to be_empty
- end
+ expect(finder.execute).to be_empty
+ end
- context 'events from multiple users' do
- let_it_be(:second_user, reload: true) { create(:user) }
- let_it_be(:private_project_second_user) { create(:project, :private, creator: second_user) }
+ context 'events from multiple users' do
+ let_it_be(:second_user, reload: true) { create(:user) }
+ let_it_be(:private_project_second_user) { create(:project, :private, creator: second_user) }
- let(:internal_project_second_user) { create(:project, :internal, creator: second_user) }
- let(:public_project_second_user) { create(:project, :public, creator: second_user) }
- let!(:private_event_second_user) { create(:event, project: private_project_second_user, author: second_user) }
- let!(:internal_event_second_user) { create(:event, project: internal_project_second_user, author: second_user) }
- let!(:public_event_second_user) { create(:event, project: public_project_second_user, author: second_user) }
+ let_it_be(:internal_project_second_user) { create(:project, :internal, creator: second_user) }
+ let_it_be(:public_project_second_user) { create(:project, :public, creator: second_user) }
+ let_it_be(:private_event_second_user) { create(:event, project: private_project_second_user, author: second_user) }
+ let_it_be(:internal_event_second_user) { create(:event, project: internal_project_second_user, author: second_user) }
+ let_it_be(:public_event_second_user) { create(:event, project: public_project_second_user, author: second_user) }
- it 'includes events from all users', :aggregate_failures do
- events = described_class.new(current_user, [project_owner, second_user], nil, params).execute
+ it 'includes events from all users', :aggregate_failures do
+ events = described_class.new(current_user, [project_owner, second_user], nil, params).execute
- expect(events).to include(private_event, internal_event, public_event)
- expect(events).to include(private_event_second_user, internal_event_second_user, public_event_second_user)
- expect(events.size).to eq(6)
- end
+ expect(events).to include(private_event, internal_event, public_event)
+ expect(events).to include(private_event_second_user, internal_event_second_user, public_event_second_user)
+ expect(events.size).to eq(6)
+ end
- context 'selected events' do
- let!(:push_event) { create(:push_event, project: public_project, author: project_owner) }
- let!(:push_event_second_user) { create(:push_event, project: public_project_second_user, author: second_user) }
+ context 'selected events' do
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be(:push_event1) { create(:push_event, project: public_project, author: project_owner) }
+ let_it_be(:push_event2) { create(:push_event, project: public_project_second_user, author: second_user) }
+ let_it_be(:merge_event1) { create(:event, :merged, target_type: MergeRequest.to_s, project: public_project, author: project_owner) }
+ let_it_be(:merge_event2) { create(:event, :merged, target_type: MergeRequest.to_s, project: public_project_second_user, author: second_user) }
+ let_it_be(:comment_event1) { create(:event, :commented, target_type: Note.to_s, project: public_project, author: project_owner) }
+ let_it_be(:comment_event2) { create(:event, :commented, target_type: DiffNote.to_s, project: public_project, author: project_owner) }
+ let_it_be(:comment_event3) { create(:event, :commented, target_type: DiscussionNote.to_s, project: public_project_second_user, author: second_user) }
+ let_it_be(:issue_event1) { create(:event, :created, project: public_project, target: issue, author: project_owner) }
+ let_it_be(:issue_event2) { create(:event, :updated, project: public_project, target: issue, author: project_owner) }
+ let_it_be(:issue_event3) { create(:event, :closed, project: public_project_second_user, target: issue, author: second_user) }
+ let_it_be(:wiki_event1) { create(:wiki_page_event, project: public_project, author: project_owner) }
+ let_it_be(:wiki_event2) { create(:wiki_page_event, project: public_project_second_user, author: second_user) }
+ let_it_be(:design_event1) { create(:design_event, project: public_project, author: project_owner) }
+ let_it_be(:design_event2) { create(:design_updated_event, project: public_project_second_user, author: second_user) }
+
+ where(:event_filter, :ordered_expected_events) do
+ EventFilter.new(EventFilter::PUSH) | lazy { [push_event1, push_event2] }
+ EventFilter.new(EventFilter::MERGED) | lazy { [merge_event1, merge_event2] }
+ EventFilter.new(EventFilter::COMMENTS) | lazy { [comment_event1, comment_event2, comment_event3] }
+ EventFilter.new(EventFilter::TEAM) | lazy { [private_event, internal_event, public_event, private_event_second_user, internal_event_second_user, public_event_second_user] }
+ EventFilter.new(EventFilter::ISSUE) | lazy { [issue_event1, issue_event2, issue_event3] }
+ EventFilter.new(EventFilter::WIKI) | lazy { [wiki_event1, wiki_event2] }
+ EventFilter.new(EventFilter::DESIGNS) | lazy { [design_event1, design_event2] }
+ end
- it 'only includes selected events (PUSH) from all users', :aggregate_failures do
- event_filter = EventFilter.new(EventFilter::PUSH)
- events = described_class.new(current_user, [project_owner, second_user], event_filter, params).execute
+ with_them do
+ it 'only returns selected events from all users (id DESC)' do
+ events = described_class.new(current_user, [project_owner, second_user], event_filter, params).execute
- expect(events).to contain_exactly(push_event, push_event_second_user)
+ expect(events).to eq(ordered_expected_events.reverse)
+ end
+ end
end
- end
- it 'does not include events from users with private profile', :aggregate_failures do
- allow(Ability).to receive(:allowed?).and_call_original
- allow(Ability).to receive(:allowed?).with(current_user, :read_user_profile, second_user).and_return(false)
+ it 'does not include events from users with private profile', :aggregate_failures do
+ allow(Ability).to receive(:allowed?).and_call_original
+ allow(Ability).to receive(:allowed?).with(current_user, :read_user_profile, second_user).and_return(false)
- events = described_class.new(current_user, [project_owner, second_user], nil, params).execute
+ events = described_class.new(current_user, [project_owner, second_user], nil, params).execute
- expect(events).to contain_exactly(private_event, internal_event, public_event)
- end
+ expect(events).to contain_exactly(private_event, internal_event, public_event)
+ end
- context 'with pagination params' do
- using RSpec::Parameterized::TableSyntax
+ context 'with pagination params' do
+ using RSpec::Parameterized::TableSyntax
- where(:limit, :offset, :ordered_expected_events) do
- nil | nil | lazy { [public_event_second_user, internal_event_second_user, private_event_second_user, public_event, internal_event, private_event] }
- 2 | nil | lazy { [public_event_second_user, internal_event_second_user] }
- nil | 4 | lazy { [internal_event, private_event] }
- 2 | 2 | lazy { [private_event_second_user, public_event] }
- end
+ where(:limit, :offset, :ordered_expected_events) do
+ nil | nil | lazy { [public_event_second_user, internal_event_second_user, private_event_second_user, public_event, internal_event, private_event] }
+ 2 | nil | lazy { [public_event_second_user, internal_event_second_user] }
+ nil | 4 | lazy { [internal_event, private_event] }
+ 2 | 2 | lazy { [private_event_second_user, public_event] }
+ end
- with_them do
- let(:params) { { limit: limit, offset: offset }.compact }
+ with_them do
+ let(:params) { { limit: limit, offset: offset }.compact }
- it 'returns paginated events sorted by id (DESC)' do
- events = described_class.new(current_user, [project_owner, second_user], nil, params).execute
+ it 'returns paginated events sorted by id (DESC)' do
+ events = described_class.new(current_user, [project_owner, second_user], nil, params).execute
- expect(events).to eq(ordered_expected_events)
+ expect(events).to eq(ordered_expected_events)
+ end
end
end
end
- end
- context 'filter activity events' do
- let!(:push_event) { create(:push_event, project: public_project, author: project_owner) }
- let!(:merge_event) { create(:event, :merged, project: public_project, author: project_owner) }
- let!(:issue_event) { create(:event, :closed, project: public_project, target: issue, author: project_owner) }
- let!(:comment_event) { create(:event, :commented, project: public_project, author: project_owner) }
- let!(:wiki_event) { create(:wiki_page_event, project: public_project, author: project_owner) }
- let!(:design_event) { create(:design_event, project: public_project, author: project_owner) }
- let!(:team_event) { create(:event, :joined, project: public_project, author: project_owner) }
-
- it 'includes all events', :aggregate_failures do
- event_filter = EventFilter.new(EventFilter::ALL)
- events = described_class.new(current_user, project_owner, event_filter, params).execute
-
- expect(events).to include(private_event, internal_event, public_event)
- expect(events).to include(push_event, merge_event, issue_event, comment_event, wiki_event, design_event, team_event)
- expect(events.size).to eq(10)
- end
+ context 'filter activity events' do
+ let_it_be(:push_event) { create(:push_event, project: public_project, author: project_owner) }
+ let_it_be(:merge_event) { create(:event, :merged, project: public_project, author: project_owner) }
+ let_it_be(:issue_event) { create(:event, :closed, project: public_project, target: issue, author: project_owner) }
+ let_it_be(:comment_event) { create(:event, :commented, project: public_project, author: project_owner) }
+ let_it_be(:wiki_event) { create(:wiki_page_event, project: public_project, author: project_owner) }
+ let_it_be(:design_event) { create(:design_event, project: public_project, author: project_owner) }
+ let_it_be(:team_event) { create(:event, :joined, project: public_project, author: project_owner) }
+
+ it 'includes all events', :aggregate_failures do
+ event_filter = EventFilter.new(EventFilter::ALL)
+ events = described_class.new(current_user, project_owner, event_filter, params).execute
+
+ expect(events).to include(private_event, internal_event, public_event)
+ expect(events).to include(push_event, merge_event, issue_event, comment_event, wiki_event, design_event, team_event)
+ expect(events.size).to eq(10)
+ end
- it 'only includes push events', :aggregate_failures do
- event_filter = EventFilter.new(EventFilter::PUSH)
- events = described_class.new(current_user, project_owner, event_filter, params).execute
+ context 'when unknown filter is given' do
+ it 'includes returns all events', :aggregate_failures do
+ event_filter = EventFilter.new('unknown')
+ allow(event_filter).to receive(:filter).and_return('unknown')
- expect(events).to include(push_event)
- expect(events.size).to eq(1)
- end
+ events = described_class.new(current_user, [project_owner], event_filter, params).execute
- it 'only includes merge events', :aggregate_failures do
- event_filter = EventFilter.new(EventFilter::MERGED)
- events = described_class.new(current_user, project_owner, event_filter, params).execute
+ expect(events).to include(private_event, internal_event, public_event)
+ expect(events).to include(push_event, merge_event, issue_event, comment_event, wiki_event, design_event, team_event)
+ expect(events.size).to eq(10)
+ end
+ end
- expect(events).to include(merge_event)
- expect(events.size).to eq(1)
- end
+ it 'only includes push events', :aggregate_failures do
+ event_filter = EventFilter.new(EventFilter::PUSH)
+ events = described_class.new(current_user, project_owner, event_filter, params).execute
- it 'only includes issue events', :aggregate_failures do
- event_filter = EventFilter.new(EventFilter::ISSUE)
- events = described_class.new(current_user, project_owner, event_filter, params).execute
+ expect(events).to include(push_event)
+ expect(events.size).to eq(1)
+ end
- expect(events).to include(issue_event)
- expect(events.size).to eq(1)
- end
+ it 'only includes merge events', :aggregate_failures do
+ event_filter = EventFilter.new(EventFilter::MERGED)
+ events = described_class.new(current_user, project_owner, event_filter, params).execute
- it 'only includes comments events', :aggregate_failures do
- event_filter = EventFilter.new(EventFilter::COMMENTS)
- events = described_class.new(current_user, project_owner, event_filter, params).execute
+ expect(events).to include(merge_event)
+ expect(events.size).to eq(1)
+ end
- expect(events).to include(comment_event)
- expect(events.size).to eq(1)
- end
+ it 'only includes issue events', :aggregate_failures do
+ event_filter = EventFilter.new(EventFilter::ISSUE)
+ events = described_class.new(current_user, project_owner, event_filter, params).execute
- it 'only includes wiki events', :aggregate_failures do
- event_filter = EventFilter.new(EventFilter::WIKI)
- events = described_class.new(current_user, project_owner, event_filter, params).execute
+ expect(events).to include(issue_event)
+ expect(events.size).to eq(1)
+ end
- expect(events).to include(wiki_event)
- expect(events.size).to eq(1)
- end
+ it 'only includes comments events', :aggregate_failures do
+ event_filter = EventFilter.new(EventFilter::COMMENTS)
+ events = described_class.new(current_user, project_owner, event_filter, params).execute
- it 'only includes design events', :aggregate_failures do
- event_filter = EventFilter.new(EventFilter::DESIGNS)
- events = described_class.new(current_user, project_owner, event_filter, params).execute
+ expect(events).to include(comment_event)
+ expect(events.size).to eq(1)
+ end
- expect(events).to include(design_event)
- expect(events.size).to eq(1)
- end
+ it 'only includes wiki events', :aggregate_failures do
+ event_filter = EventFilter.new(EventFilter::WIKI)
+ events = described_class.new(current_user, project_owner, event_filter, params).execute
- it 'only includes team events', :aggregate_failures do
- event_filter = EventFilter.new(EventFilter::TEAM)
- events = described_class.new(current_user, project_owner, event_filter, params).execute
+ expect(events).to include(wiki_event)
+ expect(events.size).to eq(1)
+ end
- expect(events).to include(private_event, internal_event, public_event, team_event)
- expect(events.size).to eq(4)
- end
- end
+ it 'only includes design events', :aggregate_failures do
+ event_filter = EventFilter.new(EventFilter::DESIGNS)
+ events = described_class.new(current_user, project_owner, event_filter, params).execute
- describe 'issue activity events' do
- let(:issue) { create(:issue, project: public_project) }
- let(:note) { create(:note_on_issue, noteable: issue, project: public_project) }
- let!(:event_a) { create(:event, :commented, target: note, author: project_owner) }
- let!(:event_b) { create(:event, :closed, target: issue, author: project_owner) }
+ expect(events).to include(design_event)
+ expect(events.size).to eq(1)
+ end
- it 'includes all issue related events', :aggregate_failures do
- events = finder.execute
+ it 'only includes team events', :aggregate_failures do
+ event_filter = EventFilter.new(EventFilter::TEAM)
+ events = described_class.new(current_user, project_owner, event_filter, params).execute
- expect(events).to include(event_a)
- expect(events).to include(event_b)
+ expect(events).to include(private_event, internal_event, public_event, team_event)
+ expect(events.size).to eq(4)
+ end
end
- end
- context 'limits' do
- before do
- stub_const("#{described_class}::DEFAULT_LIMIT", 1)
- stub_const("#{described_class}::MAX_LIMIT", 3)
- end
+ describe 'issue activity events' do
+ let(:issue) { create(:issue, project: public_project) }
+ let(:note) { create(:note_on_issue, noteable: issue, project: public_project) }
+ let!(:event_a) { create(:event, :commented, target: note, author: project_owner) }
+ let!(:event_b) { create(:event, :closed, target: issue, author: project_owner) }
- context 'when limit is not set' do
- it 'returns events limited to DEFAULT_LIMIT' do
- expect(finder.execute.size).to eq(described_class::DEFAULT_LIMIT)
+ it 'includes all issue related events', :aggregate_failures do
+ events = finder.execute
+
+ expect(events).to include(event_a)
+ expect(events).to include(event_b)
end
end
- context 'when limit is set' do
- let(:limit) { 2 }
+ context 'limits' do
+ before do
+ stub_const("#{described_class}::DEFAULT_LIMIT", 1)
+ stub_const("#{described_class}::MAX_LIMIT", 3)
+ end
- it 'returns events limited to specified limit' do
- expect(finder.execute.size).to eq(limit)
+ context 'when limit is not set' do
+ it 'returns events limited to DEFAULT_LIMIT' do
+ expect(finder.execute.size).to eq(described_class::DEFAULT_LIMIT)
+ end
end
- end
- context 'when limit is set to a number that exceeds maximum limit' do
- let(:limit) { 4 }
+ context 'when limit is set' do
+ let(:limit) { 2 }
- before do
- create(:event, project: public_project, author: project_owner)
+ it 'returns events limited to specified limit' do
+ expect(finder.execute.size).to eq(limit)
+ end
end
- it 'returns events limited to MAX_LIMIT' do
- expect(finder.execute.size).to eq(described_class::MAX_LIMIT)
+ context 'when limit is set to a number that exceeds maximum limit' do
+ let(:limit) { 4 }
+
+ before do
+ create(:event, project: public_project, author: project_owner)
+ end
+
+ it 'returns events limited to MAX_LIMIT' do
+ expect(finder.execute.size).to eq(described_class::MAX_LIMIT)
+ end
end
end
end
end
+
+ context 'when the optimized_followed_users_queries FF is on' do
+ before do
+ stub_feature_flags(optimized_followed_users_queries: true)
+ end
+
+ it_behaves_like 'UserRecentEventsFinder examples'
+ end
+
+ context 'when the optimized_followed_users_queries FF is off' do
+ before do
+ stub_feature_flags(optimized_followed_users_queries: false)
+ end
+
+ it_behaves_like 'UserRecentEventsFinder examples'
+ end
end
diff --git a/spec/finders/users_finder_spec.rb b/spec/finders/users_finder_spec.rb
index fab48cf3178..271dce44db7 100644
--- a/spec/finders/users_finder_spec.rb
+++ b/spec/finders/users_finder_spec.rb
@@ -6,13 +6,15 @@ RSpec.describe UsersFinder do
describe '#execute' do
include_context 'UsersFinder#execute filter by project context'
+ let_it_be(:project_bot) { create(:user, :project_bot) }
+
context 'with a normal user' do
- let(:user) { create(:user) }
+ let_it_be(:user) { create(:user) }
- it 'returns all users' do
+ it 'returns searchable users' do
users = described_class.new(user).execute
- expect(users).to contain_exactly(user, normal_user, blocked_user, external_user, omniauth_user, internal_user, admin_user)
+ expect(users).to contain_exactly(user, normal_user, external_user, unconfirmed_user, omniauth_user, internal_user, admin_user, project_bot)
end
it 'filters by username' do
@@ -34,9 +36,9 @@ RSpec.describe UsersFinder do
end
it 'filters by search' do
- users = described_class.new(user, search: 'orando').execute
+ users = described_class.new(user, search: 'ohndo').execute
- expect(users).to contain_exactly(blocked_user)
+ expect(users).to contain_exactly(normal_user)
end
it 'does not filter by private emails search' do
@@ -45,18 +47,6 @@ RSpec.describe UsersFinder do
expect(users).to be_empty
end
- it 'filters by blocked users' do
- users = described_class.new(user, blocked: true).execute
-
- expect(users).to contain_exactly(blocked_user)
- end
-
- it 'filters by active users' do
- users = described_class.new(user, active: true).execute
-
- expect(users).to contain_exactly(user, normal_user, external_user, omniauth_user, admin_user)
- end
-
it 'filters by external users' do
users = described_class.new(user, external: true).execute
@@ -66,7 +56,7 @@ RSpec.describe UsersFinder do
it 'filters by non external users' do
users = described_class.new(user, non_external: true).execute
- expect(users).to contain_exactly(user, normal_user, blocked_user, omniauth_user, internal_user, admin_user)
+ expect(users).to contain_exactly(user, normal_user, unconfirmed_user, omniauth_user, internal_user, admin_user, project_bot)
end
it 'filters by created_at' do
@@ -83,7 +73,7 @@ RSpec.describe UsersFinder do
it 'filters by non internal users' do
users = described_class.new(user, non_internal: true).execute
- expect(users).to contain_exactly(user, normal_user, external_user, blocked_user, omniauth_user, admin_user)
+ expect(users).to contain_exactly(user, normal_user, unconfirmed_user, external_user, omniauth_user, admin_user, project_bot)
end
it 'does not filter by custom attributes' do
@@ -92,23 +82,23 @@ RSpec.describe UsersFinder do
custom_attributes: { foo: 'bar' }
).execute
- expect(users).to contain_exactly(user, normal_user, blocked_user, external_user, omniauth_user, internal_user, admin_user)
+ expect(users).to contain_exactly(user, normal_user, external_user, unconfirmed_user, omniauth_user, internal_user, admin_user, project_bot)
end
it 'orders returned results' do
users = described_class.new(user, sort: 'id_asc').execute
- expect(users).to eq([normal_user, admin_user, blocked_user, external_user, omniauth_user, internal_user, user])
+ expect(users).to eq([normal_user, admin_user, external_user, unconfirmed_user, omniauth_user, internal_user, project_bot, user])
end
it 'does not filter by admins' do
users = described_class.new(user, admins: true).execute
- expect(users).to contain_exactly(user, normal_user, external_user, admin_user, blocked_user, omniauth_user, internal_user)
+ expect(users).to contain_exactly(user, normal_user, external_user, admin_user, unconfirmed_user, omniauth_user, internal_user, project_bot)
end
end
context 'with an admin user', :enable_admin_mode do
- let(:admin) { create(:admin) }
+ let_it_be(:admin) { create(:admin) }
it 'filters by external users' do
users = described_class.new(admin, external: true).execute
@@ -119,7 +109,19 @@ RSpec.describe UsersFinder do
it 'returns all users' do
users = described_class.new(admin).execute
- expect(users).to contain_exactly(admin, normal_user, blocked_user, external_user, omniauth_user, internal_user, admin_user)
+ expect(users).to contain_exactly(admin, normal_user, blocked_user, unconfirmed_user, banned_user, external_user, omniauth_user, internal_user, admin_user, project_bot)
+ end
+
+ it 'filters by blocked users' do
+ users = described_class.new(admin, blocked: true).execute
+
+ expect(users).to contain_exactly(blocked_user)
+ end
+
+ it 'filters by active users' do
+ users = described_class.new(admin, active: true).execute
+
+ expect(users).to contain_exactly(admin, normal_user, unconfirmed_user, external_user, omniauth_user, admin_user, project_bot)
end
it 'returns only admins' do
diff --git a/spec/fixtures/api/schemas/entities/member_user.json b/spec/fixtures/api/schemas/entities/member_user.json
index d42c686bb65..0750e81e115 100644
--- a/spec/fixtures/api/schemas/entities/member_user.json
+++ b/spec/fixtures/api/schemas/entities/member_user.json
@@ -1,15 +1,28 @@
{
"type": "object",
- "required": ["id", "name", "username", "avatar_url", "web_url", "blocked", "two_factor_enabled", "show_status"],
+ "required": [
+ "id",
+ "name",
+ "username",
+ "created_at",
+ "last_activity_on",
+ "avatar_url",
+ "web_url",
+ "blocked",
+ "two_factor_enabled",
+ "show_status"
+ ],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"username": { "type": "string" },
+ "created_at": { "type": ["string"] },
"avatar_url": { "type": ["string", "null"] },
"web_url": { "type": "string" },
"blocked": { "type": "boolean" },
"two_factor_enabled": { "type": "boolean" },
"availability": { "type": ["string", "null"] },
+ "last_activity_on": { "type": ["string", "null"] },
"status": {
"type": "object",
"required": ["emoji"],
diff --git a/spec/fixtures/api/schemas/group_link/group_group_link.json b/spec/fixtures/api/schemas/group_link/group_group_link.json
index bfca5c885e3..689679cbc0f 100644
--- a/spec/fixtures/api/schemas/group_link/group_group_link.json
+++ b/spec/fixtures/api/schemas/group_link/group_group_link.json
@@ -4,12 +4,19 @@
{ "$ref": "group_link.json" },
{
"required": [
- "can_update",
- "can_remove"
+ "source"
],
"properties": {
- "can_update": { "type": "boolean" },
- "can_remove": { "type": "boolean" }
+ "source": {
+ "type": "object",
+ "required": ["id", "full_name", "web_url"],
+ "properties": {
+ "id": { "type": "integer" },
+ "full_name": { "type": "string" },
+ "web_url": { "type": "string" }
+ },
+ "additionalProperties": false
+ }
}
}
]
diff --git a/spec/fixtures/api/schemas/group_link/group_link.json b/spec/fixtures/api/schemas/group_link/group_link.json
index 300790728a8..3c2195df11e 100644
--- a/spec/fixtures/api/schemas/group_link/group_link.json
+++ b/spec/fixtures/api/schemas/group_link/group_link.json
@@ -5,7 +5,10 @@
"created_at",
"expires_at",
"access_level",
- "valid_roles"
+ "valid_roles",
+ "can_update",
+ "can_remove",
+ "is_direct_member"
],
"properties": {
"id": { "type": "integer" },
@@ -33,6 +36,9 @@
"web_url": { "type": "string" }
},
"additionalProperties": false
- }
+ },
+ "can_update": { "type": "boolean" },
+ "can_remove": { "type": "boolean" },
+ "is_direct_member": { "type": "boolean" }
}
}
diff --git a/spec/fixtures/api/schemas/group_link/project_group_link.json b/spec/fixtures/api/schemas/group_link/project_group_link.json
index bfca5c885e3..615c808e5aa 100644
--- a/spec/fixtures/api/schemas/group_link/project_group_link.json
+++ b/spec/fixtures/api/schemas/group_link/project_group_link.json
@@ -4,12 +4,18 @@
{ "$ref": "group_link.json" },
{
"required": [
- "can_update",
- "can_remove"
+ "source"
],
"properties": {
- "can_update": { "type": "boolean" },
- "can_remove": { "type": "boolean" }
+ "source": {
+ "type": "object",
+ "required": ["id", "full_name"],
+ "properties": {
+ "id": { "type": "integer" },
+ "full_name": { "type": "string" }
+ },
+ "additionalProperties": false
+ }
}
}
]
diff --git a/spec/fixtures/api/schemas/public_api/v4/agent.json b/spec/fixtures/api/schemas/public_api/v4/agent.json
new file mode 100644
index 00000000000..4821d5e0b04
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/agent.json
@@ -0,0 +1,18 @@
+{
+ "type": "object",
+ "required": [
+ "id",
+ "name",
+ "config_project",
+ "created_at",
+ "created_by_user_id"
+ ],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "config_project": { "$ref": "project_identity.json" },
+ "created_at": { "type": "string", "format": "date-time" },
+ "created_by_user_id": { "type": "integer" }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/agents.json b/spec/fixtures/api/schemas/public_api/v4/agents.json
new file mode 100644
index 00000000000..5fe3d7f9481
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/agents.json
@@ -0,0 +1,4 @@
+{
+ "type": "array",
+ "items": { "$ref": "agent.json" }
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/issue.json b/spec/fixtures/api/schemas/public_api/v4/issue.json
index 3173a8ebfb5..90b368b5226 100644
--- a/spec/fixtures/api/schemas/public_api/v4/issue.json
+++ b/spec/fixtures/api/schemas/public_api/v4/issue.json
@@ -86,6 +86,7 @@
"due_date": { "type": ["string", "null"] },
"confidential": { "type": "boolean" },
"web_url": { "type": "uri" },
+ "severity": { "type": "string", "enum": ["UNKNOWN", "LOW", "MEDIUM", "HIGH", "CRITICAL"] },
"time_stats": {
"time_estimate": { "type": "integer" },
"total_time_spent": { "type": "integer" },
diff --git a/spec/fixtures/api/schemas/public_api/v4/issue_links.json b/spec/fixtures/api/schemas/public_api/v4/issue_links.json
deleted file mode 100644
index d254615dd58..00000000000
--- a/spec/fixtures/api/schemas/public_api/v4/issue_links.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "type": "array",
- "items": {
- "type": "object",
- "properties" : {
- "$ref": "./issue_link.json"
- }
- }
-}
diff --git a/spec/fixtures/api/schemas/public_api/v4/project_identity.json b/spec/fixtures/api/schemas/public_api/v4/project_identity.json
new file mode 100644
index 00000000000..6471dd560c5
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/project_identity.json
@@ -0,0 +1,22 @@
+{
+ "type": "object",
+ "required": [
+ "id",
+ "description",
+ "name",
+ "name_with_namespace",
+ "path",
+ "path_with_namespace",
+ "created_at"
+ ],
+ "properties": {
+ "id": { "type": "integer" },
+ "description": { "type": ["string", "null"] },
+ "name": { "type": "string" },
+ "name_with_namespace": { "type": "string" },
+ "path": { "type": "string" },
+ "path_with_namespace": { "type": "string" },
+ "created_at": { "type": "string", "format": "date-time" }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/related_issues.json b/spec/fixtures/api/schemas/public_api/v4/related_issues.json
new file mode 100644
index 00000000000..83095ab44c1
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/related_issues.json
@@ -0,0 +1,26 @@
+{
+ "type": "array",
+ "items": {
+ "type": "object",
+ "allOf": [
+ { "$ref": "../../../../../../spec/fixtures/api/schemas/public_api/v4/issue.json" },
+ {
+ "required" : [
+ "link_type",
+ "issue_link_id",
+ "link_created_at",
+ "link_updated_at"
+ ],
+ "properties" : {
+ "link_type": {
+ "type": "string",
+ "enum": ["relates_to", "blocks", "is_blocked_by"]
+ },
+ "issue_link_id": { "type": "integer" },
+ "link_created_at": { "type": "string" },
+ "link_updated_at": { "type": "string" }
+ }
+ }
+ ]
+ }
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/release/release_for_guest.json b/spec/fixtures/api/schemas/public_api/v4/release/release_for_guest.json
index 465e1193a64..0f9a5ccfa7d 100644
--- a/spec/fixtures/api/schemas/public_api/v4/release/release_for_guest.json
+++ b/spec/fixtures/api/schemas/public_api/v4/release/release_for_guest.json
@@ -5,6 +5,7 @@
"name": { "type": "string" },
"description": { "type": "string" },
"description_html": { "type": "string" },
+ "tag_name": { "type": "string"},
"created_at": { "type": "string", "format": "date-time" },
"released_at": { "type": "string", "format": "date-time" },
"upcoming_release": { "type": "boolean" },
diff --git a/spec/fixtures/api/schemas/public_api/v4/resource_access_token.json b/spec/fixtures/api/schemas/public_api/v4/resource_access_token.json
new file mode 100644
index 00000000000..3636c970e83
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/resource_access_token.json
@@ -0,0 +1,31 @@
+{
+ "type": "object",
+ "required": [
+ "id",
+ "name",
+ "user_id",
+ "active",
+ "created_at",
+ "expires_at",
+ "revoked",
+ "access_level",
+ "scopes",
+ "last_used_at"
+ ],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "user_id": { "type": "integer" },
+ "active": { "type": "boolean" },
+ "created_at": { "type": "string", "format": "date-time" },
+ "expires_at": { "type": ["string", "null"], "format": "date" },
+ "revoked": { "type": "boolean" },
+ "access_level": { "type": "integer" },
+ "scopes": {
+ "type": "array",
+ "items": { "type": "string" }
+ },
+ "last_used_at": { "type": ["string", "null"], "format": "date-time" }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/resource_access_tokens.json b/spec/fixtures/api/schemas/public_api/v4/resource_access_tokens.json
new file mode 100644
index 00000000000..1bf013b8bca
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/resource_access_tokens.json
@@ -0,0 +1,4 @@
+{
+ "type": "array",
+ "items": { "$ref": "resource_access_token.json" }
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/user/admin.json b/spec/fixtures/api/schemas/public_api/v4/user/admin.json
index f733914fbf8..8d06e16848f 100644
--- a/spec/fixtures/api/schemas/public_api/v4/user/admin.json
+++ b/spec/fixtures/api/schemas/public_api/v4/user/admin.json
@@ -26,7 +26,8 @@
"can_create_group",
"can_create_project",
"two_factor_enabled",
- "external"
+ "external",
+ "namespace_id"
],
"properties": {
"$ref": "full.json"
diff --git a/spec/fixtures/avatars/avatar1.png b/spec/fixtures/avatars/avatar1.png
new file mode 100644
index 00000000000..7e8afb39f17
--- /dev/null
+++ b/spec/fixtures/avatars/avatar1.png
Binary files differ
diff --git a/spec/fixtures/avatars/avatar2.png b/spec/fixtures/avatars/avatar2.png
new file mode 100644
index 00000000000..462678b1871
--- /dev/null
+++ b/spec/fixtures/avatars/avatar2.png
Binary files differ
diff --git a/spec/fixtures/avatars/avatar3.png b/spec/fixtures/avatars/avatar3.png
new file mode 100644
index 00000000000..e065f681817
--- /dev/null
+++ b/spec/fixtures/avatars/avatar3.png
Binary files differ
diff --git a/spec/fixtures/avatars/avatar4.png b/spec/fixtures/avatars/avatar4.png
new file mode 100644
index 00000000000..647ee193cbd
--- /dev/null
+++ b/spec/fixtures/avatars/avatar4.png
Binary files differ
diff --git a/spec/fixtures/avatars/avatar5.png b/spec/fixtures/avatars/avatar5.png
new file mode 100644
index 00000000000..27e973dc5e3
--- /dev/null
+++ b/spec/fixtures/avatars/avatar5.png
Binary files differ
diff --git a/spec/fixtures/emails/service_desk_reply_to_and_from.eml b/spec/fixtures/emails/service_desk_reply_to_and_from.eml
deleted file mode 100644
index 2545e0d30f8..00000000000
--- a/spec/fixtures/emails/service_desk_reply_to_and_from.eml
+++ /dev/null
@@ -1,28 +0,0 @@
-Delivered-To: incoming+email-test-project_id-issue-@appmail.adventuretime.ooo
-Return-Path: <jake@adventuretime.ooo>
-Received: from iceking.adventuretime.ooo ([unix socket]) by iceking (Cyrus v2.2.13-Debian-2.2.13-19+squeeze3) with LMTPA; Thu, 13 Jun 2013 17:03:50 -0400
-Received: from mail-ie0-x234.google.com (mail-ie0-x234.google.com [IPv6:2607:f8b0:4001:c03::234]) by iceking.adventuretime.ooo (8.14.3/8.14.3/Debian-9.4) with ESMTP id r5DL3nFJ016967 (version=TLSv1/SSLv3 cipher=RC4-SHA bits=128 verify=NOT) for <incoming+gitlabhq/gitlabhq@appmail.adventuretime.ooo>; Thu, 13 Jun 2013 17:03:50 -0400
-Received: by mail-ie0-f180.google.com with SMTP id f4so21977375iea.25 for <incoming+email-test-project_id-issue-@appmail.adventuretime.ooo>; Thu, 13 Jun 2013 14:03:48 -0700
-Received: by 10.0.0.1 with HTTP; Thu, 13 Jun 2013 14:03:48 -0700
-Date: Thu, 13 Jun 2013 17:03:48 -0400
-Reply-To: Marceline <marceline@adventuretime.ooo>
-From: Finn the Human <finn@adventuretime.ooo>
-Sender: Jake the Dog <jake@adventuretime.ooo>
-To: support@adventuretime.ooo
-Delivered-To: support@adventuretime.ooo
-Message-ID: <CADkmRc+rNGAGGbV2iE5p918UVy4UyJqVcXRO2=otppgzduJSg@mail.gmail.com>
-Subject: The message subject! @all
-Mime-Version: 1.0
-Content-Type: text/plain;
- charset=ISO-8859-1
-Content-Transfer-Encoding: 7bit
-X-Sieve: CMU Sieve 2.2
-X-Received: by 10.0.0.1 with SMTP id n7mr11234144ipb.85.1371157428600; Thu,
- 13 Jun 2013 14:03:48 -0700 (PDT)
-X-Scanned-By: MIMEDefang 2.69 on IPv6:2001:470:1d:165::1
-
-Service desk stuff!
-
-```
-a = b
-```
diff --git a/spec/fixtures/markdown/markdown_golden_master_examples.yml b/spec/fixtures/markdown/markdown_golden_master_examples.yml
index 8556811974d..bdd7c13c1a3 100644
--- a/spec/fixtures/markdown/markdown_golden_master_examples.yml
+++ b/spec/fixtures/markdown/markdown_golden_master_examples.yml
@@ -377,6 +377,34 @@
</ol>
</details>
+- name: diagram_kroki_nomnoml
+ markdown: |-
+ ```nomnoml
+ #stroke: #a86128
+ [<frame>Decorator pattern|
+ [<abstract>Component||+ operation()]
+ [Client] depends --> [Component]
+ [Decorator|- next: Component]
+ [Decorator] decorates -- [ConcreteComponent]
+ [Component] <:- [Decorator]
+ [Component] <:- [ConcreteComponent]
+ ]
+ ```
+ html: |-
+ <a class="no-attachment-icon" href="http://localhost:8000/nomnoml/svg/eNp1jbsOwjAMRfd-haUuIJQBBlRFVZb2L1CGkBqpgtpR6oEhH0_CW6hsts-9xwD1LJHPqKF2zX67ayqAQ3uKbkLTo-fohCMEJ4KRUoYFu2MuOS-m4ykwIUlKG-CAOT0yrdb2EewuY2YWBgxIwwxKmXx8dZ6h95ekgPAqGv4miuk-YnEVFfmIgr-Fzw6tVt-CZb7osdUNUAReJA==" target="_blank" rel="noopener noreferrer" data-diagram="nomnoml" data-diagram-src="data:text/plain;base64,ICAjc3Ryb2tlOiAjYTg2MTI4CiAgWzxmcmFtZT5EZWNvcmF0b3IgcGF0dGVybnwKICAgIFs8YWJzdHJhY3Q+Q29tcG9uZW50fHwrIG9wZXJhdGlvbigpXQogICAgW0NsaWVudF0gZGVwZW5kcyAtLT4gW0NvbXBvbmVudF0KICAgIFtEZWNvcmF0b3J8LSBuZXh0OiBDb21wb25lbnRdCiAgICBbRGVjb3JhdG9yXSBkZWNvcmF0ZXMgLS0gW0NvbmNyZXRlQ29tcG9uZW50XQogICAgW0NvbXBvbmVudF0gPDotIFtEZWNvcmF0b3JdCiAgICBbQ29tcG9uZW50XSA8Oi0gW0NvbmNyZXRlQ29tcG9uZW50XQogIF0K"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" class="js-render-kroki lazy" data-src="http://localhost:8000/nomnoml/svg/eNp1jbsOwjAMRfd-haUuIJQBBlRFVZb2L1CGkBqpgtpR6oEhH0_CW6hsts-9xwD1LJHPqKF2zX67ayqAQ3uKbkLTo-fohCMEJ4KRUoYFu2MuOS-m4ykwIUlKG-CAOT0yrdb2EewuY2YWBgxIwwxKmXx8dZ6h95ekgPAqGv4miuk-YnEVFfmIgr-Fzw6tVt-CZb7osdUNUAReJA=="></a>
+
+- name: diagram_plantuml
+ markdown: |-
+ ```plantuml
+ Alice -> Bob: Authentication Request
+ Bob --> Alice: Authentication Response
+
+ Alice -> Bob: Another authentication Request
+ Alice <-- Bob: Another authentication Response
+ ```
+ html: |-
+ <a class="no-attachment-icon" href="http://localhost:8080/png/U9nJK73CoKnELT2rKt3AJx9IS2mjoKZDAybCJYp9pCzJ24ejB4qjBk5I0Cagw09LWPLZKLTSa9zNdCe5L8bcO5u-K6MHGY8kWo7ARNHr2QY7MW00AeWxTG00" target="_blank" rel="noopener noreferrer" data-diagram="plantuml" data-diagram-src="data:text/plain;base64,ICBBbGljZSAtPiBCb2I6IEF1dGhlbnRpY2F0aW9uIFJlcXVlc3QKICBCb2IgLS0+IEFsaWNlOiBBdXRoZW50aWNhdGlvbiBSZXNwb25zZQoKICBBbGljZSAtPiBCb2I6IEFub3RoZXIgYXV0aGVudGljYXRpb24gUmVxdWVzdAogIEFsaWNlIDwtLSBCb2I6IEFub3RoZXIgYXV0aGVudGljYXRpb24gUmVzcG9uc2UK"><img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" class="lazy" data-src="http://localhost:8080/png/U9nJK73CoKnELT2rKt3AJx9IS2mjoKZDAybCJYp9pCzJ24ejB4qjBk5I0Cagw09LWPLZKLTSa9zNdCe5L8bcO5u-K6MHGY8kWo7ARNHr2QY7MW00AeWxTG00"></a>
+
- name: div
markdown: |-
<div>plain text</div>
diff --git a/spec/fixtures/security_reports/master/gl-sast-report-bandit.json b/spec/fixtures/security_reports/master/gl-sast-report-bandit.json
new file mode 100644
index 00000000000..a80833354ed
--- /dev/null
+++ b/spec/fixtures/security_reports/master/gl-sast-report-bandit.json
@@ -0,0 +1,43 @@
+{
+ "version": "14.0.4",
+ "vulnerabilities": [
+ {
+ "id": "985a5666dcae22adef5ac12f8a8a2dacf9b9b481ae5d87cd0ac1712b0fd64864",
+ "category": "sast",
+ "message": "Deserialization of Untrusted Data",
+ "description": "Avoid using `load()`. `PyYAML.load` can create arbitrary Python\nobjects. A malicious actor could exploit this to run arbitrary\ncode. Use `safe_load()` instead.\n",
+ "cve": "",
+ "severity": "Critical",
+ "scanner": {
+ "id": "bandit",
+ "name": "Bandit"
+ },
+ "location": {
+ "file": "app/app.py",
+ "start_line": 39
+ },
+ "identifiers": [
+ {
+ "type": "bandit_test_id",
+ "name": "Bandit Test ID B506",
+ "value": "B506"
+ }
+ ]
+ }
+ ],
+ "scan": {
+ "scanner": {
+ "id": "bandit",
+ "name": "Bandit",
+ "url": "https://github.com/PyCQA/bandit",
+ "vendor": {
+ "name": "GitLab"
+ },
+ "version": "1.7.1"
+ },
+ "type": "sast",
+ "start_time": "2022-03-11T00:21:49",
+ "end_time": "2022-03-11T00:21:50",
+ "status": "success"
+ }
+}
diff --git a/spec/fixtures/security_reports/master/gl-sast-report-gosec.json b/spec/fixtures/security_reports/master/gl-sast-report-gosec.json
new file mode 100644
index 00000000000..42986ea1045
--- /dev/null
+++ b/spec/fixtures/security_reports/master/gl-sast-report-gosec.json
@@ -0,0 +1,68 @@
+{
+ "version": "14.0.4",
+ "vulnerabilities": [
+ {
+ "id": "2e5656ff30e2e7cc93c36b4845c8a689ddc47fdbccf45d834c67442fbaa89be0",
+ "category": "sast",
+ "name": "Key Exchange without Entity Authentication",
+ "message": "Use of ssh InsecureIgnoreHostKey should be audited",
+ "description": "The software performs a key exchange with an actor without verifying the identity of that actor.",
+ "cve": "og.go:8:7: func foo() {\n8: \t_ = ssh.InsecureIgnoreHostKey()\n9: }\n:CWE-322",
+ "severity": "Medium",
+ "confidence": "High",
+ "raw_source_code_extract": "7: func foo() {\n8: \t_ = ssh.InsecureIgnoreHostKey()\n9: }\n",
+ "scanner": {
+ "id": "gosec",
+ "name": "Gosec"
+ },
+ "location": {
+ "file": "og.go",
+ "start_line": 8
+ },
+ "identifiers": [
+ {
+ "type": "gosec_rule_id",
+ "name": "Gosec Rule ID G106",
+ "value": "G106"
+ },
+ {
+ "type": "CWE",
+ "name": "CWE-322",
+ "value": "322",
+ "url": "https://cwe.mitre.org/data/definitions/322.html"
+ }
+ ],
+ "tracking": {
+ "type": "source",
+ "items": [
+ {
+ "file": "og.go",
+ "line_start": 8,
+ "line_end": 8,
+ "signatures": [
+ {
+ "algorithm": "scope_offset",
+ "value": "og.go|foo[0]:1"
+ }
+ ]
+ }
+ ]
+ }
+ }
+ ],
+ "scan": {
+ "scanner": {
+ "id": "gosec",
+ "name": "Gosec",
+ "url": "https://github.com/securego/gosec",
+ "vendor": {
+ "name": "GitLab"
+ },
+ "version": "2.10.0"
+ },
+ "type": "sast",
+ "start_time": "2022-03-15T20:33:12",
+ "end_time": "2022-03-15T20:33:17",
+ "status": "success"
+ }
+}
diff --git a/spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-bandit.json b/spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-bandit.json
new file mode 100644
index 00000000000..2a60a75366e
--- /dev/null
+++ b/spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-bandit.json
@@ -0,0 +1,71 @@
+{
+ "version": "14.0.4",
+ "vulnerabilities": [
+ {
+ "id": "985a5666dcae22adef5ac12f8a8a2dacf9b9b481ae5d87cd0ac1712b0fd64864",
+ "category": "sast",
+ "message": "Deserialization of Untrusted Data",
+ "description": "Avoid using `load()`. `PyYAML.load` can create arbitrary Python\nobjects. A malicious actor could exploit this to run arbitrary\ncode. Use `safe_load()` instead.\n",
+ "cve": "",
+ "severity": "Critical",
+ "scanner": {
+ "id": "semgrep",
+ "name": "Semgrep"
+ },
+ "location": {
+ "file": "app/app.py",
+ "start_line": 39
+ },
+ "identifiers": [
+ {
+ "type": "semgrep_id",
+ "name": "bandit.B506",
+ "value": "bandit.B506",
+ "url": "https://semgrep.dev/r/gitlab.bandit.B506"
+ },
+ {
+ "type": "cwe",
+ "name": "CWE-502",
+ "value": "502",
+ "url": "https://cwe.mitre.org/data/definitions/502.html"
+ },
+ {
+ "type": "bandit_test_id",
+ "name": "Bandit Test ID B506",
+ "value": "B506"
+ }
+ ],
+ "tracking": {
+ "type": "source",
+ "items": [
+ {
+ "file": "app/app.py",
+ "line_start": 39,
+ "line_end": 39,
+ "signatures": [
+ {
+ "algorithm": "scope_offset",
+ "value": "app/app.py|yaml_hammer[0]:13"
+ }
+ ]
+ }
+ ]
+ }
+ }
+ ],
+ "scan": {
+ "scanner": {
+ "id": "semgrep",
+ "name": "Semgrep",
+ "url": "https://github.com/returntocorp/semgrep",
+ "vendor": {
+ "name": "GitLab"
+ },
+ "version": "0.82.0"
+ },
+ "type": "sast",
+ "start_time": "2022-03-11T18:48:16",
+ "end_time": "2022-03-11T18:48:22",
+ "status": "success"
+ }
+}
diff --git a/spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-gosec.json b/spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-gosec.json
new file mode 100644
index 00000000000..3d8c65d5823
--- /dev/null
+++ b/spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-gosec.json
@@ -0,0 +1,70 @@
+{
+ "version": "14.0.4",
+ "vulnerabilities": [
+ {
+ "id": "79f6537b7ec83c7717f5bd1a4f12645916caafefe2e4359148d889855505aa67",
+ "category": "sast",
+ "message": "Key Exchange without Entity Authentication",
+ "description": "Audit the use of ssh.InsecureIgnoreHostKey\n",
+ "cve": "",
+ "severity": "Medium",
+ "scanner": {
+ "id": "semgrep",
+ "name": "Semgrep"
+ },
+ "location": {
+ "file": "og.go",
+ "start_line": 8
+ },
+ "identifiers": [
+ {
+ "type": "semgrep_id",
+ "name": "gosec.G106-1",
+ "value": "gosec.G106-1"
+ },
+ {
+ "type": "cwe",
+ "name": "CWE-322",
+ "value": "322",
+ "url": "https://cwe.mitre.org/data/definitions/322.html"
+ },
+ {
+ "type": "gosec_rule_id",
+ "name": "Gosec Rule ID G106",
+ "value": "G106"
+ }
+ ],
+ "tracking": {
+ "type": "source",
+ "items": [
+ {
+ "file": "og.go",
+ "line_start": 8,
+ "line_end": 8,
+ "signatures": [
+ {
+ "algorithm": "scope_offset",
+ "value": "og.go|foo[0]:1"
+ }
+ ]
+ }
+ ]
+ }
+ }
+ ],
+ "scan": {
+ "scanner": {
+ "id": "semgrep",
+ "name": "Semgrep",
+ "url": "https://github.com/returntocorp/semgrep",
+ "vendor": {
+ "name": "GitLab"
+ },
+ "version": "0.82.0"
+ },
+ "type": "sast",
+ "start_time": "2022-03-15T20:36:58",
+ "end_time": "2022-03-15T20:37:05",
+ "status": "success"
+ }
+}
diff --git a/spec/frontend/__helpers__/matchers/index.js b/spec/frontend/__helpers__/matchers/index.js
index 76571bafb06..9b83ced10e1 100644
--- a/spec/frontend/__helpers__/matchers/index.js
+++ b/spec/frontend/__helpers__/matchers/index.js
@@ -1,3 +1,4 @@
export * from './to_have_sprite_icon';
export * from './to_have_tracking_attributes';
export * from './to_match_interpolated_text';
+export * from './to_validate_json_schema';
diff --git a/spec/frontend/__helpers__/matchers/to_validate_json_schema.js b/spec/frontend/__helpers__/matchers/to_validate_json_schema.js
new file mode 100644
index 00000000000..ff391f08c55
--- /dev/null
+++ b/spec/frontend/__helpers__/matchers/to_validate_json_schema.js
@@ -0,0 +1,34 @@
+// NOTE: Make sure to initialize ajv when using this helper
+
+const getAjvErrorMessage = ({ errors }) => {
+ return (errors || []).map((error) => {
+ return `Error with item ${error.instancePath}: ${error.message}`;
+ });
+};
+
+export function toValidateJsonSchema(testData, validator) {
+ if (!(validator instanceof Function && validator.schema)) {
+ return {
+ validator,
+ message: () =>
+ 'Validator must be a validating function with property "schema", created with `ajv.compile`. See https://ajv.js.org/api.html#ajv-compile-schema-object-data-any-boolean-promise-any.',
+ pass: false,
+ };
+ }
+
+ const isValid = validator(testData);
+
+ return {
+ actual: testData,
+ message: () => {
+ if (isValid) {
+ // We can match, but still fail because we're in a `expect...not.` context
+ return 'Expected the given data not to pass the schema validation, but found that it was considered valid.';
+ }
+
+ const errorMessages = getAjvErrorMessage(validator).join('\n');
+ return `Expected the given data to pass the schema validation, but found that it was considered invalid. Errors:\n${errorMessages}`;
+ },
+ pass: isValid,
+ };
+}
diff --git a/spec/frontend/__helpers__/matchers/to_validate_json_schema_spec.js b/spec/frontend/__helpers__/matchers/to_validate_json_schema_spec.js
new file mode 100644
index 00000000000..fd42c710c65
--- /dev/null
+++ b/spec/frontend/__helpers__/matchers/to_validate_json_schema_spec.js
@@ -0,0 +1,65 @@
+import Ajv from 'ajv';
+import AjvFormats from 'ajv-formats';
+
+const JSON_SCHEMA = {
+ type: 'object',
+ properties: {
+ fruit: {
+ type: 'string',
+ minLength: 3,
+ },
+ },
+};
+
+const ajv = new Ajv({
+ strictTypes: false,
+ strictTuples: false,
+ allowMatchingProperties: true,
+});
+
+AjvFormats(ajv);
+const schema = ajv.compile(JSON_SCHEMA);
+
+describe('custom matcher toValidateJsonSchema', () => {
+ it('throws error if validator is not compiled correctly', () => {
+ expect(() => {
+ expect({}).toValidateJsonSchema({});
+ }).toThrow(
+ 'Validator must be a validating function with property "schema", created with `ajv.compile`. See https://ajv.js.org/api.html#ajv-compile-schema-object-data-any-boolean-promise-any.',
+ );
+ });
+
+ describe('positive assertions', () => {
+ it.each`
+ description | input
+ ${'valid input'} | ${{ fruit: 'apple' }}
+ `('schema validation passes for $description', ({ input }) => {
+ expect(input).toValidateJsonSchema(schema);
+ });
+
+ it('throws if not matching', () => {
+ expect(() => expect(null).toValidateJsonSchema(schema)).toThrowError(
+ `Expected the given data to pass the schema validation, but found that it was considered invalid. Errors:
+Error with item : must be object`,
+ );
+ });
+ });
+
+ describe('negative assertions', () => {
+ it.each`
+ description | input
+ ${'no input'} | ${null}
+ ${'input with invalid type'} | ${'banana'}
+ ${'input with invalid length'} | ${{ fruit: 'aa' }}
+ ${'input with invalid type'} | ${{ fruit: 12345 }}
+ `('schema validation fails for $description', ({ input }) => {
+ expect(input).not.toValidateJsonSchema(schema);
+ });
+
+ it('throws if matching', () => {
+ expect(() => expect({ fruit: 'apple' }).not.toValidateJsonSchema(schema)).toThrowError(
+ 'Expected the given data not to pass the schema validation, but found that it was considered valid.',
+ );
+ });
+ });
+});
diff --git a/spec/frontend/__helpers__/mock_apollo_helper.js b/spec/frontend/__helpers__/mock_apollo_helper.js
index c07a6d8ef85..bae9f33be87 100644
--- a/spec/frontend/__helpers__/mock_apollo_helper.js
+++ b/spec/frontend/__helpers__/mock_apollo_helper.js
@@ -1,7 +1,7 @@
import { InMemoryCache } from '@apollo/client/core';
import { createMockClient as createMockApolloClient } from 'mock-apollo-client';
import VueApollo from 'vue-apollo';
-import possibleTypes from '~/graphql_shared/possibleTypes.json';
+import possibleTypes from '~/graphql_shared/possible_types.json';
import { typePolicies } from '~/lib/graphql';
export function createMockClient(handlers = [], resolvers = {}, cacheOptions = {}) {
diff --git a/spec/frontend/__helpers__/mock_dom_observer.js b/spec/frontend/__helpers__/mock_dom_observer.js
index dd26b594ad9..bc2646be4c2 100644
--- a/spec/frontend/__helpers__/mock_dom_observer.js
+++ b/spec/frontend/__helpers__/mock_dom_observer.js
@@ -22,14 +22,14 @@ class MockObserver {
takeRecords() {}
- // eslint-disable-next-line babel/camelcase
+ // eslint-disable-next-line camelcase
$_triggerObserve(node, { entry = {}, options = {} } = {}) {
if (this.$_hasObserver(node, options)) {
this.$_cb([{ target: node, ...entry }]);
}
}
- // eslint-disable-next-line babel/camelcase
+ // eslint-disable-next-line camelcase
$_hasObserver(node, options = {}) {
return this.$_observers.some(
([obvNode, obvOptions]) => node === obvNode && isMatch(options, obvOptions),
diff --git a/spec/frontend/__helpers__/vuex_action_helper.js b/spec/frontend/__helpers__/vuex_action_helper.js
index 68203b544ef..95a811d0385 100644
--- a/spec/frontend/__helpers__/vuex_action_helper.js
+++ b/spec/frontend/__helpers__/vuex_action_helper.js
@@ -49,6 +49,7 @@ const noop = () => {};
* expectedActions: [],
* })
*/
+
export default (
actionArg,
payloadArg,
diff --git a/spec/frontend/__helpers__/yaml_transformer.js b/spec/frontend/__helpers__/yaml_transformer.js
new file mode 100644
index 00000000000..a23f9b1f715
--- /dev/null
+++ b/spec/frontend/__helpers__/yaml_transformer.js
@@ -0,0 +1,11 @@
+/* eslint-disable import/no-commonjs */
+const JsYaml = require('js-yaml');
+
+// This will transform YAML files to JSON strings
+module.exports = {
+ process: (sourceContent) => {
+ const jsonContent = JsYaml.load(sourceContent);
+ const json = JSON.stringify(jsonContent);
+ return `module.exports = ${json}`;
+ },
+};
diff --git a/spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap b/spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap
index dd742419d32..36003154b58 100644
--- a/spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap
+++ b/spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap
@@ -8,7 +8,7 @@ exports[`~/access_tokens/components/expires_at_field should render datepicker wi
optionaltext="(optional)"
>
<gl-datepicker-stub
- ariallabel=""
+ arialabel=""
autocomplete=""
container=""
displayfield="true"
diff --git a/spec/frontend/add_context_commits_modal/store/actions_spec.js b/spec/frontend/add_context_commits_modal/store/actions_spec.js
index fa4d52cbfbb..4b58a69c2b8 100644
--- a/spec/frontend/add_context_commits_modal/store/actions_spec.js
+++ b/spec/frontend/add_context_commits_modal/store/actions_spec.js
@@ -42,9 +42,9 @@ describe('AddContextCommitsModalStoreActions', () => {
});
describe('setBaseConfig', () => {
- it('commits SET_BASE_CONFIG', (done) => {
+ it('commits SET_BASE_CONFIG', () => {
const options = { contextCommitsPath, mergeRequestIid, projectId };
- testAction(
+ return testAction(
setBaseConfig,
options,
{
@@ -59,62 +59,54 @@ describe('AddContextCommitsModalStoreActions', () => {
},
],
[],
- done,
);
});
});
describe('setTabIndex', () => {
- it('commits SET_TABINDEX', (done) => {
- testAction(
+ it('commits SET_TABINDEX', () => {
+ return testAction(
setTabIndex,
{ tabIndex: 1 },
{ tabIndex: 0 },
[{ type: types.SET_TABINDEX, payload: { tabIndex: 1 } }],
[],
- done,
);
});
});
describe('setCommits', () => {
- it('commits SET_COMMITS', (done) => {
- testAction(
+ it('commits SET_COMMITS', () => {
+ return testAction(
setCommits,
{ commits: [], silentAddition: false },
{ isLoadingCommits: false, commits: [] },
[{ type: types.SET_COMMITS, payload: [] }],
[],
- done,
);
});
- it('commits SET_COMMITS_SILENT', (done) => {
- testAction(
+ it('commits SET_COMMITS_SILENT', () => {
+ return testAction(
setCommits,
{ commits: [], silentAddition: true },
{ isLoadingCommits: true, commits: [] },
[{ type: types.SET_COMMITS_SILENT, payload: [] }],
[],
- done,
);
});
});
describe('createContextCommits', () => {
- it('calls API to create context commits', (done) => {
+ it('calls API to create context commits', async () => {
mock.onPost(contextCommitEndpoint).reply(200, {});
- testAction(createContextCommits, { commits: [] }, {}, [], [], done);
+ await testAction(createContextCommits, { commits: [] }, {}, [], []);
- createContextCommits(
+ await createContextCommits(
{ state: { projectId, mergeRequestIid }, commit: () => null },
{ commits: [] },
- )
- .then(() => {
- done();
- })
- .catch(done.fail);
+ );
});
});
@@ -126,9 +118,9 @@ describe('AddContextCommitsModalStoreActions', () => {
)
.reply(200, [dummyCommit]);
});
- it('commits FETCH_CONTEXT_COMMITS', (done) => {
+ it('commits FETCH_CONTEXT_COMMITS', () => {
const contextCommit = { ...dummyCommit, isSelected: true };
- testAction(
+ return testAction(
fetchContextCommits,
null,
{
@@ -144,20 +136,18 @@ describe('AddContextCommitsModalStoreActions', () => {
{ type: 'setCommits', payload: { commits: [contextCommit], silentAddition: true } },
{ type: 'setSelectedCommits', payload: [contextCommit] },
],
- done,
);
});
});
describe('setContextCommits', () => {
- it('commits SET_CONTEXT_COMMITS', (done) => {
- testAction(
+ it('commits SET_CONTEXT_COMMITS', () => {
+ return testAction(
setContextCommits,
{ data: [] },
{ contextCommits: [], isLoadingContextCommits: false },
[{ type: types.SET_CONTEXT_COMMITS, payload: { data: [] } }],
[],
- done,
);
});
});
@@ -168,71 +158,66 @@ describe('AddContextCommitsModalStoreActions', () => {
.onDelete('/api/v4/projects/gitlab-org%2Fgitlab/merge_requests/1/context_commits')
.reply(204);
});
- it('calls API to remove context commits', (done) => {
- testAction(
+ it('calls API to remove context commits', () => {
+ return testAction(
removeContextCommits,
{ forceReload: false },
{ mergeRequestIid, projectId, toRemoveCommits: [] },
[],
[],
- done,
);
});
});
describe('setSelectedCommits', () => {
- it('commits SET_SELECTED_COMMITS', (done) => {
- testAction(
+ it('commits SET_SELECTED_COMMITS', () => {
+ return testAction(
setSelectedCommits,
[dummyCommit],
{ selectedCommits: [] },
[{ type: types.SET_SELECTED_COMMITS, payload: [dummyCommit] }],
[],
- done,
);
});
});
describe('setSearchText', () => {
- it('commits SET_SEARCH_TEXT', (done) => {
+ it('commits SET_SEARCH_TEXT', () => {
const searchText = 'Dummy Text';
- testAction(
+ return testAction(
setSearchText,
searchText,
{ searchText: '' },
[{ type: types.SET_SEARCH_TEXT, payload: searchText }],
[],
- done,
);
});
});
describe('setToRemoveCommits', () => {
- it('commits SET_TO_REMOVE_COMMITS', (done) => {
+ it('commits SET_TO_REMOVE_COMMITS', () => {
const commitId = 'abcde';
- testAction(
+ return testAction(
setToRemoveCommits,
[commitId],
{ toRemoveCommits: [] },
[{ type: types.SET_TO_REMOVE_COMMITS, payload: [commitId] }],
[],
- done,
);
});
});
describe('resetModalState', () => {
- it('commits RESET_MODAL_STATE', (done) => {
+ it('commits RESET_MODAL_STATE', () => {
const commitId = 'abcde';
- testAction(
+ return testAction(
resetModalState,
null,
{ toRemoveCommits: [commitId] },
[{ type: types.RESET_MODAL_STATE }],
[],
- done,
);
});
});
diff --git a/spec/frontend/admin/statistics_panel/store/actions_spec.js b/spec/frontend/admin/statistics_panel/store/actions_spec.js
index c7481b664b3..e7cdb5feb6a 100644
--- a/spec/frontend/admin/statistics_panel/store/actions_spec.js
+++ b/spec/frontend/admin/statistics_panel/store/actions_spec.js
@@ -22,8 +22,8 @@ describe('Admin statistics panel actions', () => {
mock.onGet(/api\/(.*)\/application\/statistics/).replyOnce(200, mockStatistics);
});
- it('dispatches success with received data', (done) =>
- testAction(
+ it('dispatches success with received data', () => {
+ return testAction(
actions.fetchStatistics,
null,
state,
@@ -37,8 +37,8 @@ describe('Admin statistics panel actions', () => {
),
},
],
- done,
- ));
+ );
+ });
});
describe('error', () => {
@@ -46,8 +46,8 @@ describe('Admin statistics panel actions', () => {
mock.onGet(/api\/(.*)\/application\/statistics/).replyOnce(500);
});
- it('dispatches error', (done) =>
- testAction(
+ it('dispatches error', () => {
+ return testAction(
actions.fetchStatistics,
null,
state,
@@ -61,26 +61,26 @@ describe('Admin statistics panel actions', () => {
payload: new Error('Request failed with status code 500'),
},
],
- done,
- ));
+ );
+ });
});
});
describe('requestStatistic', () => {
- it('should commit the request mutation', (done) =>
- testAction(
+ it('should commit the request mutation', () => {
+ return testAction(
actions.requestStatistics,
null,
state,
[{ type: types.REQUEST_STATISTICS }],
[],
- done,
- ));
+ );
+ });
});
describe('receiveStatisticsSuccess', () => {
- it('should commit received data', (done) =>
- testAction(
+ it('should commit received data', () => {
+ return testAction(
actions.receiveStatisticsSuccess,
mockStatistics,
state,
@@ -91,13 +91,13 @@ describe('Admin statistics panel actions', () => {
},
],
[],
- done,
- ));
+ );
+ });
});
describe('receiveStatisticsError', () => {
- it('should commit error', (done) => {
- testAction(
+ it('should commit error', () => {
+ return testAction(
actions.receiveStatisticsError,
500,
state,
@@ -108,7 +108,6 @@ describe('Admin statistics panel actions', () => {
},
],
[],
- done,
);
});
});
diff --git a/spec/frontend/admin/topics/components/remove_avatar_spec.js b/spec/frontend/admin/topics/components/remove_avatar_spec.js
index d4656f0a199..97d257c682c 100644
--- a/spec/frontend/admin/topics/components/remove_avatar_spec.js
+++ b/spec/frontend/admin/topics/components/remove_avatar_spec.js
@@ -1,10 +1,11 @@
-import { GlButton, GlModal } from '@gitlab/ui';
+import { GlButton, GlModal, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import RemoveAvatar from '~/admin/topics/components/remove_avatar.vue';
const modalID = 'fake-id';
const path = 'topic/path/1';
+const name = 'Topic 1';
jest.mock('lodash/uniqueId', () => () => 'fake-id');
jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
@@ -16,10 +17,14 @@ describe('RemoveAvatar', () => {
wrapper = shallowMount(RemoveAvatar, {
provide: {
path,
+ name,
},
directives: {
GlModal: createMockDirective(),
},
+ stubs: {
+ GlSprintf,
+ },
});
};
@@ -55,8 +60,8 @@ describe('RemoveAvatar', () => {
const modal = findModal();
expect(modal.exists()).toBe(true);
- expect(modal.props('title')).toBe('Confirm remove avatar');
- expect(modal.text()).toBe('Avatar will be removed. Are you sure?');
+ expect(modal.props('title')).toBe('Remove topic avatar');
+ expect(modal.text()).toBe(`Topic avatar for ${name} will be removed. This cannot be undone.`);
});
it('contains the correct modal ID', () => {
diff --git a/spec/frontend/admin/users/components/actions/actions_spec.js b/spec/frontend/admin/users/components/actions/actions_spec.js
index fa485e73999..b758c15a91a 100644
--- a/spec/frontend/admin/users/components/actions/actions_spec.js
+++ b/spec/frontend/admin/users/components/actions/actions_spec.js
@@ -1,9 +1,9 @@
import { GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import { nextTick } from 'vue';
-import { kebabCase } from 'lodash';
import Actions from '~/admin/users/components/actions';
-import SharedDeleteAction from '~/admin/users/components/actions/shared/shared_delete_action.vue';
+import eventHub, {
+ EVENT_OPEN_DELETE_USER_MODAL,
+} from '~/admin/users/components/modals/delete_user_modal_event_hub';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { OBSTACLE_TYPES } from '~/vue_shared/components/user_deletion_obstacles/constants';
import { CONFIRMATION_ACTIONS, DELETE_ACTIONS } from '../../constants';
@@ -14,12 +14,11 @@ describe('Action components', () => {
const findDropdownItem = () => wrapper.find(GlDropdownItem);
- const initComponent = ({ component, props, stubs = {} } = {}) => {
+ const initComponent = ({ component, props } = {}) => {
wrapper = shallowMount(component, {
propsData: {
...props,
},
- stubs,
});
};
@@ -29,7 +28,7 @@ describe('Action components', () => {
});
describe('CONFIRMATION_ACTIONS', () => {
- it.each(CONFIRMATION_ACTIONS)('renders a dropdown item for "%s"', async (action) => {
+ it.each(CONFIRMATION_ACTIONS)('renders a dropdown item for "%s"', (action) => {
initComponent({
component: Actions[capitalizeFirstCharacter(action)],
props: {
@@ -38,20 +37,23 @@ describe('Action components', () => {
},
});
- await nextTick();
expect(findDropdownItem().exists()).toBe(true);
});
});
describe('DELETE_ACTION_COMPONENTS', () => {
+ beforeEach(() => {
+ jest.spyOn(eventHub, '$emit').mockImplementation();
+ });
+
const userDeletionObstacles = [
{ name: 'schedule1', type: OBSTACLE_TYPES.oncallSchedules },
{ name: 'policy1', type: OBSTACLE_TYPES.escalationPolicies },
];
- it.each(DELETE_ACTIONS.map((action) => [action, paths[action]]))(
- 'renders a dropdown item for "%s"',
- async (action, expectedPath) => {
+ it.each(DELETE_ACTIONS)(
+ 'renders a dropdown item that opens the delete user modal when clicked for "%s"',
+ async (action) => {
initComponent({
component: Actions[capitalizeFirstCharacter(action)],
props: {
@@ -59,21 +61,19 @@ describe('Action components', () => {
paths,
userDeletionObstacles,
},
- stubs: { SharedDeleteAction },
});
- await nextTick();
- const sharedAction = wrapper.find(SharedDeleteAction);
+ await findDropdownItem().vm.$emit('click');
- expect(sharedAction.attributes('data-block-user-url')).toBe(paths.block);
- expect(sharedAction.attributes('data-delete-user-url')).toBe(expectedPath);
- expect(sharedAction.attributes('data-gl-modal-action')).toBe(kebabCase(action));
- expect(sharedAction.attributes('data-username')).toBe('John Doe');
- expect(sharedAction.attributes('data-user-deletion-obstacles')).toBe(
- JSON.stringify(userDeletionObstacles),
+ expect(eventHub.$emit).toHaveBeenCalledWith(
+ EVENT_OPEN_DELETE_USER_MODAL,
+ expect.objectContaining({
+ username: 'John Doe',
+ blockPath: paths.block,
+ deletePath: paths[action],
+ userDeletionObstacles,
+ }),
);
-
- expect(findDropdownItem().exists()).toBe(true);
},
);
});
diff --git a/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap b/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap
index 7a17ef2cc6c..265569ac0e3 100644
--- a/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap
+++ b/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap
@@ -1,160 +1,28 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`User Operation confirmation modal renders modal with form included 1`] = `
-<div>
- <p>
- <gl-sprintf-stub
- message="content"
- />
- </p>
-
- <user-deletion-obstacles-list-stub
- obstacles="schedule1,policy1"
- username="username"
+exports[`Delete user modal renders modal with form included 1`] = `
+<form
+ action=""
+ method="post"
+>
+ <input
+ name="_method"
+ type="hidden"
+ value="delete"
/>
- <p>
- <gl-sprintf-stub
- message="To confirm, type %{username}"
- />
- </p>
-
- <form
- action="delete-url"
- method="post"
- >
- <input
- name="_method"
- type="hidden"
- value="delete"
- />
-
- <input
- name="authenticity_token"
- type="hidden"
- value="csrf"
- />
-
- <gl-form-input-stub
- autocomplete="off"
- autofocus=""
- name="username"
- type="text"
- value=""
- />
- </form>
- <gl-button-stub
- buttontextclasses=""
- category="primary"
- icon=""
- size="medium"
- variant="default"
- >
- Cancel
- </gl-button-stub>
-
- <gl-button-stub
- buttontextclasses=""
- category="secondary"
- disabled="true"
- icon=""
- size="medium"
- variant="danger"
- >
-
- secondaryAction
-
- </gl-button-stub>
-
- <gl-button-stub
- buttontextclasses=""
- category="primary"
- disabled="true"
- icon=""
- size="medium"
- variant="danger"
- >
- action
- </gl-button-stub>
-</div>
-`;
-
-exports[`User Operation confirmation modal when user's name has leading and trailing whitespace displays user's name without whitespace 1`] = `
-<div>
- <p>
- content
- </p>
-
- <user-deletion-obstacles-list-stub
- obstacles="schedule1,policy1"
- username="John Smith"
+ <input
+ name="authenticity_token"
+ type="hidden"
+ value="csrf"
/>
- <p>
- To confirm, type
- <code
- class="gl-white-space-pre-wrap"
- >
- John Smith
- </code>
- </p>
-
- <form
- action="delete-url"
- method="post"
- >
- <input
- name="_method"
- type="hidden"
- value="delete"
- />
-
- <input
- name="authenticity_token"
- type="hidden"
- value="csrf"
- />
-
- <gl-form-input-stub
- autocomplete="off"
- autofocus=""
- name="username"
- type="text"
- value=""
- />
- </form>
- <gl-button-stub
- buttontextclasses=""
- category="primary"
- icon=""
- size="medium"
- variant="default"
- >
- Cancel
- </gl-button-stub>
-
- <gl-button-stub
- buttontextclasses=""
- category="secondary"
- disabled="true"
- icon=""
- size="medium"
- variant="danger"
- >
-
- secondaryAction
-
- </gl-button-stub>
-
- <gl-button-stub
- buttontextclasses=""
- category="primary"
- disabled="true"
- icon=""
- size="medium"
- variant="danger"
- >
- action
- </gl-button-stub>
-</div>
+ <gl-form-input-stub
+ autocomplete="off"
+ autofocus=""
+ name="username"
+ type="text"
+ value=""
+ />
+</form>
`;
diff --git a/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js b/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js
index f875cd24ee1..09a345ac826 100644
--- a/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js
+++ b/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js
@@ -1,6 +1,8 @@
import { GlButton, GlFormInput, GlSprintf } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import { nextTick } from 'vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import eventHub, {
+ EVENT_OPEN_DELETE_USER_MODAL,
+} from '~/admin/users/components/modals/delete_user_modal_event_hub';
import DeleteUserModal from '~/admin/users/components/modals/delete_user_modal.vue';
import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue';
import ModalStub from './stubs/modal_stub';
@@ -9,7 +11,7 @@ const TEST_DELETE_USER_URL = 'delete-url';
const TEST_BLOCK_USER_URL = 'block-url';
const TEST_CSRF = 'csrf';
-describe('User Operation confirmation modal', () => {
+describe('Delete user modal', () => {
let wrapper;
let formSubmitSpy;
@@ -27,28 +29,36 @@ describe('User Operation confirmation modal', () => {
const getMethodParam = () => new FormData(findForm().element).get('_method');
const getFormAction = () => findForm().attributes('action');
const findUserDeletionObstaclesList = () => wrapper.findComponent(UserDeletionObstaclesList);
+ const findMessageUsername = () => wrapper.findByTestId('message-username');
+ const findConfirmUsername = () => wrapper.findByTestId('confirm-username');
+ const emitOpenModalEvent = (modalData) => {
+ return eventHub.$emit(EVENT_OPEN_DELETE_USER_MODAL, modalData);
+ };
const setUsername = (username) => {
- findUsernameInput().vm.$emit('input', username);
+ return findUsernameInput().vm.$emit('input', username);
};
const username = 'username';
const badUsername = 'bad_username';
- const userDeletionObstacles = '["schedule1", "policy1"]';
+ const userDeletionObstacles = ['schedule1', 'policy1'];
+
+ const mockModalData = {
+ username,
+ blockPath: TEST_BLOCK_USER_URL,
+ deletePath: TEST_DELETE_USER_URL,
+ userDeletionObstacles,
+ i18n: {
+ title: 'Modal for %{username}',
+ primaryButtonLabel: 'Delete user',
+ messageBody: 'Delete %{username} or rather %{strongStart}block user%{strongEnd}?',
+ },
+ };
- const createComponent = (props = {}, stubs = {}) => {
- wrapper = shallowMount(DeleteUserModal, {
+ const createComponent = (stubs = {}) => {
+ wrapper = shallowMountExtended(DeleteUserModal, {
propsData: {
- username,
- title: 'title',
- content: 'content',
- action: 'action',
- secondaryAction: 'secondaryAction',
- deleteUserUrl: TEST_DELETE_USER_URL,
- blockUserUrl: TEST_BLOCK_USER_URL,
csrfToken: TEST_CSRF,
- userDeletionObstacles,
- ...props,
},
stubs: {
GlModal: ModalStub,
@@ -68,7 +78,7 @@ describe('User Operation confirmation modal', () => {
it('renders modal with form included', () => {
createComponent();
- expect(wrapper.element).toMatchSnapshot();
+ expect(findForm().element).toMatchSnapshot();
});
describe('on created', () => {
@@ -83,11 +93,11 @@ describe('User Operation confirmation modal', () => {
});
describe('with incorrect username', () => {
- beforeEach(async () => {
+ beforeEach(() => {
createComponent();
- setUsername(badUsername);
+ emitOpenModalEvent(mockModalData);
- await nextTick();
+ return setUsername(badUsername);
});
it('shows incorrect username', () => {
@@ -101,11 +111,11 @@ describe('User Operation confirmation modal', () => {
});
describe('with correct username', () => {
- beforeEach(async () => {
+ beforeEach(() => {
createComponent();
- setUsername(username);
+ emitOpenModalEvent(mockModalData);
- await nextTick();
+ return setUsername(username);
});
it('shows correct username', () => {
@@ -117,11 +127,9 @@ describe('User Operation confirmation modal', () => {
expect(findSecondaryButton().attributes('disabled')).toBeFalsy();
});
- describe('when primary action is submitted', () => {
- beforeEach(async () => {
- findPrimaryButton().vm.$emit('click');
-
- await nextTick();
+ describe('when primary action is clicked', () => {
+ beforeEach(() => {
+ return findPrimaryButton().vm.$emit('click');
});
it('clears the input', () => {
@@ -136,11 +144,9 @@ describe('User Operation confirmation modal', () => {
});
});
- describe('when secondary action is submitted', () => {
- beforeEach(async () => {
- findSecondaryButton().vm.$emit('click');
-
- await nextTick();
+ describe('when secondary action is clicked', () => {
+ beforeEach(() => {
+ return findSecondaryButton().vm.$emit('click');
});
it('has correct form attributes and calls submit', () => {
@@ -154,22 +160,23 @@ describe('User Operation confirmation modal', () => {
describe("when user's name has leading and trailing whitespace", () => {
beforeEach(() => {
- createComponent(
- {
- username: ' John Smith ',
- },
- { GlSprintf },
- );
+ createComponent({ GlSprintf });
+ return emitOpenModalEvent({ ...mockModalData, username: ' John Smith ' });
});
it("displays user's name without whitespace", () => {
- expect(wrapper.element).toMatchSnapshot();
+ expect(findMessageUsername().text()).toBe('John Smith');
+ expect(findConfirmUsername().text()).toBe('John Smith');
});
- it("shows enabled buttons when user's name is entered without whitespace", async () => {
- setUsername('John Smith');
+ it('passes user name without whitespace to the obstacles', () => {
+ expect(findUserDeletionObstaclesList().props()).toMatchObject({
+ userName: 'John Smith',
+ });
+ });
- await nextTick();
+ it("shows enabled buttons when user's name is entered without whitespace", async () => {
+ await setUsername('John Smith');
expect(findPrimaryButton().attributes('disabled')).toBeUndefined();
expect(findSecondaryButton().attributes('disabled')).toBeUndefined();
@@ -177,17 +184,20 @@ describe('User Operation confirmation modal', () => {
});
describe('Related user-deletion-obstacles list', () => {
- it('does NOT render the list when user has no related obstacles', () => {
- createComponent({ userDeletionObstacles: '[]' });
+ it('does NOT render the list when user has no related obstacles', async () => {
+ createComponent();
+ await emitOpenModalEvent({ ...mockModalData, userDeletionObstacles: [] });
+
expect(findUserDeletionObstaclesList().exists()).toBe(false);
});
- it('renders the list when user has related obstalces', () => {
+ it('renders the list when user has related obstalces', async () => {
createComponent();
+ await emitOpenModalEvent(mockModalData);
const obstacles = findUserDeletionObstaclesList();
expect(obstacles.exists()).toBe(true);
- expect(obstacles.props('obstacles')).toEqual(JSON.parse(userDeletionObstacles));
+ expect(obstacles.props('obstacles')).toEqual(userDeletionObstacles);
});
});
});
diff --git a/spec/frontend/admin/users/components/modals/user_modal_manager_spec.js b/spec/frontend/admin/users/components/modals/user_modal_manager_spec.js
deleted file mode 100644
index 4786357faa1..00000000000
--- a/spec/frontend/admin/users/components/modals/user_modal_manager_spec.js
+++ /dev/null
@@ -1,126 +0,0 @@
-import { mount } from '@vue/test-utils';
-import { nextTick } from 'vue';
-import UserModalManager from '~/admin/users/components/modals/user_modal_manager.vue';
-import ModalStub from './stubs/modal_stub';
-
-describe('Users admin page Modal Manager', () => {
- let wrapper;
-
- const modalConfiguration = {
- action1: {
- title: 'action1',
- content: 'Action Modal 1',
- },
- action2: {
- title: 'action2',
- content: 'Action Modal 2',
- },
- };
-
- const findModal = () => wrapper.find({ ref: 'modal' });
-
- const createComponent = (props = {}) => {
- wrapper = mount(UserModalManager, {
- propsData: {
- selector: '.js-delete-user-modal-button',
- modalConfiguration,
- csrfToken: 'dummyCSRF',
- ...props,
- },
- stubs: {
- DeleteUserModal: ModalStub,
- },
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
- describe('render behavior', () => {
- it('does not renders modal when initialized', () => {
- createComponent();
- expect(findModal().exists()).toBeFalsy();
- });
-
- it('throws if action has no proper configuration', () => {
- createComponent({
- modalConfiguration: {},
- });
- expect(() => wrapper.vm.show({ glModalAction: 'action1' })).toThrow();
- });
-
- it('renders modal with expected props when valid configuration is passed', async () => {
- createComponent();
- wrapper.vm.show({
- glModalAction: 'action1',
- extraProp: 'extraPropValue',
- });
-
- await nextTick();
- const modal = findModal();
- expect(modal.exists()).toBeTruthy();
- expect(modal.vm.$attrs.csrfToken).toEqual('dummyCSRF');
- expect(modal.vm.$attrs.extraProp).toEqual('extraPropValue');
- expect(modal.vm.showWasCalled).toBeTruthy();
- });
- });
-
- describe('click handling', () => {
- let button;
- let button2;
-
- const createButtons = () => {
- button = document.createElement('button');
- button2 = document.createElement('button');
- button.setAttribute('class', 'js-delete-user-modal-button');
- button.setAttribute('data-username', 'foo');
- button.setAttribute('data-gl-modal-action', 'action1');
- button.setAttribute('data-block-user-url', '/block');
- button.setAttribute('data-delete-user-url', '/delete');
- document.body.appendChild(button);
- document.body.appendChild(button2);
- };
- const removeButtons = () => {
- button.remove();
- button = null;
- button2.remove();
- button2 = null;
- };
-
- beforeEach(() => {
- createButtons();
- createComponent();
- });
-
- afterEach(() => {
- removeButtons();
- });
-
- it('renders the modal when the button is clicked', async () => {
- button.click();
-
- await nextTick();
-
- expect(findModal().exists()).toBe(true);
- });
-
- it('does not render the modal when a misconfigured button is clicked', async () => {
- button.removeAttribute('data-gl-modal-action');
- button.click();
-
- await nextTick();
-
- expect(findModal().exists()).toBe(false);
- });
-
- it('does not render the modal when a button without the selector class is clicked', async () => {
- button2.click();
-
- await nextTick();
-
- expect(findModal().exists()).toBe(false);
- });
- });
-});
diff --git a/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js
index 6193233881d..ed185c11732 100644
--- a/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js
+++ b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js
@@ -476,9 +476,6 @@ describe('AlertsSettingsWrapper', () => {
destroyHttpIntegration(wrapper);
expect(destroyIntegrationHandler).toHaveBeenCalled();
- await waitForPromises();
-
- expect(findIntegrations()).toHaveLength(3);
});
it('displays flash if mutation had a recoverable error', async () => {
diff --git a/spec/frontend/alerts_settings/components/mocks/apollo_mock.js b/spec/frontend/alerts_settings/components/mocks/apollo_mock.js
index 694dff56632..170af1b5e0c 100644
--- a/spec/frontend/alerts_settings/components/mocks/apollo_mock.js
+++ b/spec/frontend/alerts_settings/components/mocks/apollo_mock.js
@@ -102,7 +102,7 @@ export const destroyIntegrationResponse = {
httpIntegrationDestroy: {
errors: [],
integration: {
- __typename: 'AlertManagementIntegration',
+ __typename: 'AlertManagementHttpIntegration',
id: '37',
type: 'HTTP',
active: true,
diff --git a/spec/frontend/api/alert_management_alerts_api_spec.js b/spec/frontend/api/alert_management_alerts_api_spec.js
new file mode 100644
index 00000000000..aac14e64286
--- /dev/null
+++ b/spec/frontend/api/alert_management_alerts_api_spec.js
@@ -0,0 +1,140 @@
+import MockAdapter from 'axios-mock-adapter';
+import * as alertManagementAlertsApi from '~/api/alert_management_alerts_api';
+import axios from '~/lib/utils/axios_utils';
+
+describe('~/api/alert_management_alerts_api.js', () => {
+ let mock;
+ let originalGon;
+
+ const projectId = 1;
+ const alertIid = 2;
+
+ const imageData = { filePath: 'test', filename: 'hello', id: 5, url: null };
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+
+ originalGon = window.gon;
+ window.gon = { api_version: 'v4' };
+ });
+
+ afterEach(() => {
+ mock.restore();
+ window.gon = originalGon;
+ });
+
+ describe('fetchAlertMetricImages', () => {
+ beforeEach(() => {
+ jest.spyOn(axios, 'get');
+ });
+
+ it('retrieves metric images from the correct URL and returns them in the response data', () => {
+ const expectedUrl = `/api/v4/projects/${projectId}/alert_management_alerts/${alertIid}/metric_images`;
+ const expectedData = [imageData];
+ const options = { alertIid, id: projectId };
+
+ mock.onGet(expectedUrl).reply(200, { data: expectedData });
+
+ return alertManagementAlertsApi.fetchAlertMetricImages(options).then(({ data }) => {
+ expect(axios.get).toHaveBeenCalledWith(expectedUrl);
+ expect(data.data).toEqual(expectedData);
+ });
+ });
+ });
+
+ describe('uploadAlertMetricImage', () => {
+ beforeEach(() => {
+ jest.spyOn(axios, 'post');
+ });
+
+ it('uploads a metric image to the correct URL and returns it in the response data', () => {
+ const expectedUrl = `/api/v4/projects/${projectId}/alert_management_alerts/${alertIid}/metric_images`;
+ const expectedData = [imageData];
+
+ const file = new File(['zip contents'], 'hello');
+ const url = 'https://www.example.com';
+ const urlText = 'Example website';
+
+ const expectedFormData = new FormData();
+ expectedFormData.append('file', file);
+ expectedFormData.append('url', url);
+ expectedFormData.append('url_text', urlText);
+
+ mock.onPost(expectedUrl).reply(201, { data: expectedData });
+
+ return alertManagementAlertsApi
+ .uploadAlertMetricImage({
+ alertIid,
+ id: projectId,
+ file,
+ url,
+ urlText,
+ })
+ .then(({ data }) => {
+ expect(data).toEqual({ data: expectedData });
+ expect(axios.post).toHaveBeenCalledWith(expectedUrl, expectedFormData, {
+ headers: { 'Content-Type': 'multipart/form-data' },
+ });
+ });
+ });
+ });
+
+ describe('updateAlertMetricImage', () => {
+ beforeEach(() => {
+ jest.spyOn(axios, 'put');
+ });
+
+ it('updates a metric image to the correct URL and returns it in the response data', () => {
+ const imageIid = 3;
+ const expectedUrl = `/api/v4/projects/${projectId}/alert_management_alerts/${alertIid}/metric_images/${imageIid}`;
+ const expectedData = [imageData];
+
+ const url = 'https://www.example.com';
+ const urlText = 'Example website';
+
+ const expectedFormData = new FormData();
+ expectedFormData.append('url', url);
+ expectedFormData.append('url_text', urlText);
+
+ mock.onPut(expectedUrl).reply(200, { data: expectedData });
+
+ return alertManagementAlertsApi
+ .updateAlertMetricImage({
+ alertIid,
+ id: projectId,
+ imageId: imageIid,
+ url,
+ urlText,
+ })
+ .then(({ data }) => {
+ expect(data).toEqual({ data: expectedData });
+ expect(axios.put).toHaveBeenCalledWith(expectedUrl, expectedFormData);
+ });
+ });
+ });
+
+ describe('deleteAlertMetricImage', () => {
+ beforeEach(() => {
+ jest.spyOn(axios, 'delete');
+ });
+
+ it('deletes a metric image to the correct URL and returns it in the response data', () => {
+ const imageIid = 3;
+ const expectedUrl = `/api/v4/projects/${projectId}/alert_management_alerts/${alertIid}/metric_images/${imageIid}`;
+ const expectedData = [imageData];
+
+ mock.onDelete(expectedUrl).reply(204, { data: expectedData });
+
+ return alertManagementAlertsApi
+ .deleteAlertMetricImage({
+ alertIid,
+ id: projectId,
+ imageId: imageIid,
+ })
+ .then(({ data }) => {
+ expect(data).toEqual({ data: expectedData });
+ expect(axios.delete).toHaveBeenCalledWith(expectedUrl);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js
index bc3e12d3fc4..85332bf21d8 100644
--- a/spec/frontend/api_spec.js
+++ b/spec/frontend/api_spec.js
@@ -2,6 +2,9 @@ import MockAdapter from 'axios-mock-adapter';
import Api, { DEFAULT_PER_PAGE } from '~/api';
import axios from '~/lib/utils/axios_utils';
import httpStatus from '~/lib/utils/http_status';
+import createFlash from '~/flash';
+
+jest.mock('~/flash');
describe('Api', () => {
const dummyApiVersion = 'v3000';
@@ -155,66 +158,44 @@ describe('Api', () => {
});
describe('group', () => {
- it('fetches a group', (done) => {
+ it('fetches a group', () => {
const groupId = '123456';
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}`;
mock.onGet(expectedUrl).reply(httpStatus.OK, {
name: 'test',
});
- Api.group(groupId, (response) => {
- expect(response.name).toBe('test');
- done();
+ return new Promise((resolve) => {
+ Api.group(groupId, (response) => {
+ expect(response.name).toBe('test');
+ resolve();
+ });
});
});
});
describe('groupMembers', () => {
- it('fetches group members', (done) => {
+ it('fetches group members', () => {
const groupId = '54321';
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/members`;
const expectedData = [{ id: 7 }];
mock.onGet(expectedUrl).reply(httpStatus.OK, expectedData);
- Api.groupMembers(groupId)
- .then(({ data }) => {
- expect(data).toEqual(expectedData);
- })
- .then(done)
- .catch(done.fail);
- });
- });
-
- describe('addGroupMembersByUserId', () => {
- it('adds an existing User as a new Group Member by User ID', () => {
- const groupId = 1;
- const expectedUserId = 2;
- const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/1/members`;
- const params = {
- user_id: expectedUserId,
- access_level: 10,
- expires_at: undefined,
- };
-
- mock.onPost(expectedUrl).reply(200, {
- id: expectedUserId,
- state: 'active',
- });
-
- return Api.addGroupMembersByUserId(groupId, params).then(({ data }) => {
- expect(data.id).toBe(expectedUserId);
- expect(data.state).toBe('active');
+ return Api.groupMembers(groupId).then(({ data }) => {
+ expect(data).toEqual(expectedData);
});
});
});
- describe('inviteGroupMembersByEmail', () => {
+ describe('inviteGroupMembers', () => {
it('invites a new email address to create a new User and become a Group Member', () => {
const groupId = 1;
const email = 'email@example.com';
+ const userId = '1';
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/1/invitations`;
const params = {
email,
+ userId,
access_level: 10,
expires_at: undefined,
};
@@ -223,14 +204,14 @@ describe('Api', () => {
status: 'success',
});
- return Api.inviteGroupMembersByEmail(groupId, params).then(({ data }) => {
+ return Api.inviteGroupMembers(groupId, params).then(({ data }) => {
expect(data.status).toBe('success');
});
});
});
describe('groupMilestones', () => {
- it('fetches group milestones', (done) => {
+ it('fetches group milestones', () => {
const groupId = '16';
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/milestones`;
const expectedData = [
@@ -250,17 +231,14 @@ describe('Api', () => {
];
mock.onGet(expectedUrl).reply(httpStatus.OK, expectedData);
- Api.groupMilestones(groupId)
- .then(({ data }) => {
- expect(data).toEqual(expectedData);
- })
- .then(done)
- .catch(done.fail);
+ return Api.groupMilestones(groupId).then(({ data }) => {
+ expect(data).toEqual(expectedData);
+ });
});
});
describe('groups', () => {
- it('fetches groups', (done) => {
+ it('fetches groups', () => {
const query = 'dummy query';
const options = { unused: 'option' };
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups.json`;
@@ -270,16 +248,18 @@ describe('Api', () => {
},
]);
- Api.groups(query, options, (response) => {
- expect(response.length).toBe(1);
- expect(response[0].name).toBe('test');
- done();
+ return new Promise((resolve) => {
+ Api.groups(query, options, (response) => {
+ expect(response.length).toBe(1);
+ expect(response[0].name).toBe('test');
+ resolve();
+ });
});
});
});
describe('groupLabels', () => {
- it('fetches group labels', (done) => {
+ it('fetches group labels', () => {
const options = { params: { search: 'foo' } };
const expectedGroup = 'gitlab-org';
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${expectedGroup}/labels`;
@@ -290,18 +270,15 @@ describe('Api', () => {
},
]);
- Api.groupLabels(expectedGroup, options)
- .then((res) => {
- expect(res.length).toBe(1);
- expect(res[0].name).toBe('Foo Label');
- })
- .then(done)
- .catch(done.fail);
+ return Api.groupLabels(expectedGroup, options).then((res) => {
+ expect(res.length).toBe(1);
+ expect(res[0].name).toBe('Foo Label');
+ });
});
});
describe('namespaces', () => {
- it('fetches namespaces', (done) => {
+ it('fetches namespaces', () => {
const query = 'dummy query';
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/namespaces.json`;
mock.onGet(expectedUrl).reply(httpStatus.OK, [
@@ -310,16 +287,18 @@ describe('Api', () => {
},
]);
- Api.namespaces(query, (response) => {
- expect(response.length).toBe(1);
- expect(response[0].name).toBe('test');
- done();
+ return new Promise((resolve) => {
+ Api.namespaces(query, (response) => {
+ expect(response.length).toBe(1);
+ expect(response[0].name).toBe('test');
+ resolve();
+ });
});
});
});
describe('projects', () => {
- it('fetches projects with membership when logged in', (done) => {
+ it('fetches projects with membership when logged in', () => {
const query = 'dummy query';
const options = { unused: 'option' };
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects.json`;
@@ -330,14 +309,16 @@ describe('Api', () => {
},
]);
- Api.projects(query, options, (response) => {
- expect(response.length).toBe(1);
- expect(response[0].name).toBe('test');
- done();
+ return new Promise((resolve) => {
+ Api.projects(query, options, (response) => {
+ expect(response.length).toBe(1);
+ expect(response[0].name).toBe('test');
+ resolve();
+ });
});
});
- it('fetches projects without membership when not logged in', (done) => {
+ it('fetches projects without membership when not logged in', () => {
const query = 'dummy query';
const options = { unused: 'option' };
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects.json`;
@@ -347,31 +328,30 @@ describe('Api', () => {
},
]);
- Api.projects(query, options, (response) => {
- expect(response.length).toBe(1);
- expect(response[0].name).toBe('test');
- done();
+ return new Promise((resolve) => {
+ Api.projects(query, options, (response) => {
+ expect(response.length).toBe(1);
+ expect(response[0].name).toBe('test');
+ resolve();
+ });
});
});
});
describe('updateProject', () => {
- it('update a project with the given payload', (done) => {
+ it('update a project with the given payload', () => {
const projectPath = 'foo';
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}`;
mock.onPut(expectedUrl).reply(httpStatus.OK, { foo: 'bar' });
- Api.updateProject(projectPath, { foo: 'bar' })
- .then(({ data }) => {
- expect(data.foo).toBe('bar');
- done();
- })
- .catch(done.fail);
+ return Api.updateProject(projectPath, { foo: 'bar' }).then(({ data }) => {
+ expect(data.foo).toBe('bar');
+ });
});
});
describe('projectUsers', () => {
- it('fetches all users of a particular project', (done) => {
+ it('fetches all users of a particular project', () => {
const query = 'dummy query';
const options = { unused: 'option' };
const projectPath = 'gitlab-org%2Fgitlab-ce';
@@ -382,13 +362,10 @@ describe('Api', () => {
},
]);
- Api.projectUsers('gitlab-org/gitlab-ce', query, options)
- .then((response) => {
- expect(response.length).toBe(1);
- expect(response[0].name).toBe('test');
- })
- .then(done)
- .catch(done.fail);
+ return Api.projectUsers('gitlab-org/gitlab-ce', query, options).then((response) => {
+ expect(response.length).toBe(1);
+ expect(response[0].name).toBe('test');
+ });
});
});
@@ -396,38 +373,32 @@ describe('Api', () => {
const projectPath = 'abc';
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests`;
- it('fetches all merge requests for a project', (done) => {
+ it('fetches all merge requests for a project', () => {
const mockData = [{ source_branch: 'foo' }, { source_branch: 'bar' }];
mock.onGet(expectedUrl).reply(httpStatus.OK, mockData);
- Api.projectMergeRequests(projectPath)
- .then(({ data }) => {
- expect(data.length).toEqual(2);
- expect(data[0].source_branch).toBe('foo');
- expect(data[1].source_branch).toBe('bar');
- })
- .then(done)
- .catch(done.fail);
+ return Api.projectMergeRequests(projectPath).then(({ data }) => {
+ expect(data.length).toEqual(2);
+ expect(data[0].source_branch).toBe('foo');
+ expect(data[1].source_branch).toBe('bar');
+ });
});
- it('fetches merge requests filtered with passed params', (done) => {
+ it('fetches merge requests filtered with passed params', () => {
const params = {
source_branch: 'bar',
};
const mockData = [{ source_branch: 'bar' }];
mock.onGet(expectedUrl, { params }).reply(httpStatus.OK, mockData);
- Api.projectMergeRequests(projectPath, params)
- .then(({ data }) => {
- expect(data.length).toEqual(1);
- expect(data[0].source_branch).toBe('bar');
- })
- .then(done)
- .catch(done.fail);
+ return Api.projectMergeRequests(projectPath, params).then(({ data }) => {
+ expect(data.length).toEqual(1);
+ expect(data[0].source_branch).toBe('bar');
+ });
});
});
describe('projectMergeRequest', () => {
- it('fetches a merge request', (done) => {
+ it('fetches a merge request', () => {
const projectPath = 'abc';
const mergeRequestId = '123456';
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests/${mergeRequestId}`;
@@ -435,17 +406,14 @@ describe('Api', () => {
title: 'test',
});
- Api.projectMergeRequest(projectPath, mergeRequestId)
- .then(({ data }) => {
- expect(data.title).toBe('test');
- })
- .then(done)
- .catch(done.fail);
+ return Api.projectMergeRequest(projectPath, mergeRequestId).then(({ data }) => {
+ expect(data.title).toBe('test');
+ });
});
});
describe('projectMergeRequestChanges', () => {
- it('fetches the changes of a merge request', (done) => {
+ it('fetches the changes of a merge request', () => {
const projectPath = 'abc';
const mergeRequestId = '123456';
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests/${mergeRequestId}/changes`;
@@ -453,17 +421,14 @@ describe('Api', () => {
title: 'test',
});
- Api.projectMergeRequestChanges(projectPath, mergeRequestId)
- .then(({ data }) => {
- expect(data.title).toBe('test');
- })
- .then(done)
- .catch(done.fail);
+ return Api.projectMergeRequestChanges(projectPath, mergeRequestId).then(({ data }) => {
+ expect(data.title).toBe('test');
+ });
});
});
describe('projectMergeRequestVersions', () => {
- it('fetches the versions of a merge request', (done) => {
+ it('fetches the versions of a merge request', () => {
const projectPath = 'abc';
const mergeRequestId = '123456';
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests/${mergeRequestId}/versions`;
@@ -473,30 +438,24 @@ describe('Api', () => {
},
]);
- Api.projectMergeRequestVersions(projectPath, mergeRequestId)
- .then(({ data }) => {
- expect(data.length).toBe(1);
- expect(data[0].id).toBe(123);
- })
- .then(done)
- .catch(done.fail);
+ return Api.projectMergeRequestVersions(projectPath, mergeRequestId).then(({ data }) => {
+ expect(data.length).toBe(1);
+ expect(data[0].id).toBe(123);
+ });
});
});
describe('projectRunners', () => {
- it('fetches the runners of a project', (done) => {
+ it('fetches the runners of a project', () => {
const projectPath = 7;
const params = { scope: 'active' };
const mockData = [{ id: 4 }];
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/runners`;
mock.onGet(expectedUrl, { params }).reply(httpStatus.OK, mockData);
- Api.projectRunners(projectPath, { params })
- .then(({ data }) => {
- expect(data).toEqual(mockData);
- })
- .then(done)
- .catch(done.fail);
+ return Api.projectRunners(projectPath, { params }).then(({ data }) => {
+ expect(data).toEqual(mockData);
+ });
});
});
@@ -525,7 +484,7 @@ describe('Api', () => {
});
describe('projectMilestones', () => {
- it('fetches project milestones', (done) => {
+ it('fetches project milestones', () => {
const projectId = 1;
const options = { state: 'active' };
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/1/milestones`;
@@ -537,13 +496,10 @@ describe('Api', () => {
},
]);
- Api.projectMilestones(projectId, options)
- .then(({ data }) => {
- expect(data.length).toBe(1);
- expect(data[0].title).toBe('milestone1');
- })
- .then(done)
- .catch(done.fail);
+ return Api.projectMilestones(projectId, options).then(({ data }) => {
+ expect(data.length).toBe(1);
+ expect(data[0].title).toBe('milestone1');
+ });
});
});
@@ -566,36 +522,15 @@ describe('Api', () => {
});
});
- describe('addProjectMembersByUserId', () => {
- it('adds an existing User as a new Project Member by User ID', () => {
- const projectId = 1;
- const expectedUserId = 2;
- const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/1/members`;
- const params = {
- user_id: expectedUserId,
- access_level: 10,
- expires_at: undefined,
- };
-
- mock.onPost(expectedUrl).reply(200, {
- id: expectedUserId,
- state: 'active',
- });
-
- return Api.addProjectMembersByUserId(projectId, params).then(({ data }) => {
- expect(data.id).toBe(expectedUserId);
- expect(data.state).toBe('active');
- });
- });
- });
-
- describe('inviteProjectMembersByEmail', () => {
+ describe('inviteProjectMembers', () => {
it('invites a new email address to create a new User and become a Project Member', () => {
const projectId = 1;
- const expectedEmail = 'email@example.com';
+ const email = 'email@example.com';
+ const userId = '1';
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/1/invitations`;
const params = {
- email: expectedEmail,
+ email,
+ userId,
access_level: 10,
expires_at: undefined,
};
@@ -604,14 +539,14 @@ describe('Api', () => {
status: 'success',
});
- return Api.inviteProjectMembersByEmail(projectId, params).then(({ data }) => {
+ return Api.inviteProjectMembers(projectId, params).then(({ data }) => {
expect(data.status).toBe('success');
});
});
});
describe('newLabel', () => {
- it('creates a new project label', (done) => {
+ it('creates a new project label', () => {
const namespace = 'some namespace';
const project = 'some project';
const labelData = { some: 'data' };
@@ -630,13 +565,15 @@ describe('Api', () => {
];
});
- Api.newLabel(namespace, project, labelData, (response) => {
- expect(response.name).toBe('test');
- done();
+ return new Promise((resolve) => {
+ Api.newLabel(namespace, project, labelData, (response) => {
+ expect(response.name).toBe('test');
+ resolve();
+ });
});
});
- it('creates a new group label', (done) => {
+ it('creates a new group label', () => {
const namespace = 'group/subgroup';
const labelData = { name: 'Foo', color: '#000000' };
const expectedUrl = Api.buildUrl(Api.groupLabelsPath).replace(':namespace_path', namespace);
@@ -651,15 +588,17 @@ describe('Api', () => {
];
});
- Api.newLabel(namespace, undefined, labelData, (response) => {
- expect(response.name).toBe('Foo');
- done();
+ return new Promise((resolve) => {
+ Api.newLabel(namespace, undefined, labelData, (response) => {
+ expect(response.name).toBe('Foo');
+ resolve();
+ });
});
});
});
describe('groupProjects', () => {
- it('fetches group projects', (done) => {
+ it('fetches group projects', () => {
const groupId = '123456';
const query = 'dummy query';
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/projects.json`;
@@ -669,11 +608,40 @@ describe('Api', () => {
},
]);
- Api.groupProjects(groupId, query, {}, (response) => {
- expect(response.length).toBe(1);
- expect(response[0].name).toBe('test');
- done();
+ return new Promise((resolve) => {
+ Api.groupProjects(groupId, query, {}, (response) => {
+ expect(response.length).toBe(1);
+ expect(response[0].name).toBe('test');
+ resolve();
+ });
+ });
+ });
+
+ it('uses flesh on error by default', async () => {
+ const groupId = '123456';
+ const query = 'dummy query';
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/projects.json`;
+ const flashCallback = (callCount) => {
+ expect(createFlash).toHaveBeenCalledTimes(callCount);
+ createFlash.mockClear();
+ };
+
+ mock.onGet(expectedUrl).reply(500, null);
+
+ const response = await Api.groupProjects(groupId, query, {}, () => {}).then(() => {
+ flashCallback(1);
});
+ expect(response).toBeUndefined();
+ });
+
+ it('NOT uses flesh on error with param useCustomErrorHandler', async () => {
+ const groupId = '123456';
+ const query = 'dummy query';
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/projects.json`;
+
+ mock.onGet(expectedUrl).reply(500, null);
+ const apiCall = Api.groupProjects(groupId, query, {}, () => {}, true);
+ await expect(apiCall).rejects.toThrow();
});
});
@@ -734,12 +702,14 @@ describe('Api', () => {
templateKey,
)}`;
- it('fetches an issue template', (done) => {
+ it('fetches an issue template', () => {
mock.onGet(expectedUrl).reply(httpStatus.OK, 'test');
- Api.issueTemplate(namespace, project, templateKey, templateType, (error, response) => {
- expect(response).toBe('test');
- done();
+ return new Promise((resolve) => {
+ Api.issueTemplate(namespace, project, templateKey, templateType, (_, response) => {
+ expect(response).toBe('test');
+ resolve();
+ });
});
});
@@ -747,8 +717,11 @@ describe('Api', () => {
it('rejects the Promise', () => {
mock.onGet(expectedUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR);
- Api.issueTemplate(namespace, project, templateKey, templateType, () => {
- expect(mock.history.get).toHaveLength(1);
+ return new Promise((resolve) => {
+ Api.issueTemplate(namespace, project, templateKey, templateType, () => {
+ expect(mock.history.get).toHaveLength(1);
+ resolve();
+ });
});
});
});
@@ -760,19 +733,21 @@ describe('Api', () => {
const templateType = 'template type';
const expectedUrl = `${dummyUrlRoot}/${namespace}/${project}/templates/${templateType}`;
- it('fetches all templates by type', (done) => {
+ it('fetches all templates by type', () => {
const expectedData = [
{ key: 'Template1', name: 'Template 1', content: 'This is template 1!' },
];
mock.onGet(expectedUrl).reply(httpStatus.OK, expectedData);
- Api.issueTemplates(namespace, project, templateType, (error, response) => {
- expect(response.length).toBe(1);
- const { key, name, content } = response[0];
- expect(key).toBe('Template1');
- expect(name).toBe('Template 1');
- expect(content).toBe('This is template 1!');
- done();
+ return new Promise((resolve) => {
+ Api.issueTemplates(namespace, project, templateType, (_, response) => {
+ expect(response.length).toBe(1);
+ const { key, name, content } = response[0];
+ expect(key).toBe('Template1');
+ expect(name).toBe('Template 1');
+ expect(content).toBe('This is template 1!');
+ resolve();
+ });
});
});
@@ -788,34 +763,44 @@ describe('Api', () => {
});
describe('projectTemplates', () => {
- it('fetches a list of templates', (done) => {
+ it('fetches a list of templates', () => {
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/gitlab-org%2Fgitlab-ce/templates/licenses`;
mock.onGet(expectedUrl).reply(httpStatus.OK, 'test');
- Api.projectTemplates('gitlab-org/gitlab-ce', 'licenses', {}, (response) => {
- expect(response).toBe('test');
- done();
+ return new Promise((resolve) => {
+ Api.projectTemplates('gitlab-org/gitlab-ce', 'licenses', {}, (response) => {
+ expect(response).toBe('test');
+ resolve();
+ });
});
});
});
describe('projectTemplate', () => {
- it('fetches a single template', (done) => {
+ it('fetches a single template', () => {
const data = { unused: 'option' };
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/gitlab-org%2Fgitlab-ce/templates/licenses/test%20license`;
mock.onGet(expectedUrl).reply(httpStatus.OK, 'test');
- Api.projectTemplate('gitlab-org/gitlab-ce', 'licenses', 'test license', data, (response) => {
- expect(response).toBe('test');
- done();
+ return new Promise((resolve) => {
+ Api.projectTemplate(
+ 'gitlab-org/gitlab-ce',
+ 'licenses',
+ 'test license',
+ data,
+ (response) => {
+ expect(response).toBe('test');
+ resolve();
+ },
+ );
});
});
});
describe('users', () => {
- it('fetches users', (done) => {
+ it('fetches users', () => {
const query = 'dummy query';
const options = { unused: 'option' };
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users.json`;
@@ -825,68 +810,56 @@ describe('Api', () => {
},
]);
- Api.users(query, options)
- .then(({ data }) => {
- expect(data.length).toBe(1);
- expect(data[0].name).toBe('test');
- })
- .then(done)
- .catch(done.fail);
+ return Api.users(query, options).then(({ data }) => {
+ expect(data.length).toBe(1);
+ expect(data[0].name).toBe('test');
+ });
});
});
describe('user', () => {
- it('fetches single user', (done) => {
+ it('fetches single user', () => {
const userId = '123456';
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users/${userId}`;
mock.onGet(expectedUrl).reply(httpStatus.OK, {
name: 'testuser',
});
- Api.user(userId)
- .then(({ data }) => {
- expect(data.name).toBe('testuser');
- })
- .then(done)
- .catch(done.fail);
+ return Api.user(userId).then(({ data }) => {
+ expect(data.name).toBe('testuser');
+ });
});
});
describe('user counts', () => {
- it('fetches single user counts', (done) => {
+ it('fetches single user counts', () => {
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/user_counts`;
mock.onGet(expectedUrl).reply(httpStatus.OK, {
merge_requests: 4,
});
- Api.userCounts()
- .then(({ data }) => {
- expect(data.merge_requests).toBe(4);
- })
- .then(done)
- .catch(done.fail);
+ return Api.userCounts().then(({ data }) => {
+ expect(data.merge_requests).toBe(4);
+ });
});
});
describe('user status', () => {
- it('fetches single user status', (done) => {
+ it('fetches single user status', () => {
const userId = '123456';
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users/${userId}/status`;
mock.onGet(expectedUrl).reply(httpStatus.OK, {
message: 'testmessage',
});
- Api.userStatus(userId)
- .then(({ data }) => {
- expect(data.message).toBe('testmessage');
- })
- .then(done)
- .catch(done.fail);
+ return Api.userStatus(userId).then(({ data }) => {
+ expect(data.message).toBe('testmessage');
+ });
});
});
describe('user projects', () => {
- it('fetches all projects that belong to a particular user', (done) => {
+ it('fetches all projects that belong to a particular user', () => {
const query = 'dummy query';
const options = { unused: 'option' };
const userId = '123456';
@@ -897,16 +870,18 @@ describe('Api', () => {
},
]);
- Api.userProjects(userId, query, options, (response) => {
- expect(response.length).toBe(1);
- expect(response[0].name).toBe('test');
- done();
+ return new Promise((resolve) => {
+ Api.userProjects(userId, query, options, (response) => {
+ expect(response.length).toBe(1);
+ expect(response[0].name).toBe('test');
+ resolve();
+ });
});
});
});
describe('commitPipelines', () => {
- it('fetches pipelines for a given commit', (done) => {
+ it('fetches pipelines for a given commit', () => {
const projectId = 'example/foobar';
const commitSha = 'abc123def';
const expectedUrl = `${dummyUrlRoot}/${projectId}/commit/${commitSha}/pipelines`;
@@ -916,13 +891,10 @@ describe('Api', () => {
},
]);
- Api.commitPipelines(projectId, commitSha)
- .then(({ data }) => {
- expect(data.length).toBe(1);
- expect(data[0].name).toBe('test');
- })
- .then(done)
- .catch(done.fail);
+ return Api.commitPipelines(projectId, commitSha).then(({ data }) => {
+ expect(data.length).toBe(1);
+ expect(data[0].name).toBe('test');
+ });
});
});
@@ -947,7 +919,7 @@ describe('Api', () => {
});
describe('createBranch', () => {
- it('creates new branch', (done) => {
+ it('creates new branch', () => {
const ref = 'main';
const branch = 'new-branch-name';
const dummyProjectPath = 'gitlab-org/gitlab-ce';
@@ -961,18 +933,15 @@ describe('Api', () => {
name: branch,
});
- Api.createBranch(dummyProjectPath, { ref, branch })
- .then(({ data }) => {
- expect(data.name).toBe(branch);
- expect(axios.post).toHaveBeenCalledWith(expectedUrl, { ref, branch });
- })
- .then(done)
- .catch(done.fail);
+ return Api.createBranch(dummyProjectPath, { ref, branch }).then(({ data }) => {
+ expect(data.name).toBe(branch);
+ expect(axios.post).toHaveBeenCalledWith(expectedUrl, { ref, branch });
+ });
});
});
describe('projectForks', () => {
- it('gets forked projects', (done) => {
+ it('gets forked projects', () => {
const dummyProjectPath = 'gitlab-org/gitlab-ce';
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${encodeURIComponent(
dummyProjectPath,
@@ -982,20 +951,17 @@ describe('Api', () => {
mock.onGet(expectedUrl).replyOnce(httpStatus.OK, ['fork']);
- Api.projectForks(dummyProjectPath, { visibility: 'private' })
- .then(({ data }) => {
- expect(data).toEqual(['fork']);
- expect(axios.get).toHaveBeenCalledWith(expectedUrl, {
- params: { visibility: 'private' },
- });
- })
- .then(done)
- .catch(done.fail);
+ return Api.projectForks(dummyProjectPath, { visibility: 'private' }).then(({ data }) => {
+ expect(data).toEqual(['fork']);
+ expect(axios.get).toHaveBeenCalledWith(expectedUrl, {
+ params: { visibility: 'private' },
+ });
+ });
});
});
describe('createContextCommits', () => {
- it('creates a new context commit', (done) => {
+ it('creates a new context commit', () => {
const projectPath = 'abc';
const mergeRequestId = '123456';
const commitsData = ['abcdefg'];
@@ -1014,17 +980,16 @@ describe('Api', () => {
},
]);
- Api.createContextCommits(projectPath, mergeRequestId, expectedData)
- .then(({ data }) => {
+ return Api.createContextCommits(projectPath, mergeRequestId, expectedData).then(
+ ({ data }) => {
expect(data[0].title).toBe('Dummy commit');
- })
- .then(done)
- .catch(done.fail);
+ },
+ );
});
});
describe('allContextCommits', () => {
- it('gets all context commits', (done) => {
+ it('gets all context commits', () => {
const projectPath = 'abc';
const mergeRequestId = '123456';
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests/${mergeRequestId}/context_commits`;
@@ -1035,17 +1000,14 @@ describe('Api', () => {
.onGet(expectedUrl)
.replyOnce(200, [{ id: 'abcdef', short_id: 'abcdefghi', title: 'Dummy commit title' }]);
- Api.allContextCommits(projectPath, mergeRequestId)
- .then(({ data }) => {
- expect(data[0].title).toBe('Dummy commit title');
- })
- .then(done)
- .catch(done.fail);
+ return Api.allContextCommits(projectPath, mergeRequestId).then(({ data }) => {
+ expect(data[0].title).toBe('Dummy commit title');
+ });
});
});
describe('removeContextCommits', () => {
- it('removes context commits', (done) => {
+ it('removes context commits', () => {
const projectPath = 'abc';
const mergeRequestId = '123456';
const commitsData = ['abcdefg'];
@@ -1058,12 +1020,9 @@ describe('Api', () => {
mock.onDelete(expectedUrl).replyOnce(204);
- Api.removeContextCommits(projectPath, mergeRequestId, expectedData)
- .then(() => {
- expect(axios.delete).toHaveBeenCalledWith(expectedUrl, { data: expectedData });
- })
- .then(done)
- .catch(done.fail);
+ return Api.removeContextCommits(projectPath, mergeRequestId, expectedData).then(() => {
+ expect(axios.delete).toHaveBeenCalledWith(expectedUrl, { data: expectedData });
+ });
});
});
@@ -1306,41 +1265,37 @@ describe('Api', () => {
});
describe('updateIssue', () => {
- it('update an issue with the given payload', (done) => {
+ it('update an issue with the given payload', () => {
const projectId = 8;
const issue = 1;
const expectedArray = [1, 2, 3];
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/issues/${issue}`;
mock.onPut(expectedUrl).reply(httpStatus.OK, { assigneeIds: expectedArray });
- Api.updateIssue(projectId, issue, { assigneeIds: expectedArray })
- .then(({ data }) => {
- expect(data.assigneeIds).toEqual(expectedArray);
- done();
- })
- .catch(done.fail);
+ return Api.updateIssue(projectId, issue, { assigneeIds: expectedArray }).then(({ data }) => {
+ expect(data.assigneeIds).toEqual(expectedArray);
+ });
});
});
describe('updateMergeRequest', () => {
- it('update an issue with the given payload', (done) => {
+ it('update an issue with the given payload', () => {
const projectId = 8;
const mergeRequest = 1;
const expectedArray = [1, 2, 3];
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/merge_requests/${mergeRequest}`;
mock.onPut(expectedUrl).reply(httpStatus.OK, { assigneeIds: expectedArray });
- Api.updateMergeRequest(projectId, mergeRequest, { assigneeIds: expectedArray })
- .then(({ data }) => {
+ return Api.updateMergeRequest(projectId, mergeRequest, { assigneeIds: expectedArray }).then(
+ ({ data }) => {
expect(data.assigneeIds).toEqual(expectedArray);
- done();
- })
- .catch(done.fail);
+ },
+ );
});
});
describe('tags', () => {
- it('fetches all tags of a particular project', (done) => {
+ it('fetches all tags of a particular project', () => {
const query = 'dummy query';
const options = { unused: 'option' };
const projectId = 8;
@@ -1351,13 +1306,10 @@ describe('Api', () => {
},
]);
- Api.tags(projectId, query, options)
- .then(({ data }) => {
- expect(data.length).toBe(1);
- expect(data[0].name).toBe('test');
- })
- .then(done)
- .catch(done.fail);
+ return Api.tags(projectId, query, options).then(({ data }) => {
+ expect(data.length).toBe(1);
+ expect(data[0].name).toBe('test');
+ });
});
});
@@ -1641,6 +1593,18 @@ describe('Api', () => {
});
});
+ describe('dependency proxy cache', () => {
+ it('schedules the cache list for deletion', async () => {
+ const groupId = 1;
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/dependency_proxy/cache`;
+
+ mock.onDelete(expectedUrl).reply(httpStatus.ACCEPTED);
+ const { status } = await Api.deleteDependencyProxyCacheList(groupId, {});
+
+ expect(status).toBe(httpStatus.ACCEPTED);
+ });
+ });
+
describe('Feature Flag User List', () => {
let expectedUrl;
let projectId;
@@ -1727,4 +1691,36 @@ describe('Api', () => {
});
});
});
+
+ describe('projectProtectedBranch', () => {
+ const branchName = 'new-branch-name';
+ const dummyProjectId = 5;
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${dummyProjectId}/protected_branches/${branchName}`;
+
+ it('returns 404 for non-existing branch', () => {
+ jest.spyOn(axios, 'get');
+
+ mock.onGet(expectedUrl).replyOnce(httpStatus.NOT_FOUND, {
+ message: '404 Not found',
+ });
+
+ return Api.projectProtectedBranch(dummyProjectId, branchName).catch((error) => {
+ expect(error.response.status).toBe(httpStatus.NOT_FOUND);
+ expect(axios.get).toHaveBeenCalledWith(expectedUrl);
+ });
+ });
+
+ it('returns 200 with branch information', () => {
+ const expectedObj = { name: branchName };
+
+ jest.spyOn(axios, 'get');
+
+ mock.onGet(expectedUrl).replyOnce(httpStatus.OK, expectedObj);
+
+ return Api.projectProtectedBranch(dummyProjectId, branchName).then((data) => {
+ expect(data).toEqual(expectedObj);
+ expect(axios.get).toHaveBeenCalledWith(expectedUrl);
+ });
+ });
+ });
});
diff --git a/spec/frontend/authentication/u2f/authenticate_spec.js b/spec/frontend/authentication/u2f/authenticate_spec.js
index 153d4be56af..31782899ce4 100644
--- a/spec/frontend/authentication/u2f/authenticate_spec.js
+++ b/spec/frontend/authentication/u2f/authenticate_spec.js
@@ -36,24 +36,19 @@ describe('U2FAuthenticate', () => {
window.u2f = oldu2f;
});
- it('falls back to normal 2fa', (done) => {
- component
- .start()
- .then(() => {
- expect(component.switchToFallbackUI).toHaveBeenCalled();
- done();
- })
- .catch(done.fail);
+ it('falls back to normal 2fa', async () => {
+ await component.start();
+ expect(component.switchToFallbackUI).toHaveBeenCalled();
});
});
describe('with u2f available', () => {
- beforeEach((done) => {
+ beforeEach(() => {
// bypass automatic form submission within renderAuthenticated
jest.spyOn(component, 'renderAuthenticated').mockReturnValue(true);
u2fDevice = new MockU2FDevice();
- component.start().then(done).catch(done.fail);
+ return component.start();
});
it('allows authenticating via a U2F device', () => {
diff --git a/spec/frontend/authentication/u2f/register_spec.js b/spec/frontend/authentication/u2f/register_spec.js
index a814144ac7a..810396aa9fd 100644
--- a/spec/frontend/authentication/u2f/register_spec.js
+++ b/spec/frontend/authentication/u2f/register_spec.js
@@ -8,12 +8,12 @@ describe('U2FRegister', () => {
let container;
let component;
- beforeEach((done) => {
+ beforeEach(() => {
loadFixtures('u2f/register.html');
u2fDevice = new MockU2FDevice();
container = $('#js-register-token-2fa');
component = new U2FRegister(container, {});
- component.start().then(done).catch(done.fail);
+ return component.start();
});
it('allows registering a U2F device', () => {
diff --git a/spec/frontend/badges/components/badge_spec.js b/spec/frontend/badges/components/badge_spec.js
index 2310fb8bd8e..fe4cf8ce8eb 100644
--- a/spec/frontend/badges/components/badge_spec.js
+++ b/spec/frontend/badges/components/badge_spec.js
@@ -89,11 +89,9 @@ describe('Badge component', () => {
});
describe('behavior', () => {
- beforeEach((done) => {
+ beforeEach(() => {
setFixtures('<div id="dummy-element"></div>');
- createComponent({ ...dummyProps }, '#dummy-element')
- .then(done)
- .catch(done.fail);
+ return createComponent({ ...dummyProps }, '#dummy-element');
});
it('shows a badge image after loading', () => {
diff --git a/spec/frontend/badges/store/actions_spec.js b/spec/frontend/badges/store/actions_spec.js
index 75699f24463..02e1b8e65e4 100644
--- a/spec/frontend/badges/store/actions_spec.js
+++ b/spec/frontend/badges/store/actions_spec.js
@@ -33,41 +33,38 @@ describe('Badges store actions', () => {
});
describe('requestNewBadge', () => {
- it('commits REQUEST_NEW_BADGE', (done) => {
- testAction(
+ it('commits REQUEST_NEW_BADGE', () => {
+ return testAction(
actions.requestNewBadge,
null,
state,
[{ type: mutationTypes.REQUEST_NEW_BADGE }],
[],
- done,
);
});
});
describe('receiveNewBadge', () => {
- it('commits RECEIVE_NEW_BADGE', (done) => {
+ it('commits RECEIVE_NEW_BADGE', () => {
const newBadge = createDummyBadge();
- testAction(
+ return testAction(
actions.receiveNewBadge,
newBadge,
state,
[{ type: mutationTypes.RECEIVE_NEW_BADGE, payload: newBadge }],
[],
- done,
);
});
});
describe('receiveNewBadgeError', () => {
- it('commits RECEIVE_NEW_BADGE_ERROR', (done) => {
- testAction(
+ it('commits RECEIVE_NEW_BADGE_ERROR', () => {
+ return testAction(
actions.receiveNewBadgeError,
null,
state,
[{ type: mutationTypes.RECEIVE_NEW_BADGE_ERROR }],
[],
- done,
);
});
});
@@ -87,7 +84,7 @@ describe('Badges store actions', () => {
};
});
- it('dispatches requestNewBadge and receiveNewBadge for successful response', (done) => {
+ it('dispatches requestNewBadge and receiveNewBadge for successful response', async () => {
const dummyResponse = createDummyBadgeResponse();
endpointMock.replyOnce((req) => {
@@ -105,16 +102,12 @@ describe('Badges store actions', () => {
});
const dummyBadge = transformBackendBadge(dummyResponse);
- actions
- .addBadge({ state, dispatch })
- .then(() => {
- expect(dispatch.mock.calls).toEqual([['receiveNewBadge', dummyBadge]]);
- })
- .then(done)
- .catch(done.fail);
+
+ await actions.addBadge({ state, dispatch });
+ expect(dispatch.mock.calls).toEqual([['receiveNewBadge', dummyBadge]]);
});
- it('dispatches requestNewBadge and receiveNewBadgeError for error response', (done) => {
+ it('dispatches requestNewBadge and receiveNewBadgeError for error response', async () => {
endpointMock.replyOnce((req) => {
expect(req.data).toBe(
JSON.stringify({
@@ -129,52 +122,43 @@ describe('Badges store actions', () => {
return [500, ''];
});
- actions
- .addBadge({ state, dispatch })
- .then(() => done.fail('Expected Ajax call to fail!'))
- .catch(() => {
- expect(dispatch.mock.calls).toEqual([['receiveNewBadgeError']]);
- })
- .then(done)
- .catch(done.fail);
+ await expect(actions.addBadge({ state, dispatch })).rejects.toThrow();
+ expect(dispatch.mock.calls).toEqual([['receiveNewBadgeError']]);
});
});
describe('requestDeleteBadge', () => {
- it('commits REQUEST_DELETE_BADGE', (done) => {
- testAction(
+ it('commits REQUEST_DELETE_BADGE', () => {
+ return testAction(
actions.requestDeleteBadge,
badgeId,
state,
[{ type: mutationTypes.REQUEST_DELETE_BADGE, payload: badgeId }],
[],
- done,
);
});
});
describe('receiveDeleteBadge', () => {
- it('commits RECEIVE_DELETE_BADGE', (done) => {
- testAction(
+ it('commits RECEIVE_DELETE_BADGE', () => {
+ return testAction(
actions.receiveDeleteBadge,
badgeId,
state,
[{ type: mutationTypes.RECEIVE_DELETE_BADGE, payload: badgeId }],
[],
- done,
);
});
});
describe('receiveDeleteBadgeError', () => {
- it('commits RECEIVE_DELETE_BADGE_ERROR', (done) => {
- testAction(
+ it('commits RECEIVE_DELETE_BADGE_ERROR', () => {
+ return testAction(
actions.receiveDeleteBadgeError,
badgeId,
state,
[{ type: mutationTypes.RECEIVE_DELETE_BADGE_ERROR, payload: badgeId }],
[],
- done,
);
});
});
@@ -188,91 +172,76 @@ describe('Badges store actions', () => {
dispatch = jest.fn();
});
- it('dispatches requestDeleteBadge and receiveDeleteBadge for successful response', (done) => {
+ it('dispatches requestDeleteBadge and receiveDeleteBadge for successful response', async () => {
endpointMock.replyOnce(() => {
expect(dispatch.mock.calls).toEqual([['requestDeleteBadge', badgeId]]);
dispatch.mockClear();
return [200, ''];
});
- actions
- .deleteBadge({ state, dispatch }, { id: badgeId })
- .then(() => {
- expect(dispatch.mock.calls).toEqual([['receiveDeleteBadge', badgeId]]);
- })
- .then(done)
- .catch(done.fail);
+ await actions.deleteBadge({ state, dispatch }, { id: badgeId });
+ expect(dispatch.mock.calls).toEqual([['receiveDeleteBadge', badgeId]]);
});
- it('dispatches requestDeleteBadge and receiveDeleteBadgeError for error response', (done) => {
+ it('dispatches requestDeleteBadge and receiveDeleteBadgeError for error response', async () => {
endpointMock.replyOnce(() => {
expect(dispatch.mock.calls).toEqual([['requestDeleteBadge', badgeId]]);
dispatch.mockClear();
return [500, ''];
});
- actions
- .deleteBadge({ state, dispatch }, { id: badgeId })
- .then(() => done.fail('Expected Ajax call to fail!'))
- .catch(() => {
- expect(dispatch.mock.calls).toEqual([['receiveDeleteBadgeError', badgeId]]);
- })
- .then(done)
- .catch(done.fail);
+ await expect(actions.deleteBadge({ state, dispatch }, { id: badgeId })).rejects.toThrow();
+ expect(dispatch.mock.calls).toEqual([['receiveDeleteBadgeError', badgeId]]);
});
});
describe('editBadge', () => {
- it('commits START_EDITING', (done) => {
+ it('commits START_EDITING', () => {
const dummyBadge = createDummyBadge();
- testAction(
+ return testAction(
actions.editBadge,
dummyBadge,
state,
[{ type: mutationTypes.START_EDITING, payload: dummyBadge }],
[],
- done,
);
});
});
describe('requestLoadBadges', () => {
- it('commits REQUEST_LOAD_BADGES', (done) => {
+ it('commits REQUEST_LOAD_BADGES', () => {
const dummyData = 'this is not real data';
- testAction(
+ return testAction(
actions.requestLoadBadges,
dummyData,
state,
[{ type: mutationTypes.REQUEST_LOAD_BADGES, payload: dummyData }],
[],
- done,
);
});
});
describe('receiveLoadBadges', () => {
- it('commits RECEIVE_LOAD_BADGES', (done) => {
+ it('commits RECEIVE_LOAD_BADGES', () => {
const badges = dummyBadges;
- testAction(
+ return testAction(
actions.receiveLoadBadges,
badges,
state,
[{ type: mutationTypes.RECEIVE_LOAD_BADGES, payload: badges }],
[],
- done,
);
});
});
describe('receiveLoadBadgesError', () => {
- it('commits RECEIVE_LOAD_BADGES_ERROR', (done) => {
- testAction(
+ it('commits RECEIVE_LOAD_BADGES_ERROR', () => {
+ return testAction(
actions.receiveLoadBadgesError,
null,
state,
[{ type: mutationTypes.RECEIVE_LOAD_BADGES_ERROR }],
[],
- done,
);
});
});
@@ -286,7 +255,7 @@ describe('Badges store actions', () => {
dispatch = jest.fn();
});
- it('dispatches requestLoadBadges and receiveLoadBadges for successful response', (done) => {
+ it('dispatches requestLoadBadges and receiveLoadBadges for successful response', async () => {
const dummyData = 'this is just some data';
const dummyReponse = [
createDummyBadgeResponse(),
@@ -299,18 +268,13 @@ describe('Badges store actions', () => {
return [200, dummyReponse];
});
- actions
- .loadBadges({ state, dispatch }, dummyData)
- .then(() => {
- const badges = dummyReponse.map(transformBackendBadge);
+ await actions.loadBadges({ state, dispatch }, dummyData);
+ const badges = dummyReponse.map(transformBackendBadge);
- expect(dispatch.mock.calls).toEqual([['receiveLoadBadges', badges]]);
- })
- .then(done)
- .catch(done.fail);
+ expect(dispatch.mock.calls).toEqual([['receiveLoadBadges', badges]]);
});
- it('dispatches requestLoadBadges and receiveLoadBadgesError for error response', (done) => {
+ it('dispatches requestLoadBadges and receiveLoadBadgesError for error response', async () => {
const dummyData = 'this is just some data';
endpointMock.replyOnce(() => {
expect(dispatch.mock.calls).toEqual([['requestLoadBadges', dummyData]]);
@@ -318,53 +282,44 @@ describe('Badges store actions', () => {
return [500, ''];
});
- actions
- .loadBadges({ state, dispatch }, dummyData)
- .then(() => done.fail('Expected Ajax call to fail!'))
- .catch(() => {
- expect(dispatch.mock.calls).toEqual([['receiveLoadBadgesError']]);
- })
- .then(done)
- .catch(done.fail);
+ await expect(actions.loadBadges({ state, dispatch }, dummyData)).rejects.toThrow();
+ expect(dispatch.mock.calls).toEqual([['receiveLoadBadgesError']]);
});
});
describe('requestRenderedBadge', () => {
- it('commits REQUEST_RENDERED_BADGE', (done) => {
- testAction(
+ it('commits REQUEST_RENDERED_BADGE', () => {
+ return testAction(
actions.requestRenderedBadge,
null,
state,
[{ type: mutationTypes.REQUEST_RENDERED_BADGE }],
[],
- done,
);
});
});
describe('receiveRenderedBadge', () => {
- it('commits RECEIVE_RENDERED_BADGE', (done) => {
+ it('commits RECEIVE_RENDERED_BADGE', () => {
const dummyBadge = createDummyBadge();
- testAction(
+ return testAction(
actions.receiveRenderedBadge,
dummyBadge,
state,
[{ type: mutationTypes.RECEIVE_RENDERED_BADGE, payload: dummyBadge }],
[],
- done,
);
});
});
describe('receiveRenderedBadgeError', () => {
- it('commits RECEIVE_RENDERED_BADGE_ERROR', (done) => {
- testAction(
+ it('commits RECEIVE_RENDERED_BADGE_ERROR', () => {
+ return testAction(
actions.receiveRenderedBadgeError,
null,
state,
[{ type: mutationTypes.RECEIVE_RENDERED_BADGE_ERROR }],
[],
- done,
);
});
});
@@ -388,56 +343,41 @@ describe('Badges store actions', () => {
dispatch = jest.fn();
});
- it('returns immediately if imageUrl is empty', (done) => {
+ it('returns immediately if imageUrl is empty', async () => {
jest.spyOn(axios, 'get').mockImplementation(() => {});
badgeInForm.imageUrl = '';
- actions
- .renderBadge({ state, dispatch })
- .then(() => {
- expect(axios.get).not.toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
+ await actions.renderBadge({ state, dispatch });
+ expect(axios.get).not.toHaveBeenCalled();
});
- it('returns immediately if linkUrl is empty', (done) => {
+ it('returns immediately if linkUrl is empty', async () => {
jest.spyOn(axios, 'get').mockImplementation(() => {});
badgeInForm.linkUrl = '';
- actions
- .renderBadge({ state, dispatch })
- .then(() => {
- expect(axios.get).not.toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
+ await actions.renderBadge({ state, dispatch });
+ expect(axios.get).not.toHaveBeenCalled();
});
- it('escapes user input', (done) => {
+ it('escapes user input', async () => {
jest
.spyOn(axios, 'get')
.mockImplementation(() => Promise.resolve({ data: createDummyBadgeResponse() }));
badgeInForm.imageUrl = '&make-sandwich=true';
badgeInForm.linkUrl = '<script>I am dangerous!</script>';
- actions
- .renderBadge({ state, dispatch })
- .then(() => {
- expect(axios.get.mock.calls.length).toBe(1);
- const url = axios.get.mock.calls[0][0];
+ await actions.renderBadge({ state, dispatch });
+ expect(axios.get.mock.calls.length).toBe(1);
+ const url = axios.get.mock.calls[0][0];
- expect(url).toMatch(new RegExp(`^${dummyEndpointUrl}/render?`));
- expect(url).toMatch(
- new RegExp('\\?link_url=%3Cscript%3EI%20am%20dangerous!%3C%2Fscript%3E&'),
- );
- expect(url).toMatch(new RegExp('&image_url=%26make-sandwich%3Dtrue$'));
- })
- .then(done)
- .catch(done.fail);
+ expect(url).toMatch(new RegExp(`^${dummyEndpointUrl}/render?`));
+ expect(url).toMatch(
+ new RegExp('\\?link_url=%3Cscript%3EI%20am%20dangerous!%3C%2Fscript%3E&'),
+ );
+ expect(url).toMatch(new RegExp('&image_url=%26make-sandwich%3Dtrue$'));
});
- it('dispatches requestRenderedBadge and receiveRenderedBadge for successful response', (done) => {
+ it('dispatches requestRenderedBadge and receiveRenderedBadge for successful response', async () => {
const dummyReponse = createDummyBadgeResponse();
endpointMock.replyOnce(() => {
expect(dispatch.mock.calls).toEqual([['requestRenderedBadge']]);
@@ -445,71 +385,57 @@ describe('Badges store actions', () => {
return [200, dummyReponse];
});
- actions
- .renderBadge({ state, dispatch })
- .then(() => {
- const renderedBadge = transformBackendBadge(dummyReponse);
+ await actions.renderBadge({ state, dispatch });
+ const renderedBadge = transformBackendBadge(dummyReponse);
- expect(dispatch.mock.calls).toEqual([['receiveRenderedBadge', renderedBadge]]);
- })
- .then(done)
- .catch(done.fail);
+ expect(dispatch.mock.calls).toEqual([['receiveRenderedBadge', renderedBadge]]);
});
- it('dispatches requestRenderedBadge and receiveRenderedBadgeError for error response', (done) => {
+ it('dispatches requestRenderedBadge and receiveRenderedBadgeError for error response', async () => {
endpointMock.replyOnce(() => {
expect(dispatch.mock.calls).toEqual([['requestRenderedBadge']]);
dispatch.mockClear();
return [500, ''];
});
- actions
- .renderBadge({ state, dispatch })
- .then(() => done.fail('Expected Ajax call to fail!'))
- .catch(() => {
- expect(dispatch.mock.calls).toEqual([['receiveRenderedBadgeError']]);
- })
- .then(done)
- .catch(done.fail);
+ await expect(actions.renderBadge({ state, dispatch })).rejects.toThrow();
+ expect(dispatch.mock.calls).toEqual([['receiveRenderedBadgeError']]);
});
});
describe('requestUpdatedBadge', () => {
- it('commits REQUEST_UPDATED_BADGE', (done) => {
- testAction(
+ it('commits REQUEST_UPDATED_BADGE', () => {
+ return testAction(
actions.requestUpdatedBadge,
null,
state,
[{ type: mutationTypes.REQUEST_UPDATED_BADGE }],
[],
- done,
);
});
});
describe('receiveUpdatedBadge', () => {
- it('commits RECEIVE_UPDATED_BADGE', (done) => {
+ it('commits RECEIVE_UPDATED_BADGE', () => {
const updatedBadge = createDummyBadge();
- testAction(
+ return testAction(
actions.receiveUpdatedBadge,
updatedBadge,
state,
[{ type: mutationTypes.RECEIVE_UPDATED_BADGE, payload: updatedBadge }],
[],
- done,
);
});
});
describe('receiveUpdatedBadgeError', () => {
- it('commits RECEIVE_UPDATED_BADGE_ERROR', (done) => {
- testAction(
+ it('commits RECEIVE_UPDATED_BADGE_ERROR', () => {
+ return testAction(
actions.receiveUpdatedBadgeError,
null,
state,
[{ type: mutationTypes.RECEIVE_UPDATED_BADGE_ERROR }],
[],
- done,
);
});
});
@@ -529,7 +455,7 @@ describe('Badges store actions', () => {
dispatch = jest.fn();
});
- it('dispatches requestUpdatedBadge and receiveUpdatedBadge for successful response', (done) => {
+ it('dispatches requestUpdatedBadge and receiveUpdatedBadge for successful response', async () => {
const dummyResponse = createDummyBadgeResponse();
endpointMock.replyOnce((req) => {
@@ -547,16 +473,11 @@ describe('Badges store actions', () => {
});
const updatedBadge = transformBackendBadge(dummyResponse);
- actions
- .saveBadge({ state, dispatch })
- .then(() => {
- expect(dispatch.mock.calls).toEqual([['receiveUpdatedBadge', updatedBadge]]);
- })
- .then(done)
- .catch(done.fail);
+ await actions.saveBadge({ state, dispatch });
+ expect(dispatch.mock.calls).toEqual([['receiveUpdatedBadge', updatedBadge]]);
});
- it('dispatches requestUpdatedBadge and receiveUpdatedBadgeError for error response', (done) => {
+ it('dispatches requestUpdatedBadge and receiveUpdatedBadgeError for error response', async () => {
endpointMock.replyOnce((req) => {
expect(req.data).toBe(
JSON.stringify({
@@ -571,53 +492,44 @@ describe('Badges store actions', () => {
return [500, ''];
});
- actions
- .saveBadge({ state, dispatch })
- .then(() => done.fail('Expected Ajax call to fail!'))
- .catch(() => {
- expect(dispatch.mock.calls).toEqual([['receiveUpdatedBadgeError']]);
- })
- .then(done)
- .catch(done.fail);
+ await expect(actions.saveBadge({ state, dispatch })).rejects.toThrow();
+ expect(dispatch.mock.calls).toEqual([['receiveUpdatedBadgeError']]);
});
});
describe('stopEditing', () => {
- it('commits STOP_EDITING', (done) => {
- testAction(
+ it('commits STOP_EDITING', () => {
+ return testAction(
actions.stopEditing,
null,
state,
[{ type: mutationTypes.STOP_EDITING }],
[],
- done,
);
});
});
describe('updateBadgeInForm', () => {
- it('commits UPDATE_BADGE_IN_FORM', (done) => {
+ it('commits UPDATE_BADGE_IN_FORM', () => {
const dummyBadge = createDummyBadge();
- testAction(
+ return testAction(
actions.updateBadgeInForm,
dummyBadge,
state,
[{ type: mutationTypes.UPDATE_BADGE_IN_FORM, payload: dummyBadge }],
[],
- done,
);
});
describe('updateBadgeInModal', () => {
- it('commits UPDATE_BADGE_IN_MODAL', (done) => {
+ it('commits UPDATE_BADGE_IN_MODAL', () => {
const dummyBadge = createDummyBadge();
- testAction(
+ return testAction(
actions.updateBadgeInModal,
dummyBadge,
state,
[{ type: mutationTypes.UPDATE_BADGE_IN_MODAL, payload: dummyBadge }],
[],
- done,
);
});
});
diff --git a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js
index b0e9e5dd00b..e9535d8cc12 100644
--- a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js
+++ b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js
@@ -29,53 +29,56 @@ describe('Batch comments store actions', () => {
});
describe('addDraftToDiscussion', () => {
- it('commits ADD_NEW_DRAFT if no errors returned', (done) => {
+ it('commits ADD_NEW_DRAFT if no errors returned', () => {
res = { id: 1 };
mock.onAny().reply(200, res);
- testAction(
+ return testAction(
actions.addDraftToDiscussion,
{ endpoint: TEST_HOST, data: 'test' },
null,
[{ type: 'ADD_NEW_DRAFT', payload: res }],
[],
- done,
);
});
- it('does not commit ADD_NEW_DRAFT if errors returned', (done) => {
+ it('does not commit ADD_NEW_DRAFT if errors returned', () => {
mock.onAny().reply(500);
- testAction(
+ return testAction(
actions.addDraftToDiscussion,
{ endpoint: TEST_HOST, data: 'test' },
null,
[],
[],
- done,
);
});
});
describe('createNewDraft', () => {
- it('commits ADD_NEW_DRAFT if no errors returned', (done) => {
+ it('commits ADD_NEW_DRAFT if no errors returned', () => {
res = { id: 1 };
mock.onAny().reply(200, res);
- testAction(
+ return testAction(
actions.createNewDraft,
{ endpoint: TEST_HOST, data: 'test' },
null,
[{ type: 'ADD_NEW_DRAFT', payload: res }],
[],
- done,
);
});
- it('does not commit ADD_NEW_DRAFT if errors returned', (done) => {
+ it('does not commit ADD_NEW_DRAFT if errors returned', () => {
mock.onAny().reply(500);
- testAction(actions.createNewDraft, { endpoint: TEST_HOST, data: 'test' }, null, [], [], done);
+ return testAction(
+ actions.createNewDraft,
+ { endpoint: TEST_HOST, data: 'test' },
+ null,
+ [],
+ [],
+ );
});
});
@@ -90,7 +93,7 @@ describe('Batch comments store actions', () => {
};
});
- it('commits DELETE_DRAFT if no errors returned', (done) => {
+ it('commits DELETE_DRAFT if no errors returned', () => {
const commit = jest.fn();
const context = {
getters,
@@ -99,16 +102,12 @@ describe('Batch comments store actions', () => {
res = { id: 1 };
mock.onAny().reply(200);
- actions
- .deleteDraft(context, { id: 1 })
- .then(() => {
- expect(commit).toHaveBeenCalledWith('DELETE_DRAFT', 1);
- })
- .then(done)
- .catch(done.fail);
+ return actions.deleteDraft(context, { id: 1 }).then(() => {
+ expect(commit).toHaveBeenCalledWith('DELETE_DRAFT', 1);
+ });
});
- it('does not commit DELETE_DRAFT if errors returned', (done) => {
+ it('does not commit DELETE_DRAFT if errors returned', () => {
const commit = jest.fn();
const context = {
getters,
@@ -116,13 +115,9 @@ describe('Batch comments store actions', () => {
};
mock.onAny().reply(500);
- actions
- .deleteDraft(context, { id: 1 })
- .then(() => {
- expect(commit).not.toHaveBeenCalledWith('DELETE_DRAFT', 1);
- })
- .then(done)
- .catch(done.fail);
+ return actions.deleteDraft(context, { id: 1 }).then(() => {
+ expect(commit).not.toHaveBeenCalledWith('DELETE_DRAFT', 1);
+ });
});
});
@@ -137,7 +132,7 @@ describe('Batch comments store actions', () => {
};
});
- it('commits SET_BATCH_COMMENTS_DRAFTS with returned data', (done) => {
+ it('commits SET_BATCH_COMMENTS_DRAFTS with returned data', () => {
const commit = jest.fn();
const dispatch = jest.fn();
const context = {
@@ -151,14 +146,10 @@ describe('Batch comments store actions', () => {
res = { id: 1 };
mock.onAny().reply(200, res);
- actions
- .fetchDrafts(context)
- .then(() => {
- expect(commit).toHaveBeenCalledWith('SET_BATCH_COMMENTS_DRAFTS', { id: 1 });
- expect(dispatch).toHaveBeenCalledWith('convertToDiscussion', '1', { root: true });
- })
- .then(done)
- .catch(done.fail);
+ return actions.fetchDrafts(context).then(() => {
+ expect(commit).toHaveBeenCalledWith('SET_BATCH_COMMENTS_DRAFTS', { id: 1 });
+ expect(dispatch).toHaveBeenCalledWith('convertToDiscussion', '1', { root: true });
+ });
});
});
@@ -177,32 +168,24 @@ describe('Batch comments store actions', () => {
rootGetters = { discussionsStructuredByLineCode: 'discussions' };
});
- it('dispatches actions & commits', (done) => {
+ it('dispatches actions & commits', () => {
mock.onAny().reply(200);
- actions
- .publishReview({ dispatch, commit, getters, rootGetters })
- .then(() => {
- expect(commit.mock.calls[0]).toEqual(['REQUEST_PUBLISH_REVIEW']);
- expect(commit.mock.calls[1]).toEqual(['RECEIVE_PUBLISH_REVIEW_SUCCESS']);
+ return actions.publishReview({ dispatch, commit, getters, rootGetters }).then(() => {
+ expect(commit.mock.calls[0]).toEqual(['REQUEST_PUBLISH_REVIEW']);
+ expect(commit.mock.calls[1]).toEqual(['RECEIVE_PUBLISH_REVIEW_SUCCESS']);
- expect(dispatch.mock.calls[0]).toEqual(['updateDiscussionsAfterPublish']);
- })
- .then(done)
- .catch(done.fail);
+ expect(dispatch.mock.calls[0]).toEqual(['updateDiscussionsAfterPublish']);
+ });
});
- it('dispatches error commits', (done) => {
+ it('dispatches error commits', () => {
mock.onAny().reply(500);
- actions
- .publishReview({ dispatch, commit, getters, rootGetters })
- .then(() => {
- expect(commit.mock.calls[0]).toEqual(['REQUEST_PUBLISH_REVIEW']);
- expect(commit.mock.calls[1]).toEqual(['RECEIVE_PUBLISH_REVIEW_ERROR']);
- })
- .then(done)
- .catch(done.fail);
+ return actions.publishReview({ dispatch, commit, getters, rootGetters }).then(() => {
+ expect(commit.mock.calls[0]).toEqual(['REQUEST_PUBLISH_REVIEW']);
+ expect(commit.mock.calls[1]).toEqual(['RECEIVE_PUBLISH_REVIEW_ERROR']);
+ });
});
});
@@ -262,7 +245,7 @@ describe('Batch comments store actions', () => {
});
describe('expandAllDiscussions', () => {
- it('dispatches expandDiscussion for all drafts', (done) => {
+ it('dispatches expandDiscussion for all drafts', () => {
const state = {
drafts: [
{
@@ -271,7 +254,7 @@ describe('Batch comments store actions', () => {
],
};
- testAction(
+ return testAction(
actions.expandAllDiscussions,
null,
state,
@@ -282,7 +265,6 @@ describe('Batch comments store actions', () => {
payload: { discussionId: '1' },
},
],
- done,
);
});
});
diff --git a/spec/frontend/behaviors/gl_emoji_spec.js b/spec/frontend/behaviors/gl_emoji_spec.js
index cac1ea67cf5..8842ad636ec 100644
--- a/spec/frontend/behaviors/gl_emoji_spec.js
+++ b/spec/frontend/behaviors/gl_emoji_spec.js
@@ -77,6 +77,12 @@ describe('gl_emoji', () => {
'<gl-emoji data-name="grey_question" data-unicode-version="6.0" title="white question mark ornament">❔</gl-emoji>',
`<gl-emoji data-name="grey_question" data-unicode-version="6.0" title="white question mark ornament"><img class="emoji" title=":grey_question:" alt=":grey_question:" src="/-/emojis/${EMOJI_VERSION}/grey_question.png" width="20" height="20" align="absmiddle"></gl-emoji>`,
],
+ [
+ 'custom emoji with image fallback',
+ '<gl-emoji data-fallback-src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" data-name="party-parrot" data-unicode-version="custom"></gl-emoji>',
+ '<gl-emoji data-fallback-src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" data-name="party-parrot" data-unicode-version="custom"><img class="emoji" title=":party-parrot:" alt=":party-parrot:" src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" width="20" height="20" align="absmiddle"></gl-emoji>',
+ '<gl-emoji data-fallback-src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" data-name="party-parrot" data-unicode-version="custom"><img class="emoji" title=":party-parrot:" alt=":party-parrot:" src="https://cultofthepartyparrot.com/parrots/hd/parrot.gif" width="20" height="20" align="absmiddle"></gl-emoji>',
+ ],
])('%s', (name, markup, withEmojiSupport, withoutEmojiSupport) => {
it(`renders correctly with emoji support`, async () => {
jest.spyOn(EmojiUnicodeSupport, 'default').mockReturnValue(true);
diff --git a/spec/frontend/blob/balsamiq/balsamiq_viewer_spec.js b/spec/frontend/blob/balsamiq/balsamiq_viewer_spec.js
deleted file mode 100644
index d7531d15b9a..00000000000
--- a/spec/frontend/blob/balsamiq/balsamiq_viewer_spec.js
+++ /dev/null
@@ -1,363 +0,0 @@
-import sqljs from 'sql.js';
-import ClassSpecHelper from 'helpers/class_spec_helper';
-import BalsamiqViewer from '~/blob/balsamiq/balsamiq_viewer';
-import axios from '~/lib/utils/axios_utils';
-
-jest.mock('sql.js');
-
-describe('BalsamiqViewer', () => {
- const mockArrayBuffer = new ArrayBuffer(10);
- let balsamiqViewer;
- let viewer;
-
- describe('class constructor', () => {
- beforeEach(() => {
- viewer = {};
-
- balsamiqViewer = new BalsamiqViewer(viewer);
- });
-
- it('should set .viewer', () => {
- expect(balsamiqViewer.viewer).toBe(viewer);
- });
- });
-
- describe('loadFile', () => {
- let bv;
- const endpoint = 'endpoint';
- const requestSuccess = Promise.resolve({
- data: mockArrayBuffer,
- status: 200,
- });
-
- beforeEach(() => {
- viewer = {};
- bv = new BalsamiqViewer(viewer);
- });
-
- it('should call `axios.get` on `endpoint` param with responseType set to `arraybuffer', () => {
- jest.spyOn(axios, 'get').mockReturnValue(requestSuccess);
- jest.spyOn(bv, 'renderFile').mockReturnValue();
-
- bv.loadFile(endpoint);
-
- expect(axios.get).toHaveBeenCalledWith(
- endpoint,
- expect.objectContaining({
- responseType: 'arraybuffer',
- }),
- );
- });
-
- it('should call `renderFile` on request success', (done) => {
- jest.spyOn(axios, 'get').mockReturnValue(requestSuccess);
- jest.spyOn(bv, 'renderFile').mockImplementation(() => {});
-
- bv.loadFile(endpoint)
- .then(() => {
- expect(bv.renderFile).toHaveBeenCalledWith(mockArrayBuffer);
- })
- .then(done)
- .catch(done.fail);
- });
-
- it('should not call `renderFile` on request failure', (done) => {
- jest.spyOn(axios, 'get').mockReturnValue(Promise.reject());
- jest.spyOn(bv, 'renderFile').mockImplementation(() => {});
-
- bv.loadFile(endpoint)
- .then(() => {
- done.fail('Expected loadFile to throw error!');
- })
- .catch(() => {
- expect(bv.renderFile).not.toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
- });
- });
-
- describe('renderFile', () => {
- let container;
- let previews;
-
- beforeEach(() => {
- viewer = {
- appendChild: jest.fn(),
- };
- previews = [document.createElement('ul'), document.createElement('ul')];
-
- balsamiqViewer = {
- initDatabase: jest.fn(),
- getPreviews: jest.fn(),
- renderPreview: jest.fn(),
- };
- balsamiqViewer.viewer = viewer;
-
- balsamiqViewer.getPreviews.mockReturnValue(previews);
- balsamiqViewer.renderPreview.mockImplementation((preview) => preview);
- viewer.appendChild.mockImplementation((containerElement) => {
- container = containerElement;
- });
-
- BalsamiqViewer.prototype.renderFile.call(balsamiqViewer, mockArrayBuffer);
- });
-
- it('should call .initDatabase', () => {
- expect(balsamiqViewer.initDatabase).toHaveBeenCalledWith(mockArrayBuffer);
- });
-
- it('should call .getPreviews', () => {
- expect(balsamiqViewer.getPreviews).toHaveBeenCalled();
- });
-
- it('should call .renderPreview for each preview', () => {
- const allArgs = balsamiqViewer.renderPreview.mock.calls;
-
- expect(allArgs.length).toBe(2);
-
- previews.forEach((preview, i) => {
- expect(allArgs[i][0]).toBe(preview);
- });
- });
-
- it('should set the container HTML', () => {
- expect(container.innerHTML).toBe('<ul></ul><ul></ul>');
- });
-
- it('should add inline preview classes', () => {
- expect(container.classList[0]).toBe('list-inline');
- expect(container.classList[1]).toBe('previews');
- });
-
- it('should call viewer.appendChild', () => {
- expect(viewer.appendChild).toHaveBeenCalledWith(container);
- });
- });
-
- describe('initDatabase', () => {
- let uint8Array;
- let data;
-
- beforeEach(() => {
- uint8Array = {};
- data = 'data';
- balsamiqViewer = {};
- window.Uint8Array = jest.fn();
- window.Uint8Array.mockReturnValue(uint8Array);
-
- BalsamiqViewer.prototype.initDatabase.call(balsamiqViewer, data);
- });
-
- it('should instantiate Uint8Array', () => {
- expect(window.Uint8Array).toHaveBeenCalledWith(data);
- });
-
- it('should call sqljs.Database', () => {
- expect(sqljs.Database).toHaveBeenCalledWith(uint8Array);
- });
-
- it('should set .database', () => {
- expect(balsamiqViewer.database).not.toBe(null);
- });
- });
-
- describe('getPreviews', () => {
- let database;
- let thumbnails;
- let getPreviews;
-
- beforeEach(() => {
- database = {
- exec: jest.fn(),
- };
- thumbnails = [{ values: [0, 1, 2] }];
-
- balsamiqViewer = {
- database,
- };
-
- jest
- .spyOn(BalsamiqViewer, 'parsePreview')
- .mockImplementation((preview) => preview.toString());
- database.exec.mockReturnValue(thumbnails);
-
- getPreviews = BalsamiqViewer.prototype.getPreviews.call(balsamiqViewer);
- });
-
- it('should call database.exec', () => {
- expect(database.exec).toHaveBeenCalledWith('SELECT * FROM thumbnails');
- });
-
- it('should call .parsePreview for each value', () => {
- const allArgs = BalsamiqViewer.parsePreview.mock.calls;
-
- expect(allArgs.length).toBe(3);
-
- thumbnails[0].values.forEach((value, i) => {
- expect(allArgs[i][0]).toBe(value);
- });
- });
-
- it('should return an array of parsed values', () => {
- expect(getPreviews).toEqual(['0', '1', '2']);
- });
- });
-
- describe('getResource', () => {
- let database;
- let resourceID;
- let resource;
- let getResource;
-
- beforeEach(() => {
- database = {
- exec: jest.fn(),
- };
- resourceID = 4;
- resource = ['resource'];
-
- balsamiqViewer = {
- database,
- };
-
- database.exec.mockReturnValue(resource);
-
- getResource = BalsamiqViewer.prototype.getResource.call(balsamiqViewer, resourceID);
- });
-
- it('should call database.exec', () => {
- expect(database.exec).toHaveBeenCalledWith(
- `SELECT * FROM resources WHERE id = '${resourceID}'`,
- );
- });
-
- it('should return the selected resource', () => {
- expect(getResource).toBe(resource[0]);
- });
- });
-
- describe('renderPreview', () => {
- let previewElement;
- let innerHTML;
- let preview;
- let renderPreview;
-
- beforeEach(() => {
- innerHTML = '<a>innerHTML</a>';
- previewElement = {
- outerHTML: '<p>outerHTML</p>',
- classList: {
- add: jest.fn(),
- },
- };
- preview = {};
-
- balsamiqViewer = {
- renderTemplate: jest.fn(),
- };
-
- jest.spyOn(document, 'createElement').mockReturnValue(previewElement);
- balsamiqViewer.renderTemplate.mockReturnValue(innerHTML);
-
- renderPreview = BalsamiqViewer.prototype.renderPreview.call(balsamiqViewer, preview);
- });
-
- it('should call classList.add', () => {
- expect(previewElement.classList.add).toHaveBeenCalledWith('preview');
- });
-
- it('should call .renderTemplate', () => {
- expect(balsamiqViewer.renderTemplate).toHaveBeenCalledWith(preview);
- });
-
- it('should set .innerHTML', () => {
- expect(previewElement.innerHTML).toBe(innerHTML);
- });
-
- it('should return element', () => {
- expect(renderPreview).toBe(previewElement);
- });
- });
-
- describe('renderTemplate', () => {
- let preview;
- let name;
- let resource;
- let template;
- let renderTemplate;
-
- beforeEach(() => {
- preview = { resourceID: 1, image: 'image' };
- name = 'name';
- resource = 'resource';
- template = `
- <div class="card">
- <div class="card-header">name</div>
- <div class="card-body">
- <img class="img-thumbnail" src="data:image/png;base64,image"/>
- </div>
- </div>
- `;
-
- balsamiqViewer = {
- getResource: jest.fn(),
- };
-
- jest.spyOn(BalsamiqViewer, 'parseTitle').mockReturnValue(name);
- balsamiqViewer.getResource.mockReturnValue(resource);
-
- renderTemplate = BalsamiqViewer.prototype.renderTemplate.call(balsamiqViewer, preview);
- });
-
- it('should call .getResource', () => {
- expect(balsamiqViewer.getResource).toHaveBeenCalledWith(preview.resourceID);
- });
-
- it('should call .parseTitle', () => {
- expect(BalsamiqViewer.parseTitle).toHaveBeenCalledWith(resource);
- });
-
- it('should return the template string', () => {
- expect(renderTemplate.replace(/\s/g, '')).toEqual(template.replace(/\s/g, ''));
- });
- });
-
- describe('parsePreview', () => {
- let preview;
- let parsePreview;
-
- beforeEach(() => {
- preview = ['{}', '{ "id": 1 }'];
-
- jest.spyOn(JSON, 'parse');
-
- parsePreview = BalsamiqViewer.parsePreview(preview);
- });
-
- ClassSpecHelper.itShouldBeAStaticMethod(BalsamiqViewer, 'parsePreview');
-
- it('should return the parsed JSON', () => {
- expect(parsePreview).toEqual(JSON.parse('{ "id": 1 }'));
- });
- });
-
- describe('parseTitle', () => {
- let title;
- let parseTitle;
-
- beforeEach(() => {
- title = { values: [['{}', '{}', '{"name":"name"}']] };
-
- jest.spyOn(JSON, 'parse');
-
- parseTitle = BalsamiqViewer.parseTitle(title);
- });
-
- ClassSpecHelper.itShouldBeAStaticMethod(BalsamiqViewer, 'parsePreview');
-
- it('should return the name value', () => {
- expect(parseTitle).toBe('name');
- });
- });
-});
diff --git a/spec/frontend/boards/boards_util_spec.js b/spec/frontend/boards/boards_util_spec.js
index d45b6e35a45..ab3cf072357 100644
--- a/spec/frontend/boards/boards_util_spec.js
+++ b/spec/frontend/boards/boards_util_spec.js
@@ -1,6 +1,12 @@
import { formatIssueInput, filterVariables } from '~/boards/boards_util';
describe('formatIssueInput', () => {
+ const issueInput = {
+ labelIds: ['gid://gitlab/GroupLabel/5'],
+ projectPath: 'gitlab-org/gitlab-test',
+ id: 'gid://gitlab/Issue/11',
+ };
+
it('correctly merges boardConfig into the issue', () => {
const boardConfig = {
labels: [
@@ -14,12 +20,6 @@ describe('formatIssueInput', () => {
weight: 1,
};
- const issueInput = {
- labelIds: ['gid://gitlab/GroupLabel/5'],
- projectPath: 'gitlab-org/gitlab-test',
- id: 'gid://gitlab/Issue/11',
- };
-
const result = formatIssueInput(issueInput, boardConfig);
expect(result).toEqual({
projectPath: 'gitlab-org/gitlab-test',
@@ -27,8 +27,26 @@ describe('formatIssueInput', () => {
labelIds: ['gid://gitlab/GroupLabel/5', 'gid://gitlab/GroupLabel/44'],
assigneeIds: ['gid://gitlab/User/55'],
milestoneId: 'gid://gitlab/Milestone/66',
+ weight: 1,
});
});
+
+ it('does not add weight to input if weight is NONE', () => {
+ const boardConfig = {
+ weight: -2, // NO_WEIGHT
+ };
+
+ const result = formatIssueInput(issueInput, boardConfig);
+ const expected = {
+ projectPath: 'gitlab-org/gitlab-test',
+ id: 'gid://gitlab/Issue/11',
+ labelIds: ['gid://gitlab/GroupLabel/5'],
+ assigneeIds: [],
+ milestoneId: undefined,
+ };
+
+ expect(result).toEqual(expected);
+ });
});
describe('filterVariables', () => {
diff --git a/spec/frontend/boards/components/board_filtered_search_spec.js b/spec/frontend/boards/components/board_filtered_search_spec.js
index 85ba703a6ee..731578e15a3 100644
--- a/spec/frontend/boards/components/board_filtered_search_spec.js
+++ b/spec/frontend/boards/components/board_filtered_search_spec.js
@@ -124,7 +124,7 @@ describe('BoardFilteredSearch', () => {
{ type: 'milestone', value: { data: 'New Milestone', operator: '=' } },
{ type: 'type', value: { data: 'INCIDENT', operator: '=' } },
{ type: 'weight', value: { data: '2', operator: '=' } },
- { type: 'iteration', value: { data: '3341', operator: '=' } },
+ { type: 'iteration', value: { data: 'Any&3', operator: '=' } },
{ type: 'release', value: { data: 'v1.0.0', operator: '=' } },
];
jest.spyOn(urlUtility, 'updateHistory');
@@ -134,7 +134,7 @@ describe('BoardFilteredSearch', () => {
title: '',
replace: true,
url:
- 'http://test.host/?author_username=root&label_name[]=label&label_name[]=label%262&assignee_username=root&milestone_title=New%20Milestone&iteration_id=3341&types=INCIDENT&weight=2&release_tag=v1.0.0',
+ 'http://test.host/?author_username=root&label_name[]=label&label_name[]=label%262&assignee_username=root&milestone_title=New%20Milestone&iteration_id=Any&iteration_cadence_id=3&types=INCIDENT&weight=2&release_tag=v1.0.0',
});
});
diff --git a/spec/frontend/boards/components/board_form_spec.js b/spec/frontend/boards/components/board_form_spec.js
index c976ba7525b..6a659623b53 100644
--- a/spec/frontend/boards/components/board_form_spec.js
+++ b/spec/frontend/boards/components/board_form_spec.js
@@ -62,7 +62,7 @@ describe('BoardForm', () => {
};
},
provide: {
- rootPath: 'root',
+ boardBaseUrl: 'root',
},
mocks: {
$apollo: {
diff --git a/spec/frontend/boards/components/board_top_bar_spec.js b/spec/frontend/boards/components/board_top_bar_spec.js
new file mode 100644
index 00000000000..997768a0cc7
--- /dev/null
+++ b/spec/frontend/boards/components/board_top_bar_spec.js
@@ -0,0 +1,88 @@
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import Vuex from 'vuex';
+
+import BoardTopBar from '~/boards/components/board_top_bar.vue';
+import BoardAddNewColumnTrigger from '~/boards/components/board_add_new_column_trigger.vue';
+import BoardsSelector from '~/boards/components/boards_selector.vue';
+import ConfigToggle from '~/boards/components/config_toggle.vue';
+import IssueBoardFilteredSearch from '~/boards/components/issue_board_filtered_search.vue';
+import NewBoardButton from '~/boards/components/new_board_button.vue';
+import ToggleFocus from '~/boards/components/toggle_focus.vue';
+
+describe('BoardTopBar', () => {
+ let wrapper;
+
+ Vue.use(Vuex);
+
+ const createStore = ({ mockGetters = {} } = {}) => {
+ return new Vuex.Store({
+ state: {},
+ getters: {
+ isEpicBoard: () => false,
+ ...mockGetters,
+ },
+ });
+ };
+
+ const createComponent = ({ provide = {}, mockGetters = {} } = {}) => {
+ const store = createStore({ mockGetters });
+ wrapper = shallowMount(BoardTopBar, {
+ store,
+ provide: {
+ swimlanesFeatureAvailable: false,
+ canAdminList: false,
+ isSignedIn: false,
+ fullPath: 'gitlab-org',
+ boardType: 'group',
+ releasesFetchPath: '/releases',
+ ...provide,
+ },
+ stubs: { IssueBoardFilteredSearch },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('base template', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders BoardsSelector component', () => {
+ expect(wrapper.findComponent(BoardsSelector).exists()).toBe(true);
+ });
+
+ it('renders IssueBoardFilteredSearch component', () => {
+ expect(wrapper.findComponent(IssueBoardFilteredSearch).exists()).toBe(true);
+ });
+
+ it('renders NewBoardButton component', () => {
+ expect(wrapper.findComponent(NewBoardButton).exists()).toBe(true);
+ });
+
+ it('renders ConfigToggle component', () => {
+ expect(wrapper.findComponent(ConfigToggle).exists()).toBe(true);
+ });
+
+ it('renders ToggleFocus component', () => {
+ expect(wrapper.findComponent(ToggleFocus).exists()).toBe(true);
+ });
+
+ it('does not render BoardAddNewColumnTrigger component', () => {
+ expect(wrapper.findComponent(BoardAddNewColumnTrigger).exists()).toBe(false);
+ });
+ });
+
+ describe('when user can admin list', () => {
+ beforeEach(() => {
+ createComponent({ provide: { canAdminList: true } });
+ });
+
+ it('renders BoardAddNewColumnTrigger component', () => {
+ expect(wrapper.findComponent(BoardAddNewColumnTrigger).exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/boards/components/boards_selector_spec.js b/spec/frontend/boards/components/boards_selector_spec.js
index 0c044deb78c..f60d04af4fc 100644
--- a/spec/frontend/boards/components/boards_selector_spec.js
+++ b/spec/frontend/boards/components/boards_selector_spec.js
@@ -1,5 +1,4 @@
import { GlDropdown, GlLoadingIcon, GlDropdownSectionHeader } from '@gitlab/ui';
-import { mount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import Vuex from 'vuex';
@@ -14,6 +13,7 @@ import groupRecentBoardsQuery from '~/boards/graphql/group_recent_boards.query.g
import projectRecentBoardsQuery from '~/boards/graphql/project_recent_boards.query.graphql';
import defaultStore from '~/boards/stores';
import createMockApollo from 'helpers/mock_apollo_helper';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
import {
mockGroupBoardResponse,
mockProjectBoardResponse,
@@ -60,7 +60,7 @@ describe('BoardsSelector', () => {
searchBoxInput.trigger('input');
};
- const getDropdownItems = () => wrapper.findAll('.js-dropdown-item');
+ const getDropdownItems = () => wrapper.findAllByTestId('dropdown-item');
const getDropdownHeaders = () => wrapper.findAllComponents(GlDropdownSectionHeader);
const getLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findDropdown = () => wrapper.findComponent(GlDropdown);
@@ -100,11 +100,15 @@ describe('BoardsSelector', () => {
[groupRecentBoardsQuery, groupRecentBoardsQueryHandlerSuccess],
]);
- wrapper = mount(BoardsSelector, {
+ wrapper = mountExtended(BoardsSelector, {
store,
apolloProvider: fakeApollo,
propsData: {
throttleDuration,
+ },
+ attachTo: document.body,
+ provide: {
+ fullPath: '',
boardBaseUrl: `${TEST_HOST}/board/base/url`,
hasMissingBoards: false,
canAdminBoard: true,
@@ -112,10 +116,6 @@ describe('BoardsSelector', () => {
scopedIssueBoardFeatureEnabled: true,
weights: [],
},
- attachTo: document.body,
- provide: {
- fullPath: '',
- },
});
};
diff --git a/spec/frontend/boards/components/issuable_title_spec.js b/spec/frontend/boards/components/issuable_title_spec.js
deleted file mode 100644
index 4b7f491b998..00000000000
--- a/spec/frontend/boards/components/issuable_title_spec.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import IssuableTitle from '~/boards/components/issuable_title.vue';
-
-describe('IssuableTitle', () => {
- let wrapper;
- const defaultProps = {
- title: 'One',
- refPath: 'path',
- };
- const createComponent = () => {
- wrapper = shallowMount(IssuableTitle, {
- propsData: { ...defaultProps },
- });
- };
- const findIssueContent = () => wrapper.find('[data-testid="issue-title"]');
-
- beforeEach(() => {
- createComponent();
- });
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
- it('renders a title of an issue in the sidebar', () => {
- expect(findIssueContent().text()).toContain('One');
- });
-
- it('renders a referencePath of an issue in the sidebar', () => {
- expect(findIssueContent().text()).toContain('path');
- });
-});
diff --git a/spec/frontend/boards/components/issue_board_filtered_search_spec.js b/spec/frontend/boards/components/issue_board_filtered_search_spec.js
index 76e8b84d8ef..e4a6a2b8b76 100644
--- a/spec/frontend/boards/components/issue_board_filtered_search_spec.js
+++ b/spec/frontend/boards/components/issue_board_filtered_search_spec.js
@@ -14,10 +14,11 @@ describe('IssueBoardFilter', () => {
const createComponent = ({ isSignedIn = false } = {}) => {
wrapper = shallowMount(IssueBoardFilteredSpec, {
- propsData: { fullPath: 'gitlab-org', boardType: 'group' },
provide: {
isSignedIn,
releasesFetchPath: '/releases',
+ fullPath: 'gitlab-org',
+ boardType: 'group',
},
});
};
diff --git a/spec/frontend/boards/components/issue_time_estimate_spec.js b/spec/frontend/boards/components/issue_time_estimate_spec.js
index 635964b6b4a..948a7a20f7f 100644
--- a/spec/frontend/boards/components/issue_time_estimate_spec.js
+++ b/spec/frontend/boards/components/issue_time_estimate_spec.js
@@ -5,6 +5,8 @@ import IssueTimeEstimate from '~/boards/components/issue_time_estimate.vue';
describe('Issue Time Estimate component', () => {
let wrapper;
+ const findIssueTimeEstimate = () => wrapper.find('[data-testid="issue-time-estimate"]');
+
afterEach(() => {
wrapper.destroy();
});
@@ -26,7 +28,7 @@ describe('Issue Time Estimate component', () => {
});
it('renders expanded time estimate in tooltip', () => {
- expect(wrapper.find('.js-issue-time-estimate').text()).toContain('2 weeks 3 days 1 minute');
+ expect(findIssueTimeEstimate().text()).toContain('2 weeks 3 days 1 minute');
});
it('prevents tooltip xss', async () => {
@@ -42,7 +44,7 @@ describe('Issue Time Estimate component', () => {
expect(alertSpy).not.toHaveBeenCalled();
expect(wrapper.find('time').text().trim()).toEqual('0m');
- expect(wrapper.find('.js-issue-time-estimate').text()).toContain('0m');
+ expect(findIssueTimeEstimate().text()).toContain('0m');
});
});
@@ -63,7 +65,7 @@ describe('Issue Time Estimate component', () => {
});
it('renders expanded time estimate in tooltip', () => {
- expect(wrapper.find('.js-issue-time-estimate').text()).toContain('104 hours 1 minute');
+ expect(findIssueTimeEstimate().text()).toContain('104 hours 1 minute');
});
});
});
diff --git a/spec/frontend/boards/components/item_count_spec.js b/spec/frontend/boards/components/item_count_spec.js
index 45980c36f1c..06cd3910fc0 100644
--- a/spec/frontend/boards/components/item_count_spec.js
+++ b/spec/frontend/boards/components/item_count_spec.js
@@ -29,7 +29,7 @@ describe('IssueCount', () => {
});
it('does not contains maxIssueCount in the template', () => {
- expect(vm.find('.js-max-issue-size').exists()).toBe(false);
+ expect(vm.find('.max-issue-size').exists()).toBe(false);
});
});
@@ -50,7 +50,7 @@ describe('IssueCount', () => {
});
it('contains maxIssueCount in the template', () => {
- expect(vm.find('.js-max-issue-size').text()).toEqual(String(maxIssueCount));
+ expect(vm.find('.max-issue-size').text()).toEqual(String(maxIssueCount));
});
it('does not have text-danger class when issueSize is less than maxIssueCount', () => {
@@ -75,7 +75,7 @@ describe('IssueCount', () => {
});
it('contains maxIssueCount in the template', () => {
- expect(vm.find('.js-max-issue-size').text()).toEqual(String(maxIssueCount));
+ expect(vm.find('.max-issue-size').text()).toEqual(String(maxIssueCount));
});
it('has text-danger class', () => {
diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js
index ad661a31556..eacf9db191e 100644
--- a/spec/frontend/boards/stores/actions_spec.js
+++ b/spec/frontend/boards/stores/actions_spec.js
@@ -166,31 +166,29 @@ describe('setFilters', () => {
});
describe('performSearch', () => {
- it('should dispatch setFilters, fetchLists and resetIssues action', (done) => {
- testAction(
+ it('should dispatch setFilters, fetchLists and resetIssues action', () => {
+ return testAction(
actions.performSearch,
{},
{},
[],
[{ type: 'setFilters', payload: {} }, { type: 'fetchLists' }, { type: 'resetIssues' }],
- done,
);
});
});
describe('setActiveId', () => {
- it('should commit mutation SET_ACTIVE_ID', (done) => {
+ it('should commit mutation SET_ACTIVE_ID', () => {
const state = {
activeId: inactiveId,
};
- testAction(
+ return testAction(
actions.setActiveId,
{ id: 1, sidebarType: 'something' },
state,
[{ type: types.SET_ACTIVE_ID, payload: { id: 1, sidebarType: 'something' } }],
[],
- done,
);
});
});
@@ -219,10 +217,10 @@ describe('fetchLists', () => {
const formattedLists = formatBoardLists(queryResponse.data.group.board.lists);
- it('should commit mutations RECEIVE_BOARD_LISTS_SUCCESS on success', (done) => {
+ it('should commit mutations RECEIVE_BOARD_LISTS_SUCCESS on success', () => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
- testAction(
+ return testAction(
actions.fetchLists,
{},
state,
@@ -233,14 +231,13 @@ describe('fetchLists', () => {
},
],
[],
- done,
);
});
- it('should commit mutations RECEIVE_BOARD_LISTS_FAILURE on failure', (done) => {
+ it('should commit mutations RECEIVE_BOARD_LISTS_FAILURE on failure', () => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(Promise.reject());
- testAction(
+ return testAction(
actions.fetchLists,
{},
state,
@@ -250,11 +247,10 @@ describe('fetchLists', () => {
},
],
[],
- done,
);
});
- it('dispatch createList action when backlog list does not exist and is not hidden', (done) => {
+ it('dispatch createList action when backlog list does not exist and is not hidden', () => {
queryResponse = {
data: {
group: {
@@ -269,7 +265,7 @@ describe('fetchLists', () => {
};
jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
- testAction(
+ return testAction(
actions.fetchLists,
{},
state,
@@ -280,7 +276,6 @@ describe('fetchLists', () => {
},
],
[{ type: 'createList', payload: { backlog: true } }],
- done,
);
});
@@ -951,10 +946,10 @@ describe('fetchItemsForList', () => {
});
});
- it('should commit mutations REQUEST_ITEMS_FOR_LIST and RECEIVE_ITEMS_FOR_LIST_SUCCESS on success', (done) => {
+ it('should commit mutations REQUEST_ITEMS_FOR_LIST and RECEIVE_ITEMS_FOR_LIST_SUCCESS on success', () => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
- testAction(
+ return testAction(
actions.fetchItemsForList,
{ listId },
state,
@@ -973,14 +968,13 @@ describe('fetchItemsForList', () => {
},
],
[],
- done,
);
});
- it('should commit mutations REQUEST_ITEMS_FOR_LIST and RECEIVE_ITEMS_FOR_LIST_FAILURE on failure', (done) => {
+ it('should commit mutations REQUEST_ITEMS_FOR_LIST and RECEIVE_ITEMS_FOR_LIST_FAILURE on failure', () => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(Promise.reject());
- testAction(
+ return testAction(
actions.fetchItemsForList,
{ listId },
state,
@@ -996,7 +990,6 @@ describe('fetchItemsForList', () => {
{ type: types.RECEIVE_ITEMS_FOR_LIST_FAILURE, payload: listId },
],
[],
- done,
);
});
});
@@ -1398,8 +1391,8 @@ describe('setAssignees', () => {
const node = { username: 'name' };
describe('when succeeds', () => {
- it('calls the correct mutation with the correct values', (done) => {
- testAction(
+ it('calls the correct mutation with the correct values', () => {
+ return testAction(
actions.setAssignees,
{ assignees: [node], iid: '1' },
{ commit: () => {} },
@@ -1410,7 +1403,6 @@ describe('setAssignees', () => {
},
],
[],
- done,
);
});
});
@@ -1728,7 +1720,7 @@ describe('setActiveItemSubscribed', () => {
projectPath: 'gitlab-org/gitlab-test',
};
- it('should commit subscribed status', (done) => {
+ it('should commit subscribed status', () => {
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: {
updateIssuableSubscription: {
@@ -1746,7 +1738,7 @@ describe('setActiveItemSubscribed', () => {
value: subscribedState,
};
- testAction(
+ return testAction(
actions.setActiveItemSubscribed,
input,
{ ...state, ...getters },
@@ -1757,7 +1749,6 @@ describe('setActiveItemSubscribed', () => {
},
],
[],
- done,
);
});
@@ -1783,7 +1774,7 @@ describe('setActiveItemTitle', () => {
projectPath: 'h/b',
};
- it('should commit title after setting the issue', (done) => {
+ it('should commit title after setting the issue', () => {
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: {
updateIssuableTitle: {
@@ -1801,7 +1792,7 @@ describe('setActiveItemTitle', () => {
value: testTitle,
};
- testAction(
+ return testAction(
actions.setActiveItemTitle,
input,
{ ...state, ...getters },
@@ -1812,7 +1803,6 @@ describe('setActiveItemTitle', () => {
},
],
[],
- done,
);
});
@@ -1829,14 +1819,14 @@ describe('setActiveItemConfidential', () => {
const state = { boardItems: { [mockIssue.id]: mockIssue } };
const getters = { activeBoardItem: mockIssue };
- it('set confidential value on board item', (done) => {
+ it('set confidential value on board item', () => {
const payload = {
itemId: getters.activeBoardItem.id,
prop: 'confidential',
value: true,
};
- testAction(
+ return testAction(
actions.setActiveItemConfidential,
true,
{ ...state, ...getters },
@@ -1847,7 +1837,6 @@ describe('setActiveItemConfidential', () => {
},
],
[],
- done,
);
});
});
@@ -1876,10 +1865,10 @@ describe('fetchGroupProjects', () => {
},
};
- it('should commit mutations REQUEST_GROUP_PROJECTS and RECEIVE_GROUP_PROJECTS_SUCCESS on success', (done) => {
+ it('should commit mutations REQUEST_GROUP_PROJECTS and RECEIVE_GROUP_PROJECTS_SUCCESS on success', () => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
- testAction(
+ return testAction(
actions.fetchGroupProjects,
{},
state,
@@ -1894,14 +1883,13 @@ describe('fetchGroupProjects', () => {
},
],
[],
- done,
);
});
- it('should commit mutations REQUEST_GROUP_PROJECTS and RECEIVE_GROUP_PROJECTS_FAILURE on failure', (done) => {
+ it('should commit mutations REQUEST_GROUP_PROJECTS and RECEIVE_GROUP_PROJECTS_FAILURE on failure', () => {
jest.spyOn(gqlClient, 'query').mockRejectedValue();
- testAction(
+ return testAction(
actions.fetchGroupProjects,
{},
state,
@@ -1915,16 +1903,15 @@ describe('fetchGroupProjects', () => {
},
],
[],
- done,
);
});
});
describe('setSelectedProject', () => {
- it('should commit mutation SET_SELECTED_PROJECT', (done) => {
+ it('should commit mutation SET_SELECTED_PROJECT', () => {
const project = mockGroupProjects[0];
- testAction(
+ return testAction(
actions.setSelectedProject,
project,
{},
@@ -1935,7 +1922,6 @@ describe('setSelectedProject', () => {
},
],
[],
- done,
);
});
});
diff --git a/spec/frontend/captcha/apollo_captcha_link_spec.js b/spec/frontend/captcha/apollo_captcha_link_spec.js
index eab52344d1f..cd32e63d00c 100644
--- a/spec/frontend/captcha/apollo_captcha_link_spec.js
+++ b/spec/frontend/captcha/apollo_captcha_link_spec.js
@@ -95,70 +95,82 @@ describe('apolloCaptchaLink', () => {
return { operationName: 'operation', variables: {}, setContext: mockContext };
}
- it('successful responses are passed through', (done) => {
+ it('successful responses are passed through', () => {
setupLink(SUCCESS_RESPONSE);
- link.request(mockOperation()).subscribe((result) => {
- expect(result).toEqual(SUCCESS_RESPONSE);
- expect(mockLinkImplementation).toHaveBeenCalledTimes(1);
- expect(waitForCaptchaToBeSolved).not.toHaveBeenCalled();
- done();
+
+ return new Promise((resolve) => {
+ link.request(mockOperation()).subscribe((result) => {
+ expect(result).toEqual(SUCCESS_RESPONSE);
+ expect(mockLinkImplementation).toHaveBeenCalledTimes(1);
+ expect(waitForCaptchaToBeSolved).not.toHaveBeenCalled();
+ resolve();
+ });
});
});
- it('non-spam related errors are passed through', (done) => {
+ it('non-spam related errors are passed through', () => {
setupLink(NON_CAPTCHA_ERROR_RESPONSE);
- link.request(mockOperation()).subscribe((result) => {
- expect(result).toEqual(NON_CAPTCHA_ERROR_RESPONSE);
- expect(mockLinkImplementation).toHaveBeenCalledTimes(1);
- expect(mockContext).not.toHaveBeenCalled();
- expect(waitForCaptchaToBeSolved).not.toHaveBeenCalled();
- done();
+
+ return new Promise((resolve) => {
+ link.request(mockOperation()).subscribe((result) => {
+ expect(result).toEqual(NON_CAPTCHA_ERROR_RESPONSE);
+ expect(mockLinkImplementation).toHaveBeenCalledTimes(1);
+ expect(mockContext).not.toHaveBeenCalled();
+ expect(waitForCaptchaToBeSolved).not.toHaveBeenCalled();
+ resolve();
+ });
});
});
- it('unresolvable spam errors are passed through', (done) => {
+ it('unresolvable spam errors are passed through', () => {
setupLink(SPAM_ERROR_RESPONSE);
- link.request(mockOperation()).subscribe((result) => {
- expect(result).toEqual(SPAM_ERROR_RESPONSE);
- expect(mockLinkImplementation).toHaveBeenCalledTimes(1);
- expect(mockContext).not.toHaveBeenCalled();
- expect(waitForCaptchaToBeSolved).not.toHaveBeenCalled();
- done();
+ return new Promise((resolve) => {
+ link.request(mockOperation()).subscribe((result) => {
+ expect(result).toEqual(SPAM_ERROR_RESPONSE);
+ expect(mockLinkImplementation).toHaveBeenCalledTimes(1);
+ expect(mockContext).not.toHaveBeenCalled();
+ expect(waitForCaptchaToBeSolved).not.toHaveBeenCalled();
+ resolve();
+ });
});
});
describe('resolvable spam errors', () => {
- it('re-submits request with spam headers if the captcha modal was solved correctly', (done) => {
+ it('re-submits request with spam headers if the captcha modal was solved correctly', () => {
waitForCaptchaToBeSolved.mockResolvedValue(CAPTCHA_RESPONSE);
setupLink(CAPTCHA_ERROR_RESPONSE, SUCCESS_RESPONSE);
- link.request(mockOperation()).subscribe((result) => {
- expect(result).toEqual(SUCCESS_RESPONSE);
- expect(waitForCaptchaToBeSolved).toHaveBeenCalledWith(CAPTCHA_SITE_KEY);
- expect(mockContext).toHaveBeenCalledWith({
- headers: {
- 'X-GitLab-Captcha-Response': CAPTCHA_RESPONSE,
- 'X-GitLab-Spam-Log-Id': SPAM_LOG_ID,
- },
+ return new Promise((resolve) => {
+ link.request(mockOperation()).subscribe((result) => {
+ expect(result).toEqual(SUCCESS_RESPONSE);
+ expect(waitForCaptchaToBeSolved).toHaveBeenCalledWith(CAPTCHA_SITE_KEY);
+ expect(mockContext).toHaveBeenCalledWith({
+ headers: {
+ 'X-GitLab-Captcha-Response': CAPTCHA_RESPONSE,
+ 'X-GitLab-Spam-Log-Id': SPAM_LOG_ID,
+ },
+ });
+ expect(mockLinkImplementation).toHaveBeenCalledTimes(2);
+ resolve();
});
- expect(mockLinkImplementation).toHaveBeenCalledTimes(2);
- done();
});
});
- it('throws error if the captcha modal was not solved correctly', (done) => {
+ it('throws error if the captcha modal was not solved correctly', () => {
const error = new UnsolvedCaptchaError();
waitForCaptchaToBeSolved.mockRejectedValue(error);
setupLink(CAPTCHA_ERROR_RESPONSE, SUCCESS_RESPONSE);
- link.request(mockOperation()).subscribe({
- next: done.catch,
- error: (result) => {
- expect(result).toEqual(error);
- expect(waitForCaptchaToBeSolved).toHaveBeenCalledWith(CAPTCHA_SITE_KEY);
- expect(mockContext).not.toHaveBeenCalled();
- expect(mockLinkImplementation).toHaveBeenCalledTimes(1);
- done();
- },
+ return new Promise((resolve, reject) => {
+ link.request(mockOperation()).subscribe({
+ next: reject,
+ error: (result) => {
+ expect(result).toEqual(error);
+ expect(waitForCaptchaToBeSolved).toHaveBeenCalledWith(CAPTCHA_SITE_KEY);
+ expect(mockContext).not.toHaveBeenCalled();
+ expect(mockLinkImplementation).toHaveBeenCalledTimes(1);
+ resolve();
+ },
+ });
});
});
});
diff --git a/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js b/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js
index 085ab1c0c30..2fedbbecd64 100644
--- a/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js
+++ b/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js
@@ -36,7 +36,7 @@ describe('Ci variable modal', () => {
const findAddorUpdateButton = () =>
findModal()
.findAll(GlButton)
- .wrappers.find((button) => button.props('variant') === 'success');
+ .wrappers.find((button) => button.props('variant') === 'confirm');
const deleteVariableButton = () =>
findModal()
.findAll(GlButton)
diff --git a/spec/frontend/ci_variable_list/store/actions_spec.js b/spec/frontend/ci_variable_list/store/actions_spec.js
index 426e6cae8fb..eb31fcd3ef4 100644
--- a/spec/frontend/ci_variable_list/store/actions_spec.js
+++ b/spec/frontend/ci_variable_list/store/actions_spec.js
@@ -86,10 +86,10 @@ describe('CI variable list store actions', () => {
});
describe('deleteVariable', () => {
- it('dispatch correct actions on successful deleted variable', (done) => {
+ it('dispatch correct actions on successful deleted variable', () => {
mock.onPatch(state.endpoint).reply(200);
- testAction(
+ return testAction(
actions.deleteVariable,
{},
state,
@@ -99,16 +99,13 @@ describe('CI variable list store actions', () => {
{ type: 'receiveDeleteVariableSuccess' },
{ type: 'fetchVariables' },
],
- () => {
- done();
- },
);
});
- it('should show flash error and set error in state on delete failure', (done) => {
+ it('should show flash error and set error in state on delete failure', async () => {
mock.onPatch(state.endpoint).reply(500, '');
- testAction(
+ await testAction(
actions.deleteVariable,
{},
state,
@@ -120,19 +117,16 @@ describe('CI variable list store actions', () => {
payload: payloadError,
},
],
- () => {
- expect(createFlash).toHaveBeenCalled();
- done();
- },
);
+ expect(createFlash).toHaveBeenCalled();
});
});
describe('updateVariable', () => {
- it('dispatch correct actions on successful updated variable', (done) => {
+ it('dispatch correct actions on successful updated variable', () => {
mock.onPatch(state.endpoint).reply(200);
- testAction(
+ return testAction(
actions.updateVariable,
{},
state,
@@ -142,16 +136,13 @@ describe('CI variable list store actions', () => {
{ type: 'receiveUpdateVariableSuccess' },
{ type: 'fetchVariables' },
],
- () => {
- done();
- },
);
});
- it('should show flash error and set error in state on update failure', (done) => {
+ it('should show flash error and set error in state on update failure', async () => {
mock.onPatch(state.endpoint).reply(500, '');
- testAction(
+ await testAction(
actions.updateVariable,
mockVariable,
state,
@@ -163,19 +154,16 @@ describe('CI variable list store actions', () => {
payload: payloadError,
},
],
- () => {
- expect(createFlash).toHaveBeenCalled();
- done();
- },
);
+ expect(createFlash).toHaveBeenCalled();
});
});
describe('addVariable', () => {
- it('dispatch correct actions on successful added variable', (done) => {
+ it('dispatch correct actions on successful added variable', () => {
mock.onPatch(state.endpoint).reply(200);
- testAction(
+ return testAction(
actions.addVariable,
{},
state,
@@ -185,16 +173,13 @@ describe('CI variable list store actions', () => {
{ type: 'receiveAddVariableSuccess' },
{ type: 'fetchVariables' },
],
- () => {
- done();
- },
);
});
- it('should show flash error and set error in state on add failure', (done) => {
+ it('should show flash error and set error in state on add failure', async () => {
mock.onPatch(state.endpoint).reply(500, '');
- testAction(
+ await testAction(
actions.addVariable,
{},
state,
@@ -206,19 +191,16 @@ describe('CI variable list store actions', () => {
payload: payloadError,
},
],
- () => {
- expect(createFlash).toHaveBeenCalled();
- done();
- },
);
+ expect(createFlash).toHaveBeenCalled();
});
});
describe('fetchVariables', () => {
- it('dispatch correct actions on fetchVariables', (done) => {
+ it('dispatch correct actions on fetchVariables', () => {
mock.onGet(state.endpoint).reply(200, { variables: mockData.mockVariables });
- testAction(
+ return testAction(
actions.fetchVariables,
{},
state,
@@ -230,29 +212,24 @@ describe('CI variable list store actions', () => {
payload: prepareDataForDisplay(mockData.mockVariables),
},
],
- () => {
- done();
- },
);
});
- it('should show flash error and set error in state on fetch variables failure', (done) => {
+ it('should show flash error and set error in state on fetch variables failure', async () => {
mock.onGet(state.endpoint).reply(500);
- testAction(actions.fetchVariables, {}, state, [], [{ type: 'requestVariables' }], () => {
- expect(createFlash).toHaveBeenCalledWith({
- message: 'There was an error fetching the variables.',
- });
- done();
+ await testAction(actions.fetchVariables, {}, state, [], [{ type: 'requestVariables' }]);
+ expect(createFlash).toHaveBeenCalledWith({
+ message: 'There was an error fetching the variables.',
});
});
});
describe('fetchEnvironments', () => {
- it('dispatch correct actions on fetchEnvironments', (done) => {
+ it('dispatch correct actions on fetchEnvironments', () => {
Api.environments = jest.fn().mockResolvedValue({ data: mockData.mockEnvironments });
- testAction(
+ return testAction(
actions.fetchEnvironments,
{},
state,
@@ -264,28 +241,17 @@ describe('CI variable list store actions', () => {
payload: prepareEnvironments(mockData.mockEnvironments),
},
],
- () => {
- done();
- },
);
});
- it('should show flash error and set error in state on fetch environments failure', (done) => {
+ it('should show flash error and set error in state on fetch environments failure', async () => {
Api.environments = jest.fn().mockRejectedValue();
- testAction(
- actions.fetchEnvironments,
- {},
- state,
- [],
- [{ type: 'requestEnvironments' }],
- () => {
- expect(createFlash).toHaveBeenCalledWith({
- message: 'There was an error fetching the environments information.',
- });
- done();
- },
- );
+ await testAction(actions.fetchEnvironments, {}, state, [], [{ type: 'requestEnvironments' }]);
+
+ expect(createFlash).toHaveBeenCalledWith({
+ message: 'There was an error fetching the environments information.',
+ });
});
});
diff --git a/spec/frontend/clusters_list/components/agent_empty_state_spec.js b/spec/frontend/clusters_list/components/agent_empty_state_spec.js
index ed2a0d0b97b..22775aa6603 100644
--- a/spec/frontend/clusters_list/components/agent_empty_state_spec.js
+++ b/spec/frontend/clusters_list/components/agent_empty_state_spec.js
@@ -1,8 +1,6 @@
-import { GlEmptyState, GlSprintf, GlLink, GlButton } from '@gitlab/ui';
+import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui';
import AgentEmptyState from '~/clusters_list/components/agent_empty_state.vue';
-import { INSTALL_AGENT_MODAL_ID } from '~/clusters_list/constants';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { helpPagePath } from '~/helpers/help_page_helper';
const emptyStateImage = '/path/to/image';
@@ -15,16 +13,12 @@ describe('AgentEmptyStateComponent', () => {
};
const findInstallDocsLink = () => wrapper.findComponent(GlLink);
- const findIntegrationButton = () => wrapper.findComponent(GlButton);
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
beforeEach(() => {
wrapper = shallowMountExtended(AgentEmptyState, {
provide: provideData,
- directives: {
- GlModalDirective: createMockDirective(),
- },
- stubs: { GlEmptyState, GlSprintf },
+ stubs: { GlSprintf },
});
});
@@ -38,17 +32,7 @@ describe('AgentEmptyStateComponent', () => {
expect(findEmptyState().exists()).toBe(true);
});
- it('renders button for the agent registration', () => {
- expect(findIntegrationButton().exists()).toBe(true);
- });
-
it('renders correct href attributes for the docs link', () => {
expect(findInstallDocsLink().attributes('href')).toBe(installDocsUrl);
});
-
- it('renders correct modal id for the agent registration modal', () => {
- const binding = getBinding(findIntegrationButton().element, 'gl-modal-directive');
-
- expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID);
- });
});
diff --git a/spec/frontend/clusters_list/components/agent_table_spec.js b/spec/frontend/clusters_list/components/agent_table_spec.js
index db723622a51..a466a35428a 100644
--- a/spec/frontend/clusters_list/components/agent_table_spec.js
+++ b/spec/frontend/clusters_list/components/agent_table_spec.js
@@ -9,7 +9,7 @@ import timeagoMixin from '~/vue_shared/mixins/timeago';
import { clusterAgents, connectedTimeNow, connectedTimeInactive } from './mock_data';
const defaultConfigHelpUrl =
- '/help/user/clusters/agent/install/index#create-an-agent-without-configuration-file';
+ '/help/user/clusters/agent/install/index#create-an-agent-configuration-file';
const provideData = {
gitlabVersion: '14.8',
diff --git a/spec/frontend/clusters_list/components/agent_token_spec.js b/spec/frontend/clusters_list/components/agent_token_spec.js
index a80c8ffaad4..7f6ec2eb3a2 100644
--- a/spec/frontend/clusters_list/components/agent_token_spec.js
+++ b/spec/frontend/clusters_list/components/agent_token_spec.js
@@ -53,7 +53,7 @@ describe('InstallAgentModal', () => {
});
it('shows agent token as an input value', () => {
- expect(findInput().props('value')).toBe('agent-token');
+ expect(findInput().props('value')).toBe(agentToken);
});
it('renders a copy button', () => {
@@ -65,12 +65,12 @@ describe('InstallAgentModal', () => {
});
it('shows warning alert', () => {
- expect(findAlert().props('title')).toBe(I18N_AGENT_TOKEN.tokenSingleUseWarningTitle);
+ expect(findAlert().text()).toBe(I18N_AGENT_TOKEN.tokenSingleUseWarningTitle);
});
it('shows code block with agent installation command', () => {
- expect(findCodeBlock().props('code')).toContain('--agent-token=agent-token');
- expect(findCodeBlock().props('code')).toContain('--kas-address=kas.example.com');
+ expect(findCodeBlock().props('code')).toContain(`--set config.token=${agentToken}`);
+ expect(findCodeBlock().props('code')).toContain(`--set config.kasAddress=${kasAddress}`);
});
});
});
diff --git a/spec/frontend/clusters_list/components/agents_spec.js b/spec/frontend/clusters_list/components/agents_spec.js
index 3cfa4b92bc0..92cfff7d490 100644
--- a/spec/frontend/clusters_list/components/agents_spec.js
+++ b/spec/frontend/clusters_list/components/agents_spec.js
@@ -308,7 +308,7 @@ describe('Agents', () => {
});
it('displays an alert message', () => {
- expect(findAlert().text()).toBe('An error occurred while loading your Agents');
+ expect(findAlert().text()).toBe('An error occurred while loading your agents');
});
});
diff --git a/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js b/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js
index eca2b1f5cb1..197735d3c77 100644
--- a/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js
+++ b/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js
@@ -1,5 +1,6 @@
import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { ENTER_KEY } from '~/lib/utils/keys';
import AvailableAgentsDropdown from '~/clusters_list/components/available_agents_dropdown.vue';
import { I18N_AVAILABLE_AGENTS_DROPDOWN } from '~/clusters_list/constants';
@@ -18,6 +19,7 @@ describe('AvailableAgentsDropdown', () => {
propsData,
stubs: { GlDropdown },
});
+ wrapper.vm.$refs.dropdown.hide = jest.fn();
};
afterEach(() => {
@@ -96,6 +98,25 @@ describe('AvailableAgentsDropdown', () => {
expect(findDropdown().props('text')).toBe('new-agent');
});
});
+
+ describe('click enter to register new agent without configuration', () => {
+ beforeEach(async () => {
+ await findSearchInput().vm.$emit('input', 'new-agent');
+ await findSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY }));
+ });
+
+ it('emits agentSelected with the name of the clicked agent', () => {
+ expect(wrapper.emitted('agentSelected')).toEqual([['new-agent']]);
+ });
+
+ it('marks the clicked item as selected', () => {
+ expect(findDropdown().props('text')).toBe('new-agent');
+ });
+
+ it('closes the dropdown', () => {
+ expect(wrapper.vm.$refs.dropdown.hide).toHaveBeenCalledTimes(1);
+ });
+ });
});
describe('registration in progress', () => {
diff --git a/spec/frontend/clusters_list/components/clusters_actions_spec.js b/spec/frontend/clusters_list/components/clusters_actions_spec.js
index 312df12ab5f..21dcc66c639 100644
--- a/spec/frontend/clusters_list/components/clusters_actions_spec.js
+++ b/spec/frontend/clusters_list/components/clusters_actions_spec.js
@@ -1,4 +1,4 @@
-import { GlDropdown, GlDropdownItem, GlButton } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem, GlTooltip } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ClustersActions from '~/clusters_list/components/clusters_actions.vue';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
@@ -7,12 +7,14 @@ import { INSTALL_AGENT_MODAL_ID, CLUSTERS_ACTIONS } from '~/clusters_list/consta
describe('ClustersActionsComponent', () => {
let wrapper;
- const newClusterPath = 'path/to/create/cluster';
+ const newClusterPath = 'path/to/add/cluster';
const addClusterPath = 'path/to/connect/existing/cluster';
+ const newClusterDocsPath = 'path/to/create/new/cluster';
const defaultProvide = {
newClusterPath,
addClusterPath,
+ newClusterDocsPath,
canAddCluster: true,
displayClusterAgents: true,
certificateBasedClustersEnabled: true,
@@ -20,12 +22,13 @@ describe('ClustersActionsComponent', () => {
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
+ const findTooltip = () => wrapper.findComponent(GlTooltip);
const findDropdownItemIds = () =>
findDropdownItems().wrappers.map((x) => x.attributes('data-testid'));
+ const findDropdownItemTexts = () => findDropdownItems().wrappers.map((x) => x.text());
const findNewClusterLink = () => wrapper.findByTestId('new-cluster-link');
+ const findNewClusterDocsLink = () => wrapper.findByTestId('create-cluster-link');
const findConnectClusterLink = () => wrapper.findByTestId('connect-cluster-link');
- const findConnectNewAgentLink = () => wrapper.findByTestId('connect-new-agent-link');
- const findConnectWithAgentButton = () => wrapper.findComponent(GlButton);
const createWrapper = (provideData = {}) => {
wrapper = shallowMountExtended(ClustersActions, {
@@ -35,7 +38,6 @@ describe('ClustersActionsComponent', () => {
},
directives: {
GlModalDirective: createMockDirective(),
- GlTooltip: createMockDirective(),
},
});
};
@@ -49,12 +51,15 @@ describe('ClustersActionsComponent', () => {
});
describe('when the certificate based clusters are enabled', () => {
it('renders actions menu', () => {
- expect(findDropdown().props('text')).toBe(CLUSTERS_ACTIONS.actionsButton);
+ expect(findDropdown().exists()).toBe(true);
});
- it('renders correct href attributes for the links', () => {
- expect(findNewClusterLink().attributes('href')).toBe(newClusterPath);
- expect(findConnectClusterLink().attributes('href')).toBe(addClusterPath);
+ it('shows split button in dropdown', () => {
+ expect(findDropdown().props('split')).toBe(true);
+ });
+
+ it("doesn't show the tooltip", () => {
+ expect(findTooltip().exists()).toBe(false);
});
describe('when user cannot add clusters', () => {
@@ -67,8 +72,7 @@ describe('ClustersActionsComponent', () => {
});
it('shows tooltip explaining why dropdown is disabled', () => {
- const tooltip = getBinding(findDropdown().element, 'gl-tooltip');
- expect(tooltip.value).toBe(CLUSTERS_ACTIONS.dropdownDisabledHint);
+ expect(findTooltip().attributes('title')).toBe(CLUSTERS_ACTIONS.dropdownDisabledHint);
});
it('does not bind split dropdown button', () => {
@@ -79,33 +83,36 @@ describe('ClustersActionsComponent', () => {
});
describe('when on project level', () => {
- it('renders a dropdown with 3 actions items', () => {
- expect(findDropdownItemIds()).toEqual([
- 'connect-new-agent-link',
- 'new-cluster-link',
- 'connect-cluster-link',
- ]);
+ it(`displays default action as ${CLUSTERS_ACTIONS.connectWithAgent}`, () => {
+ expect(findDropdown().props('text')).toBe(CLUSTERS_ACTIONS.connectWithAgent);
});
- it('renders correct modal id for the agent link', () => {
- const binding = getBinding(findConnectNewAgentLink().element, 'gl-modal-directive');
+ it('renders correct modal id for the default action', () => {
+ const binding = getBinding(findDropdown().element, 'gl-modal-directive');
expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID);
});
- it('shows tooltip', () => {
- const tooltip = getBinding(findDropdown().element, 'gl-tooltip');
- expect(tooltip.value).toBe(CLUSTERS_ACTIONS.connectWithAgent);
+ it('renders a dropdown with 3 actions items', () => {
+ expect(findDropdownItemIds()).toEqual([
+ 'create-cluster-link',
+ 'new-cluster-link',
+ 'connect-cluster-link',
+ ]);
});
- it('shows split button in dropdown', () => {
- expect(findDropdown().props('split')).toBe(true);
+ it('renders correct texts for the dropdown items', () => {
+ expect(findDropdownItemTexts()).toEqual([
+ CLUSTERS_ACTIONS.createCluster,
+ CLUSTERS_ACTIONS.createClusterCertificate,
+ CLUSTERS_ACTIONS.connectClusterCertificate,
+ ]);
});
- it('binds split button with modal id', () => {
- const binding = getBinding(findDropdown().element, 'gl-modal-directive');
-
- expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID);
+ it('renders correct href attributes for the links', () => {
+ expect(findNewClusterDocsLink().attributes('href')).toBe(newClusterDocsPath);
+ expect(findNewClusterLink().attributes('href')).toBe(newClusterPath);
+ expect(findConnectClusterLink().attributes('href')).toBe(addClusterPath);
});
});
@@ -114,17 +121,20 @@ describe('ClustersActionsComponent', () => {
createWrapper({ displayClusterAgents: false });
});
- it('renders a dropdown with 2 actions items', () => {
- expect(findDropdownItemIds()).toEqual(['new-cluster-link', 'connect-cluster-link']);
+ it(`displays default action as ${CLUSTERS_ACTIONS.connectClusterDeprecated}`, () => {
+ expect(findDropdown().props('text')).toBe(CLUSTERS_ACTIONS.connectClusterDeprecated);
});
- it('shows tooltip', () => {
- const tooltip = getBinding(findDropdown().element, 'gl-tooltip');
- expect(tooltip.value).toBe(CLUSTERS_ACTIONS.connectExistingCluster);
+ it('renders a dropdown with 1 action item', () => {
+ expect(findDropdownItemIds()).toEqual(['new-cluster-link']);
});
- it('does not show split button in dropdown', () => {
- expect(findDropdown().props('split')).toBe(false);
+ it('renders correct text for the dropdown item', () => {
+ expect(findDropdownItemTexts()).toEqual([CLUSTERS_ACTIONS.createClusterDeprecated]);
+ });
+
+ it('renders correct href attributes for the links', () => {
+ expect(findNewClusterLink().attributes('href')).toBe(newClusterPath);
});
it('does not bind dropdown button to modal', () => {
@@ -140,17 +150,26 @@ describe('ClustersActionsComponent', () => {
createWrapper({ certificateBasedClustersEnabled: false });
});
- it('it does not show the the dropdown', () => {
- expect(findDropdown().exists()).toBe(false);
+ it(`displays default action as ${CLUSTERS_ACTIONS.connectCluster}`, () => {
+ expect(findDropdown().props('text')).toBe(CLUSTERS_ACTIONS.connectCluster);
});
- it('shows the connect with agent button', () => {
- expect(findConnectWithAgentButton().props()).toMatchObject({
- disabled: !defaultProvide.canAddCluster,
- category: 'primary',
- variant: 'confirm',
- });
- expect(findConnectWithAgentButton().text()).toBe(CLUSTERS_ACTIONS.connectWithAgent);
+ it('renders correct modal id for the default action', () => {
+ const binding = getBinding(findDropdown().element, 'gl-modal-directive');
+
+ expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID);
+ });
+
+ it('renders a dropdown with 1 action item', () => {
+ expect(findDropdownItemIds()).toEqual(['create-cluster-link']);
+ });
+
+ it('renders correct text for the dropdown item', () => {
+ expect(findDropdownItemTexts()).toEqual([CLUSTERS_ACTIONS.createCluster]);
+ });
+
+ it('renders correct href attributes for the links', () => {
+ expect(findNewClusterDocsLink().attributes('href')).toBe(newClusterDocsPath);
});
});
});
diff --git a/spec/frontend/clusters_list/components/clusters_empty_state_spec.js b/spec/frontend/clusters_list/components/clusters_empty_state_spec.js
index fe2189296a6..2c3a224f3c8 100644
--- a/spec/frontend/clusters_list/components/clusters_empty_state_spec.js
+++ b/spec/frontend/clusters_list/components/clusters_empty_state_spec.js
@@ -1,10 +1,8 @@
-import { GlEmptyState, GlButton } from '@gitlab/ui';
+import { GlEmptyState } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ClustersEmptyState from '~/clusters_list/components/clusters_empty_state.vue';
-import ClusterStore from '~/clusters_list/store';
const clustersEmptyStateImage = 'path/to/svg';
-const addClusterPath = '/path/to/connect/cluster';
const emptyStateHelpText = 'empty state text';
describe('ClustersEmptyStateComponent', () => {
@@ -12,52 +10,28 @@ describe('ClustersEmptyStateComponent', () => {
const defaultProvideData = {
clustersEmptyStateImage,
- addClusterPath,
};
- const findButton = () => wrapper.findComponent(GlButton);
const findEmptyStateText = () => wrapper.findByTestId('clusters-empty-state-text');
- const createWrapper = ({
- provideData = { emptyStateHelpText: null },
- isChildComponent = false,
- canAddCluster = true,
- } = {}) => {
+ const createWrapper = ({ provideData = { emptyStateHelpText: null } } = {}) => {
wrapper = shallowMountExtended(ClustersEmptyState, {
- store: ClusterStore({ canAddCluster }),
- propsData: { isChildComponent },
provide: { ...defaultProvideData, ...provideData },
stubs: { GlEmptyState },
});
};
- beforeEach(() => {
- createWrapper();
- });
-
afterEach(() => {
wrapper.destroy();
});
- describe('when the component is loaded independently', () => {
- it('should render the action button', () => {
- expect(findButton().exists()).toBe(true);
- });
- });
-
describe('when the help text is not provided', () => {
- it('should not render the empty state text', () => {
- expect(findEmptyStateText().exists()).toBe(false);
- });
- });
-
- describe('when the component is loaded as a child component', () => {
beforeEach(() => {
- createWrapper({ isChildComponent: true });
+ createWrapper();
});
- it('should not render the action button', () => {
- expect(findButton().exists()).toBe(false);
+ it('should not render the empty state text', () => {
+ expect(findEmptyStateText().exists()).toBe(false);
});
});
@@ -70,13 +44,4 @@ describe('ClustersEmptyStateComponent', () => {
expect(findEmptyStateText().text()).toBe(emptyStateHelpText);
});
});
-
- describe('when the user cannot add clusters', () => {
- beforeEach(() => {
- createWrapper({ canAddCluster: false });
- });
- it('should disable the button', () => {
- expect(findButton().props('disabled')).toBe(true);
- });
- });
});
diff --git a/spec/frontend/clusters_list/components/clusters_view_all_spec.js b/spec/frontend/clusters_list/components/clusters_view_all_spec.js
index 2c1e3d909cc..b4eb9242003 100644
--- a/spec/frontend/clusters_list/components/clusters_view_all_spec.js
+++ b/spec/frontend/clusters_list/components/clusters_view_all_spec.js
@@ -1,24 +1,21 @@
-import { GlCard, GlLoadingIcon, GlButton, GlSprintf, GlBadge } from '@gitlab/ui';
+import { GlCard, GlLoadingIcon, GlSprintf, GlBadge } from '@gitlab/ui';
import Vue from 'vue';
import Vuex from 'vuex';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ClustersViewAll from '~/clusters_list/components/clusters_view_all.vue';
import Agents from '~/clusters_list/components/agents.vue';
import Clusters from '~/clusters_list/components/clusters.vue';
-import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import {
AGENT,
CERTIFICATE_BASED,
AGENT_CARD_INFO,
CERTIFICATE_BASED_CARD_INFO,
MAX_CLUSTERS_LIST,
- INSTALL_AGENT_MODAL_ID,
} from '~/clusters_list/constants';
import { sprintf } from '~/locale';
Vue.use(Vuex);
-const addClusterPath = '/path/to/add/cluster';
const defaultBranchName = 'default-branch';
describe('ClustersViewAllComponent', () => {
@@ -32,11 +29,6 @@ describe('ClustersViewAllComponent', () => {
defaultBranchName,
};
- const defaultProvide = {
- addClusterPath,
- canAddCluster: true,
- };
-
const entryData = {
loadingClusters: false,
totalClusters: 0,
@@ -46,37 +38,20 @@ describe('ClustersViewAllComponent', () => {
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findAgentsComponent = () => wrapper.findComponent(Agents);
const findClustersComponent = () => wrapper.findComponent(Clusters);
- const findInstallAgentButtonTooltip = () => wrapper.findByTestId('install-agent-button-tooltip');
- const findConnectExistingClusterButtonTooltip = () =>
- wrapper.findByTestId('connect-existing-cluster-button-tooltip');
const findCardsContainer = () => wrapper.findByTestId('clusters-cards-container');
const findAgentCardTitle = () => wrapper.findByTestId('agent-card-title');
const findRecommendedBadge = () => wrapper.findComponent(GlBadge);
const findClustersCardTitle = () => wrapper.findByTestId('clusters-card-title');
- const findFooterButton = (line) => findCards().at(line).findComponent(GlButton);
- const getTooltipText = (el) => {
- const binding = getBinding(el, 'gl-tooltip');
-
- return binding.value;
- };
const createStore = (initialState) =>
new Vuex.Store({
state: initialState,
});
- const createWrapper = ({ initialState = entryData, provideData } = {}) => {
+ const createWrapper = ({ initialState = entryData } = {}) => {
wrapper = shallowMountExtended(ClustersViewAll, {
store: createStore(initialState),
propsData,
- provide: {
- ...defaultProvide,
- ...provideData,
- },
- directives: {
- GlModalDirective: createMockDirective(),
- GlTooltip: createMockDirective(),
- },
stubs: { GlCard, GlSprintf },
});
};
@@ -138,25 +113,10 @@ describe('ClustersViewAllComponent', () => {
expect(findAgentsComponent().props('defaultBranchName')).toBe(defaultBranchName);
});
- it('should show install new Agent button in the footer', () => {
- expect(findFooterButton(0).exists()).toBe(true);
- expect(findFooterButton(0).props('disabled')).toBe(false);
- });
-
- it('does not show tooltip for install new Agent button', () => {
- expect(getTooltipText(findInstallAgentButtonTooltip().element)).toBe('');
- });
-
describe('when there are no agents', () => {
it('should show the empty title', () => {
expect(findAgentCardTitle().text()).toBe(AGENT_CARD_INFO.emptyTitle);
});
-
- it('should render correct modal id for the agent link', () => {
- const binding = getBinding(findFooterButton(0).element, 'gl-modal-directive');
-
- expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID);
- });
});
describe('when the agents are present', () => {
@@ -191,22 +151,6 @@ describe('ClustersViewAllComponent', () => {
});
});
});
-
- describe('when the user cannot add clusters', () => {
- beforeEach(() => {
- createWrapper({ provideData: { canAddCluster: false } });
- });
-
- it('should disable the button', () => {
- expect(findFooterButton(0).props('disabled')).toBe(true);
- });
-
- it('should show a tooltip explaining why the button is disabled', () => {
- expect(getTooltipText(findInstallAgentButtonTooltip().element)).toBe(
- AGENT_CARD_INFO.installAgentDisabledHint,
- );
- });
- });
});
describe('clusters tab', () => {
@@ -214,43 +158,10 @@ describe('ClustersViewAllComponent', () => {
expect(findClustersComponent().props('limit')).toBe(MAX_CLUSTERS_LIST);
});
- it('should pass the is-child-component prop', () => {
- expect(findClustersComponent().props('isChildComponent')).toBe(true);
- });
-
describe('when there are no clusters', () => {
it('should show the empty title', () => {
expect(findClustersCardTitle().text()).toBe(CERTIFICATE_BASED_CARD_INFO.emptyTitle);
});
-
- it('should show install new cluster button in the footer', () => {
- expect(findFooterButton(1).exists()).toBe(true);
- expect(findFooterButton(1).props('disabled')).toBe(false);
- });
-
- it('should render correct href for the button in the footer', () => {
- expect(findFooterButton(1).attributes('href')).toBe(addClusterPath);
- });
-
- it('does not show tooltip for install new cluster button', () => {
- expect(getTooltipText(findConnectExistingClusterButtonTooltip().element)).toBe('');
- });
- });
-
- describe('when the user cannot add clusters', () => {
- beforeEach(() => {
- createWrapper({ provideData: { canAddCluster: false } });
- });
-
- it('should disable the button', () => {
- expect(findFooterButton(1).props('disabled')).toBe(true);
- });
-
- it('should show a tooltip explaining why the button is disabled', () => {
- expect(getTooltipText(findConnectExistingClusterButtonTooltip().element)).toBe(
- CERTIFICATE_BASED_CARD_INFO.connectExistingClusterDisabledHint,
- );
- });
});
describe('when the clusters are present', () => {
diff --git a/spec/frontend/clusters_list/mocks/apollo.js b/spec/frontend/clusters_list/mocks/apollo.js
index b0f2978a230..3467b4c665c 100644
--- a/spec/frontend/clusters_list/mocks/apollo.js
+++ b/spec/frontend/clusters_list/mocks/apollo.js
@@ -1,4 +1,5 @@
const agent = {
+ __typename: 'ClusterAgent',
id: 'agent-id',
name: 'agent-name',
webPath: 'agent-webPath',
diff --git a/spec/frontend/clusters_list/store/actions_spec.js b/spec/frontend/clusters_list/store/actions_spec.js
index f4b69053e14..7663f329b3f 100644
--- a/spec/frontend/clusters_list/store/actions_spec.js
+++ b/spec/frontend/clusters_list/store/actions_spec.js
@@ -24,14 +24,12 @@ describe('Clusters store actions', () => {
captureException.mockRestore();
});
- it('should report sentry error', (done) => {
+ it('should report sentry error', async () => {
const sentryError = new Error('New Sentry Error');
const tag = 'sentryErrorTag';
- testAction(actions.reportSentryError, { error: sentryError, tag }, {}, [], [], () => {
- expect(captureException).toHaveBeenCalledWith(sentryError);
- done();
- });
+ await testAction(actions.reportSentryError, { error: sentryError, tag }, {}, [], []);
+ expect(captureException).toHaveBeenCalledWith(sentryError);
});
});
@@ -62,10 +60,10 @@ describe('Clusters store actions', () => {
afterEach(() => mock.restore());
- it('should commit SET_CLUSTERS_DATA with received response', (done) => {
+ it('should commit SET_CLUSTERS_DATA with received response', () => {
mock.onGet().reply(200, apiData, headers);
- testAction(
+ return testAction(
actions.fetchClusters,
{ endpoint: apiData.endpoint },
{},
@@ -75,14 +73,13 @@ describe('Clusters store actions', () => {
{ type: types.SET_LOADING_CLUSTERS, payload: false },
],
[],
- () => done(),
);
});
- it('should show flash on API error', (done) => {
+ it('should show flash on API error', async () => {
mock.onGet().reply(400, 'Not Found');
- testAction(
+ await testAction(
actions.fetchClusters,
{ endpoint: apiData.endpoint },
{},
@@ -100,13 +97,10 @@ describe('Clusters store actions', () => {
},
},
],
- () => {
- expect(createFlash).toHaveBeenCalledWith({
- message: expect.stringMatching('error'),
- });
- done();
- },
);
+ expect(createFlash).toHaveBeenCalledWith({
+ message: expect.stringMatching('error'),
+ });
});
describe('multiple api requests', () => {
@@ -128,8 +122,8 @@ describe('Clusters store actions', () => {
pollStop.mockRestore();
});
- it('should stop polling after MAX Requests', (done) => {
- testAction(
+ it('should stop polling after MAX Requests', async () => {
+ await testAction(
actions.fetchClusters,
{ endpoint: apiData.endpoint },
{},
@@ -139,47 +133,43 @@ describe('Clusters store actions', () => {
{ type: types.SET_LOADING_CLUSTERS, payload: false },
],
[],
- () => {
- expect(pollRequest).toHaveBeenCalledTimes(1);
+ );
+ expect(pollRequest).toHaveBeenCalledTimes(1);
+ expect(pollStop).toHaveBeenCalledTimes(0);
+ jest.advanceTimersByTime(pollInterval);
+
+ return waitForPromises()
+ .then(() => {
+ expect(pollRequest).toHaveBeenCalledTimes(2);
expect(pollStop).toHaveBeenCalledTimes(0);
jest.advanceTimersByTime(pollInterval);
-
- waitForPromises()
- .then(() => {
- expect(pollRequest).toHaveBeenCalledTimes(2);
- expect(pollStop).toHaveBeenCalledTimes(0);
- jest.advanceTimersByTime(pollInterval);
- })
- .then(() => waitForPromises())
- .then(() => {
- expect(pollRequest).toHaveBeenCalledTimes(MAX_REQUESTS);
- expect(pollStop).toHaveBeenCalledTimes(0);
- jest.advanceTimersByTime(pollInterval);
- })
- .then(() => waitForPromises())
- .then(() => {
- expect(pollRequest).toHaveBeenCalledTimes(MAX_REQUESTS + 1);
- // Stops poll once it exceeds the MAX_REQUESTS limit
- expect(pollStop).toHaveBeenCalledTimes(1);
- jest.advanceTimersByTime(pollInterval);
- })
- .then(() => waitForPromises())
- .then(() => {
- // Additional poll requests are not made once pollStop is called
- expect(pollRequest).toHaveBeenCalledTimes(MAX_REQUESTS + 1);
- expect(pollStop).toHaveBeenCalledTimes(1);
- })
- .then(done)
- .catch(done.fail);
- },
- );
+ })
+ .then(() => waitForPromises())
+ .then(() => {
+ expect(pollRequest).toHaveBeenCalledTimes(MAX_REQUESTS);
+ expect(pollStop).toHaveBeenCalledTimes(0);
+ jest.advanceTimersByTime(pollInterval);
+ })
+ .then(() => waitForPromises())
+ .then(() => {
+ expect(pollRequest).toHaveBeenCalledTimes(MAX_REQUESTS + 1);
+ // Stops poll once it exceeds the MAX_REQUESTS limit
+ expect(pollStop).toHaveBeenCalledTimes(1);
+ jest.advanceTimersByTime(pollInterval);
+ })
+ .then(() => waitForPromises())
+ .then(() => {
+ // Additional poll requests are not made once pollStop is called
+ expect(pollRequest).toHaveBeenCalledTimes(MAX_REQUESTS + 1);
+ expect(pollStop).toHaveBeenCalledTimes(1);
+ });
});
- it('should stop polling and report to Sentry when data is invalid', (done) => {
+ it('should stop polling and report to Sentry when data is invalid', async () => {
const badApiResponse = { clusters: {} };
mock.onGet().reply(200, badApiResponse, pollHeaders);
- testAction(
+ await testAction(
actions.fetchClusters,
{ endpoint: apiData.endpoint },
{},
@@ -202,12 +192,9 @@ describe('Clusters store actions', () => {
},
},
],
- () => {
- expect(pollRequest).toHaveBeenCalledTimes(1);
- expect(pollStop).toHaveBeenCalledTimes(1);
- done();
- },
);
+ expect(pollRequest).toHaveBeenCalledTimes(1);
+ expect(pollStop).toHaveBeenCalledTimes(1);
});
});
});
diff --git a/spec/frontend/code_navigation/components/app_spec.js b/spec/frontend/code_navigation/components/app_spec.js
index 0d7c0360e9b..f2f97092c5a 100644
--- a/spec/frontend/code_navigation/components/app_spec.js
+++ b/spec/frontend/code_navigation/components/app_spec.js
@@ -38,12 +38,17 @@ describe('Code navigation app component', () => {
const codeNavigationPath = 'code/nav/path.js';
const path = 'blob/path.js';
const definitionPathPrefix = 'path/prefix';
+ const wrapTextNodes = true;
- factory({}, { codeNavigationPath, blobPath: path, pathPrefix: definitionPathPrefix });
+ factory(
+ {},
+ { codeNavigationPath, blobPath: path, pathPrefix: definitionPathPrefix, wrapTextNodes },
+ );
expect(setInitialData).toHaveBeenCalledWith(expect.anything(), {
blobs: [{ codeNavigationPath, path }],
definitionPathPrefix,
+ wrapTextNodes,
});
});
diff --git a/spec/frontend/code_navigation/store/actions_spec.js b/spec/frontend/code_navigation/store/actions_spec.js
index 73f935deeca..c26416aca94 100644
--- a/spec/frontend/code_navigation/store/actions_spec.js
+++ b/spec/frontend/code_navigation/store/actions_spec.js
@@ -7,15 +7,16 @@ import axios from '~/lib/utils/axios_utils';
jest.mock('~/code_navigation/utils');
describe('Code navigation actions', () => {
+ const wrapTextNodes = true;
+
describe('setInitialData', () => {
- it('commits SET_INITIAL_DATA', (done) => {
- testAction(
+ it('commits SET_INITIAL_DATA', () => {
+ return testAction(
actions.setInitialData,
- { projectPath: 'test' },
+ { projectPath: 'test', wrapTextNodes },
{},
- [{ type: 'SET_INITIAL_DATA', payload: { projectPath: 'test' } }],
+ [{ type: 'SET_INITIAL_DATA', payload: { projectPath: 'test', wrapTextNodes } }],
[],
- done,
);
});
});
@@ -30,7 +31,7 @@ describe('Code navigation actions', () => {
const codeNavigationPath =
'gitlab-org/gitlab-shell/-/jobs/1114/artifacts/raw/lsif/cmd/check/main.go.json';
- const state = { blobs: [{ path: 'index.js', codeNavigationPath }] };
+ const state = { blobs: [{ path: 'index.js', codeNavigationPath }], wrapTextNodes };
beforeEach(() => {
window.gon = { api_version: '1' };
@@ -57,8 +58,8 @@ describe('Code navigation actions', () => {
]);
});
- it('commits REQUEST_DATA_SUCCESS with normalized data', (done) => {
- testAction(
+ it('commits REQUEST_DATA_SUCCESS with normalized data', () => {
+ return testAction(
actions.fetchData,
null,
state,
@@ -80,12 +81,11 @@ describe('Code navigation actions', () => {
},
],
[],
- done,
);
});
- it('calls addInteractionClass with data', (done) => {
- testAction(
+ it('calls addInteractionClass with data', () => {
+ return testAction(
actions.fetchData,
null,
state,
@@ -107,16 +107,17 @@ describe('Code navigation actions', () => {
},
],
[],
- )
- .then(() => {
- expect(addInteractionClass).toHaveBeenCalledWith('index.js', {
+ ).then(() => {
+ expect(addInteractionClass).toHaveBeenCalledWith({
+ path: 'index.js',
+ d: {
start_line: 0,
start_char: 0,
hover: { value: '123' },
- });
- })
- .then(done)
- .catch(done.fail);
+ },
+ wrapTextNodes,
+ });
+ });
});
});
@@ -125,14 +126,13 @@ describe('Code navigation actions', () => {
mock.onGet(codeNavigationPath).replyOnce(500);
});
- it('dispatches requestDataError', (done) => {
- testAction(
+ it('dispatches requestDataError', () => {
+ return testAction(
actions.fetchData,
null,
state,
[{ type: 'REQUEST_DATA' }],
[{ type: 'requestDataError' }],
- done,
);
});
});
@@ -144,14 +144,19 @@ describe('Code navigation actions', () => {
data: {
'index.js': { '0:0': 'test', '1:1': 'console.log' },
},
+ wrapTextNodes,
};
actions.showBlobInteractionZones({ state }, 'index.js');
expect(addInteractionClass).toHaveBeenCalled();
expect(addInteractionClass.mock.calls.length).toBe(2);
- expect(addInteractionClass.mock.calls[0]).toEqual(['index.js', 'test']);
- expect(addInteractionClass.mock.calls[1]).toEqual(['index.js', 'console.log']);
+ expect(addInteractionClass.mock.calls[0]).toEqual([
+ { path: 'index.js', d: 'test', wrapTextNodes },
+ ]);
+ expect(addInteractionClass.mock.calls[1]).toEqual([
+ { path: 'index.js', d: 'console.log', wrapTextNodes },
+ ]);
});
it('does not call addInteractionClass when no data exists', () => {
@@ -175,20 +180,20 @@ describe('Code navigation actions', () => {
target = document.querySelector('.js-test');
});
- it('returns early when no data exists', (done) => {
- testAction(actions.showDefinition, { target }, {}, [], [], done);
+ it('returns early when no data exists', () => {
+ return testAction(actions.showDefinition, { target }, {}, [], []);
});
- it('commits SET_CURRENT_DEFINITION when target is not code navitation element', (done) => {
- testAction(actions.showDefinition, { target }, { data: {} }, [], [], done);
+ it('commits SET_CURRENT_DEFINITION when target is not code navitation element', () => {
+ return testAction(actions.showDefinition, { target }, { data: {} }, [], []);
});
- it('commits SET_CURRENT_DEFINITION with LSIF data', (done) => {
+ it('commits SET_CURRENT_DEFINITION with LSIF data', () => {
target.classList.add('js-code-navigation');
target.setAttribute('data-line-index', '0');
target.setAttribute('data-char-index', '0');
- testAction(
+ return testAction(
actions.showDefinition,
{ target },
{ data: { 'index.js': { '0:0': { hover: 'test' } } } },
@@ -203,7 +208,6 @@ describe('Code navigation actions', () => {
},
],
[],
- done,
);
});
diff --git a/spec/frontend/code_navigation/store/mutations_spec.js b/spec/frontend/code_navigation/store/mutations_spec.js
index cb10729f4b6..b2f1b3bddfd 100644
--- a/spec/frontend/code_navigation/store/mutations_spec.js
+++ b/spec/frontend/code_navigation/store/mutations_spec.js
@@ -13,10 +13,12 @@ describe('Code navigation mutations', () => {
mutations.SET_INITIAL_DATA(state, {
blobs: ['test'],
definitionPathPrefix: 'https://test.com/blob/main',
+ wrapTextNodes: true,
});
expect(state.blobs).toEqual(['test']);
expect(state.definitionPathPrefix).toBe('https://test.com/blob/main');
+ expect(state.wrapTextNodes).toBe(true);
});
});
diff --git a/spec/frontend/code_navigation/utils/index_spec.js b/spec/frontend/code_navigation/utils/index_spec.js
index 6a01249d2a3..682c8bce8c5 100644
--- a/spec/frontend/code_navigation/utils/index_spec.js
+++ b/spec/frontend/code_navigation/utils/index_spec.js
@@ -45,14 +45,42 @@ describe('addInteractionClass', () => {
${0} | ${0} | ${0}
${0} | ${8} | ${2}
${1} | ${0} | ${0}
+ ${1} | ${0} | ${0}
`(
'it sets code navigation attributes for line $line and character $char',
({ line, char, index }) => {
- addInteractionClass('index.js', { start_line: line, start_char: char });
+ addInteractionClass({ path: 'index.js', d: { start_line: line, start_char: char } });
expect(document.querySelectorAll(`#LC${line + 1} span`)[index].classList).toContain(
'js-code-navigation',
);
},
);
+
+ describe('wrapTextNodes', () => {
+ beforeEach(() => {
+ setFixtures(
+ '<div data-path="index.js"><div class="blob-content"><div id="LC1" class="line"> Text </div></div></div>',
+ );
+ });
+
+ const params = { path: 'index.js', d: { start_line: 0, start_char: 0 } };
+ const findAllSpans = () => document.querySelectorAll('#LC1 span');
+
+ it('does not wrap text nodes by default', () => {
+ addInteractionClass(params);
+ const spans = findAllSpans();
+ expect(spans.length).toBe(0);
+ });
+
+ it('wraps text nodes if wrapTextNodes is true', () => {
+ addInteractionClass({ ...params, wrapTextNodes: true });
+ const spans = findAllSpans();
+
+ expect(spans.length).toBe(3);
+ expect(spans[0].textContent).toBe(' ');
+ expect(spans[1].textContent).toBe('Text');
+ expect(spans[2].textContent).toBe(' ');
+ });
+ });
});
diff --git a/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js b/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js
index 1a2e188e7ae..b1c8ba48475 100644
--- a/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js
+++ b/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js
@@ -1,7 +1,18 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import { shallowMount } from '@vue/test-utils';
+import createMockApollo from 'helpers/mock_apollo_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import createFlash from '~/flash';
import CommitBoxPipelineMiniGraph from '~/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue';
-import { mockStages } from './mock_data';
+import getLinkedPipelinesQuery from '~/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql';
+import getPipelineStagesQuery from '~/projects/commit_box/info/graphql/queries/get_pipeline_stages.query.graphql';
+import { mockPipelineStagesQueryResponse, mockStages } from './mock_data';
+
+jest.mock('~/flash');
+
+Vue.use(VueApollo);
describe('Commit box pipeline mini graph', () => {
let wrapper;
@@ -10,34 +21,36 @@ describe('Commit box pipeline mini graph', () => {
const findUpstream = () => wrapper.findByTestId('commit-box-mini-graph-upstream');
const findDownstream = () => wrapper.findByTestId('commit-box-mini-graph-downstream');
- const createComponent = () => {
+ const stagesHandler = jest.fn().mockResolvedValue(mockPipelineStagesQueryResponse);
+
+ const createComponent = ({ props = {} } = {}) => {
+ const handlers = [
+ [getLinkedPipelinesQuery, {}],
+ [getPipelineStagesQuery, stagesHandler],
+ ];
+
wrapper = extendedWrapper(
shallowMount(CommitBoxPipelineMiniGraph, {
propsData: {
stages: mockStages,
+ ...props,
},
- mocks: {
- $apollo: {
- queries: {
- pipeline: {
- loading: false,
- },
- },
- },
- },
+ apolloProvider: createMockApollo(handlers),
}),
);
- };
- beforeEach(() => {
- createComponent();
- });
+ return waitForPromises();
+ };
afterEach(() => {
wrapper.destroy();
});
describe('linked pipelines', () => {
+ beforeEach(async () => {
+ await createComponent();
+ });
+
it('should display the mini pipeine graph', () => {
expect(findMiniGraph().exists()).toBe(true);
});
@@ -47,4 +60,18 @@ describe('Commit box pipeline mini graph', () => {
expect(findDownstream().exists()).toBe(false);
});
});
+
+ describe('when data is mismatched', () => {
+ beforeEach(async () => {
+ await createComponent({ props: { stages: [] } });
+ });
+
+ it('calls create flash with expected arguments', () => {
+ expect(createFlash).toHaveBeenCalledWith({
+ message: 'There was a problem handling the pipeline data.',
+ captureError: true,
+ error: new Error('Rest stages and graphQl stages must be the same length'),
+ });
+ });
+ });
});
diff --git a/spec/frontend/commit/components/commit_box_pipeline_status_spec.js b/spec/frontend/commit/components/commit_box_pipeline_status_spec.js
new file mode 100644
index 00000000000..db7b7b45397
--- /dev/null
+++ b/spec/frontend/commit/components/commit_box_pipeline_status_spec.js
@@ -0,0 +1,150 @@
+import { GlLoadingIcon, GlLink } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import createFlash from '~/flash';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import CommitBoxPipelineStatus from '~/projects/commit_box/info/components/commit_box_pipeline_status.vue';
+import {
+ COMMIT_BOX_POLL_INTERVAL,
+ PIPELINE_STATUS_FETCH_ERROR,
+} from '~/projects/commit_box/info/constants';
+import getLatestPipelineStatusQuery from '~/projects/commit_box/info/graphql/queries/get_latest_pipeline_status.query.graphql';
+import * as graphQlUtils from '~/pipelines/components/graph/utils';
+import { mockPipelineStatusResponse } from '../mock_data';
+
+const mockProvide = {
+ fullPath: 'root/ci-project',
+ iid: '46',
+ graphqlResourceEtag: '/api/graphql:pipelines/id/320',
+};
+
+Vue.use(VueApollo);
+
+jest.mock('~/flash');
+
+describe('Commit box pipeline status', () => {
+ let wrapper;
+
+ const statusSuccessHandler = jest.fn().mockResolvedValue(mockPipelineStatusResponse);
+ const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error'));
+
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findStatusIcon = () => wrapper.findComponent(CiIcon);
+ const findPipelineLink = () => wrapper.findComponent(GlLink);
+
+ const advanceToNextFetch = () => {
+ jest.advanceTimersByTime(COMMIT_BOX_POLL_INTERVAL);
+ };
+
+ const createMockApolloProvider = (handler) => {
+ const requestHandlers = [[getLatestPipelineStatusQuery, handler]];
+
+ return createMockApollo(requestHandlers);
+ };
+
+ const createComponent = (handler = statusSuccessHandler) => {
+ wrapper = shallowMount(CommitBoxPipelineStatus, {
+ provide: {
+ ...mockProvide,
+ },
+ apolloProvider: createMockApolloProvider(handler),
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('loading state', () => {
+ it('should display loading state when loading', () => {
+ createComponent();
+
+ expect(findLoadingIcon().exists()).toBe(true);
+ expect(findStatusIcon().exists()).toBe(false);
+ });
+ });
+
+ describe('loaded state', () => {
+ beforeEach(async () => {
+ createComponent();
+
+ await waitForPromises();
+ });
+
+ it('should display pipeline status after the query is resolved successfully', async () => {
+ expect(findStatusIcon().exists()).toBe(true);
+
+ expect(findLoadingIcon().exists()).toBe(false);
+ expect(createFlash).toHaveBeenCalledTimes(0);
+ });
+
+ it('should link to the latest pipeline', () => {
+ const {
+ data: {
+ project: {
+ pipeline: {
+ detailedStatus: { detailsPath },
+ },
+ },
+ },
+ } = mockPipelineStatusResponse;
+
+ expect(findPipelineLink().attributes('href')).toBe(detailsPath);
+ });
+ });
+
+ describe('error state', () => {
+ it('createFlash should show if there is an error fetching the pipeline status', async () => {
+ createComponent(failedHandler);
+
+ await waitForPromises();
+
+ expect(createFlash).toHaveBeenCalledWith({
+ message: PIPELINE_STATUS_FETCH_ERROR,
+ });
+ });
+ });
+
+ describe('polling', () => {
+ it('polling interval is set for pipeline stages', () => {
+ createComponent();
+
+ const expectedInterval = wrapper.vm.$apollo.queries.pipelineStatus.options.pollInterval;
+
+ expect(expectedInterval).toBe(COMMIT_BOX_POLL_INTERVAL);
+ });
+
+ it('polls for pipeline status', async () => {
+ createComponent();
+
+ await waitForPromises();
+
+ expect(statusSuccessHandler).toHaveBeenCalledTimes(1);
+
+ advanceToNextFetch();
+ await waitForPromises();
+
+ expect(statusSuccessHandler).toHaveBeenCalledTimes(2);
+
+ advanceToNextFetch();
+ await waitForPromises();
+
+ expect(statusSuccessHandler).toHaveBeenCalledTimes(3);
+ });
+
+ it('toggles pipelineStatus polling with visibility check', async () => {
+ jest.spyOn(graphQlUtils, 'toggleQueryPollingByVisibility');
+
+ createComponent();
+
+ await waitForPromises();
+
+ expect(graphQlUtils.toggleQueryPollingByVisibility).toHaveBeenCalledWith(
+ wrapper.vm.$apollo.queries.pipelineStatus,
+ );
+ });
+ });
+});
diff --git a/spec/frontend/commit/mock_data.js b/spec/frontend/commit/mock_data.js
index ef018a4fbd7..8db162c07c2 100644
--- a/spec/frontend/commit/mock_data.js
+++ b/spec/frontend/commit/mock_data.js
@@ -115,3 +115,49 @@ export const mockStages = [
dropdown_path: '/root/ci-project/-/pipelines/611/stage.json?stage=qa',
},
];
+
+export const mockPipelineStagesQueryResponse = {
+ data: {
+ project: {
+ id: 'gid://gitlab/Project/20',
+ pipeline: {
+ id: 'gid://gitlab/Ci::Pipeline/320',
+ stages: {
+ nodes: [
+ {
+ __typename: 'CiStage',
+ id: 'gid://gitlab/Ci::Stage/409',
+ name: 'build',
+ detailedStatus: {
+ id: 'success-409-409',
+ group: 'success',
+ icon: 'status_success',
+ __typename: 'DetailedStatus',
+ },
+ },
+ ],
+ },
+ },
+ },
+ },
+};
+
+export const mockPipelineStatusResponse = {
+ data: {
+ project: {
+ id: 'gid://gitlab/Project/20',
+ pipeline: {
+ id: 'gid://gitlab/Ci::Pipeline/320',
+ detailedStatus: {
+ id: 'pending-320-320',
+ detailsPath: '/root/ci-project/-/pipelines/320',
+ icon: 'status_pending',
+ group: 'pending',
+ __typename: 'DetailedStatus',
+ },
+ __typename: 'Pipeline',
+ },
+ __typename: 'Project',
+ },
+ },
+};
diff --git a/spec/frontend/commit/pipelines/pipelines_table_spec.js b/spec/frontend/commit/pipelines/pipelines_table_spec.js
index 203a4d23160..9b01af1e585 100644
--- a/spec/frontend/commit/pipelines/pipelines_table_spec.js
+++ b/spec/frontend/commit/pipelines/pipelines_table_spec.js
@@ -120,18 +120,20 @@ describe('Pipelines table in Commits and Merge requests', () => {
});
describe('pipeline badge counts', () => {
- it('should receive update-pipelines-count event', (done) => {
+ it('should receive update-pipelines-count event', () => {
const element = document.createElement('div');
document.body.appendChild(element);
- element.addEventListener('update-pipelines-count', (event) => {
- expect(event.detail.pipelineCount).toEqual(10);
- done();
- });
+ return new Promise((resolve) => {
+ element.addEventListener('update-pipelines-count', (event) => {
+ expect(event.detail.pipelineCount).toEqual(10);
+ resolve();
+ });
- createComponent();
+ createComponent();
- element.appendChild(wrapper.vm.$el);
+ element.appendChild(wrapper.vm.$el);
+ });
});
});
});
diff --git a/spec/frontend/commit/pipelines/utils_spec.js b/spec/frontend/commit/pipelines/utils_spec.js
new file mode 100644
index 00000000000..472e35a6eb3
--- /dev/null
+++ b/spec/frontend/commit/pipelines/utils_spec.js
@@ -0,0 +1,59 @@
+import { formatStages } from '~/projects/commit_box/info/utils';
+
+const graphqlStage = [
+ {
+ __typename: 'CiStage',
+ name: 'deploy',
+ detailedStatus: {
+ __typename: 'DetailedStatus',
+ icon: 'status_success',
+ group: 'success',
+ id: 'success-409-409',
+ },
+ },
+];
+
+const restStage = [
+ {
+ name: 'deploy',
+ title: 'deploy: passed',
+ status: {
+ icon: 'status_success',
+ text: 'passed',
+ label: 'passed',
+ group: 'success',
+ tooltip: 'passed',
+ has_details: true,
+ details_path: '/root/ci-project/-/pipelines/318#deploy',
+ illustration: null,
+ favicon:
+ '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png',
+ },
+ path: '/root/ci-project/-/pipelines/318#deploy',
+ dropdown_path: '/root/ci-project/-/pipelines/318/stage.json?stage=deploy',
+ },
+];
+
+describe('Utils', () => {
+ it('combines REST and GraphQL stages correctly for component', () => {
+ expect(formatStages(graphqlStage, restStage)).toEqual([
+ {
+ dropdown_path: '/root/ci-project/-/pipelines/318/stage.json?stage=deploy',
+ name: 'deploy',
+ status: {
+ __typename: 'DetailedStatus',
+ group: 'success',
+ icon: 'status_success',
+ id: 'success-409-409',
+ },
+ title: 'deploy: passed',
+ },
+ ]);
+ });
+
+ it('throws an error if arrays are not the same length', () => {
+ expect(() => {
+ formatStages(graphqlStage, []);
+ }).toThrow('Rest stages and graphQl stages must be the same length');
+ });
+});
diff --git a/spec/frontend/commits_spec.js b/spec/frontend/commits_spec.js
index 8189ebe6e55..a049a6997f0 100644
--- a/spec/frontend/commits_spec.js
+++ b/spec/frontend/commits_spec.js
@@ -70,29 +70,17 @@ describe('Commits List', () => {
mock.restore();
});
- it('should save the last search string', (done) => {
+ it('should save the last search string', async () => {
commitsList.searchField.val('GitLab');
- commitsList
- .filterResults()
- .then(() => {
- expect(ajaxSpy).toHaveBeenCalled();
- expect(commitsList.lastSearch).toEqual('GitLab');
-
- done();
- })
- .catch(done.fail);
+ await commitsList.filterResults();
+ expect(ajaxSpy).toHaveBeenCalled();
+ expect(commitsList.lastSearch).toEqual('GitLab');
});
- it('should not make ajax call if the input does not change', (done) => {
- commitsList
- .filterResults()
- .then(() => {
- expect(ajaxSpy).not.toHaveBeenCalled();
- expect(commitsList.lastSearch).toEqual('');
-
- done();
- })
- .catch(done.fail);
+ it('should not make ajax call if the input does not change', async () => {
+ await commitsList.filterResults();
+ expect(ajaxSpy).not.toHaveBeenCalled();
+ expect(commitsList.lastSearch).toEqual('');
});
});
});
diff --git a/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap b/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap
index c2fa6556847..d9f161b47b1 100644
--- a/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap
+++ b/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap
@@ -12,7 +12,7 @@ exports[`Confidential merge request project form group component renders empty s
<!---->
<p
- class="text-muted mt-1 mb-0"
+ class="gl-text-gray-600 gl-mt-1 gl-mb-0"
>
No forks are available to you.
@@ -27,7 +27,7 @@ exports[`Confidential merge request project form group component renders empty s
</a>
and set the fork's visibility to private.
<gl-link-stub
- class="w-auto p-0 d-inline-block text-primary bg-transparent"
+ class="gl-w-auto gl-p-0 gl-display-inline-block gl-bg-transparent"
href="/help"
target="_blank"
>
@@ -62,13 +62,13 @@ exports[`Confidential merge request project form group component renders fork dr
/>
<p
- class="text-muted mt-1 mb-0"
+ class="gl-text-gray-600 gl-mt-1 gl-mb-0"
>
To protect this issue's confidentiality, a private fork of this project was selected.
<gl-link-stub
- class="w-auto p-0 d-inline-block text-primary bg-transparent"
+ class="gl-w-auto gl-p-0 gl-display-inline-block gl-bg-transparent"
href="/help"
target="_blank"
>
diff --git a/spec/frontend/content_editor/components/code_block_bubble_menu_spec.js b/spec/frontend/content_editor/components/code_block_bubble_menu_spec.js
new file mode 100644
index 00000000000..074c311495f
--- /dev/null
+++ b/spec/frontend/content_editor/components/code_block_bubble_menu_spec.js
@@ -0,0 +1,142 @@
+import { BubbleMenu } from '@tiptap/vue-2';
+import { GlButton, GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui';
+import Vue from 'vue';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import CodeBlockBubbleMenu from '~/content_editor/components/code_block_bubble_menu.vue';
+import eventHubFactory from '~/helpers/event_hub_factory';
+import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight';
+import codeBlockLanguageLoader from '~/content_editor/services/code_block_language_loader';
+import { createTestEditor, emitEditorEvent } from '../test_utils';
+
+describe('content_editor/components/code_block_bubble_menu', () => {
+ let wrapper;
+ let tiptapEditor;
+ let bubbleMenu;
+ let eventHub;
+
+ const buildEditor = () => {
+ tiptapEditor = createTestEditor({ extensions: [CodeBlockHighlight] });
+ eventHub = eventHubFactory();
+ };
+
+ const buildWrapper = () => {
+ wrapper = mountExtended(CodeBlockBubbleMenu, {
+ provide: {
+ tiptapEditor,
+ eventHub,
+ },
+ });
+ };
+
+ const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
+ const findDropdownItemsData = () =>
+ findDropdownItems().wrappers.map((x) => ({
+ text: x.text(),
+ visible: x.isVisible(),
+ checked: x.props('isChecked'),
+ }));
+
+ beforeEach(() => {
+ buildEditor();
+ buildWrapper();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders bubble menu component', async () => {
+ tiptapEditor.commands.insertContent('<pre>test</pre>');
+ bubbleMenu = wrapper.findComponent(BubbleMenu);
+
+ await emitEditorEvent({ event: 'transaction', tiptapEditor });
+
+ expect(bubbleMenu.props('editor')).toBe(tiptapEditor);
+ expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base']);
+ });
+
+ it('selects plaintext language by default', async () => {
+ tiptapEditor.commands.insertContent('<pre>test</pre>');
+ bubbleMenu = wrapper.findComponent(BubbleMenu);
+
+ await emitEditorEvent({ event: 'transaction', tiptapEditor });
+
+ expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Plain text');
+ });
+
+ it('selects appropriate language based on the code block', async () => {
+ tiptapEditor.commands.insertContent('<pre lang="javascript">var a = 2;</pre>');
+ bubbleMenu = wrapper.findComponent(BubbleMenu);
+
+ await emitEditorEvent({ event: 'transaction', tiptapEditor });
+
+ expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Javascript');
+ });
+
+ it("selects Custom (syntax) if the language doesn't exist in the list", async () => {
+ tiptapEditor.commands.insertContent('<pre lang="nomnoml">test</pre>');
+ bubbleMenu = wrapper.findComponent(BubbleMenu);
+
+ await emitEditorEvent({ event: 'transaction', tiptapEditor });
+
+ expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Custom (nomnoml)');
+ });
+
+ it('delete button deletes the code block', async () => {
+ tiptapEditor.commands.insertContent('<pre lang="javascript">var a = 2;</pre>');
+
+ await wrapper.findComponent(GlButton).vm.$emit('click');
+
+ expect(tiptapEditor.getText()).toBe('');
+ });
+
+ describe('when opened and search is changed', () => {
+ beforeEach(async () => {
+ tiptapEditor.commands.insertContent('<pre lang="javascript">var a = 2;</pre>');
+
+ wrapper.findComponent(GlSearchBoxByType).vm.$emit('input', 'js');
+
+ await Vue.nextTick();
+ });
+
+ it('shows dropdown items', () => {
+ expect(findDropdownItemsData()).toEqual([
+ { text: 'Javascript', visible: true, checked: true },
+ { text: 'Java', visible: true, checked: false },
+ { text: 'Javascript', visible: false, checked: false },
+ { text: 'JSON', visible: true, checked: false },
+ ]);
+ });
+
+ describe('when dropdown item is clicked', () => {
+ beforeEach(async () => {
+ jest.spyOn(codeBlockLanguageLoader, 'loadLanguages').mockResolvedValue();
+
+ findDropdownItems().at(1).vm.$emit('click');
+
+ await Vue.nextTick();
+ });
+
+ it('loads language', () => {
+ expect(codeBlockLanguageLoader.loadLanguages).toHaveBeenCalledWith(['java']);
+ });
+
+ it('sets code block', () => {
+ expect(tiptapEditor.getJSON()).toMatchObject({
+ content: [
+ {
+ type: 'codeBlock',
+ attrs: {
+ language: 'java',
+ },
+ },
+ ],
+ });
+ });
+
+ it('updates selected dropdown', () => {
+ expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Java');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js b/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js
index e44a7fa4ddb..192ddee78c6 100644
--- a/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js
+++ b/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js
@@ -9,7 +9,7 @@ import {
} from '~/content_editor/constants';
import { createTestEditor } from '../test_utils';
-describe('content_editor/components/top_toolbar', () => {
+describe('content_editor/components/formatting_bubble_menu', () => {
let wrapper;
let trackingSpy;
let tiptapEditor;
diff --git a/spec/frontend/content_editor/components/wrappers/image_spec.js b/spec/frontend/content_editor/components/wrappers/media_spec.js
index 7b057f9cabc..3e95e2f3914 100644
--- a/spec/frontend/content_editor/components/wrappers/image_spec.js
+++ b/spec/frontend/content_editor/components/wrappers/media_spec.js
@@ -1,21 +1,24 @@
import { GlLoadingIcon } from '@gitlab/ui';
import { NodeViewWrapper } from '@tiptap/vue-2';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import ImageWrapper from '~/content_editor/components/wrappers/image.vue';
+import MediaWrapper from '~/content_editor/components/wrappers/media.vue';
-describe('content/components/wrappers/image', () => {
+describe('content/components/wrappers/media', () => {
let wrapper;
const createWrapper = async (nodeAttrs = {}) => {
- wrapper = shallowMountExtended(ImageWrapper, {
+ wrapper = shallowMountExtended(MediaWrapper, {
propsData: {
node: {
attrs: nodeAttrs,
+ type: {
+ name: 'image',
+ },
},
},
});
};
- const findImage = () => wrapper.findByTestId('image');
+ const findMedia = () => wrapper.findByTestId('media');
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
afterEach(() => {
@@ -33,7 +36,7 @@ describe('content/components/wrappers/image', () => {
createWrapper({ src });
- expect(findImage().attributes().src).toBe(src);
+ expect(findMedia().attributes().src).toBe(src);
});
describe('when uploading', () => {
@@ -45,8 +48,8 @@ describe('content/components/wrappers/image', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
- it('adds gl-opacity-5 class selector to image', () => {
- expect(findImage().classes()).toContain('gl-opacity-5');
+ it('adds gl-opacity-5 class selector to the media tag', () => {
+ expect(findMedia().classes()).toContain('gl-opacity-5');
});
});
@@ -59,8 +62,8 @@ describe('content/components/wrappers/image', () => {
expect(findLoadingIcon().exists()).toBe(false);
});
- it('does not add gl-opacity-5 class selector to image', () => {
- expect(findImage().classes()).not.toContain('gl-opacity-5');
+ it('does not add gl-opacity-5 class selector to the media tag', () => {
+ expect(findMedia().classes()).not.toContain('gl-opacity-5');
});
});
});
diff --git a/spec/frontend/content_editor/extensions/attachment_spec.js b/spec/frontend/content_editor/extensions/attachment_spec.js
index ec67545cf17..d3c42104e47 100644
--- a/spec/frontend/content_editor/extensions/attachment_spec.js
+++ b/spec/frontend/content_editor/extensions/attachment_spec.js
@@ -1,7 +1,10 @@
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
+import waitForPromises from 'helpers/wait_for_promises';
import Attachment from '~/content_editor/extensions/attachment';
import Image from '~/content_editor/extensions/image';
+import Audio from '~/content_editor/extensions/audio';
+import Video from '~/content_editor/extensions/video';
import Link from '~/content_editor/extensions/link';
import Loading from '~/content_editor/extensions/loading';
import { VARIANT_DANGER } from '~/flash';
@@ -14,6 +17,23 @@ const PROJECT_WIKI_ATTACHMENT_IMAGE_HTML = `<p data-sourcepos="1:1-1:27" dir="au
<img alt="test-file" class="lazy" data-src="/group1/project1/-/wikis/test-file.png" data-canonical-src="test-file.png">
</a>
</p>`;
+
+const PROJECT_WIKI_ATTACHMENT_VIDEO_HTML = `<p data-sourcepos="1:1-1:132" dir="auto">
+ <span class="media-container video-container">
+ <video src="/group1/project1/-/wikis/test-file.mp4" controls="true" data-setup="{}" data-title="test-file" width="400" preload="metadata" data-canonical-src="test-file.mp4">
+ </video>
+ <a href="/himkp/test/-/wikis/test-file.mp4" target="_blank" rel="noopener noreferrer" title="Download 'test-file'" data-canonical-src="test-file.mp4">test-file</a>
+ </span>
+</p>`;
+
+const PROJECT_WIKI_ATTACHMENT_AUDIO_HTML = `<p data-sourcepos="3:1-3:74" dir="auto">
+ <span class="media-container audio-container">
+ <audio src="/himkp/test/-/wikis/test-file.mp3" controls="true" data-setup="{}" data-title="test-file" data-canonical-src="test-file.mp3">
+ </audio>
+ <a href="/himkp/test/-/wikis/test-file.mp3" target="_blank" rel="noopener noreferrer" title="Download 'test-file'" data-canonical-src="test-file.mp3">test-file</a>
+ </span>
+</p>`;
+
const PROJECT_WIKI_ATTACHMENT_LINK_HTML = `<p data-sourcepos="1:1-1:26" dir="auto">
<a href="/group1/project1/-/wikis/test-file.zip" data-canonical-src="test-file.zip">test-file</a>
</p>`;
@@ -23,6 +43,8 @@ describe('content_editor/extensions/attachment', () => {
let doc;
let p;
let image;
+ let audio;
+ let video;
let loading;
let link;
let renderMarkdown;
@@ -31,15 +53,18 @@ describe('content_editor/extensions/attachment', () => {
const uploadsPath = '/uploads/';
const imageFile = new File(['foo'], 'test-file.png', { type: 'image/png' });
+ const audioFile = new File(['foo'], 'test-file.mp3', { type: 'audio/mpeg' });
+ const videoFile = new File(['foo'], 'test-file.mp4', { type: 'video/mp4' });
const attachmentFile = new File(['foo'], 'test-file.zip', { type: 'application/zip' });
const expectDocumentAfterTransaction = ({ number, expectedDoc, action }) => {
return new Promise((resolve) => {
let counter = 1;
- const handleTransaction = () => {
+ const handleTransaction = async () => {
if (counter === number) {
expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON());
tiptapEditor.off('update', handleTransaction);
+ await waitForPromises();
resolve();
}
@@ -60,18 +85,22 @@ describe('content_editor/extensions/attachment', () => {
Loading,
Link,
Image,
+ Audio,
+ Video,
Attachment.configure({ renderMarkdown, uploadsPath, eventHub }),
],
});
({
- builders: { doc, p, image, loading, link },
+ builders: { doc, p, image, audio, video, loading, link },
} = createDocBuilder({
tiptapEditor,
names: {
loading: { markType: Loading.name },
image: { nodeType: Image.name },
link: { nodeType: Link.name },
+ audio: { nodeType: Audio.name },
+ video: { nodeType: Video.name },
},
}));
@@ -103,17 +132,22 @@ describe('content_editor/extensions/attachment', () => {
tiptapEditor.commands.setContent(initialDoc.toJSON());
});
- describe('when the file has image mime type', () => {
- const base64EncodedFile = 'data:image/png;base64,Zm9v';
+ describe.each`
+ nodeType | mimeType | html | file | mediaType
+ ${'image'} | ${'image/png'} | ${PROJECT_WIKI_ATTACHMENT_IMAGE_HTML} | ${imageFile} | ${(attrs) => image(attrs)}
+ ${'audio'} | ${'audio/mpeg'} | ${PROJECT_WIKI_ATTACHMENT_AUDIO_HTML} | ${audioFile} | ${(attrs) => audio(attrs)}
+ ${'video'} | ${'video/mp4'} | ${PROJECT_WIKI_ATTACHMENT_VIDEO_HTML} | ${videoFile} | ${(attrs) => video(attrs)}
+ `('when the file has $nodeType mime type', ({ mimeType, html, file, mediaType }) => {
+ const base64EncodedFile = `data:${mimeType};base64,Zm9v`;
beforeEach(() => {
- renderMarkdown.mockResolvedValue(PROJECT_WIKI_ATTACHMENT_IMAGE_HTML);
+ renderMarkdown.mockResolvedValue(html);
});
describe('when uploading succeeds', () => {
const successResponse = {
link: {
- markdown: '![test-file](test-file.png)',
+ markdown: `![test-file](${file.name})`,
},
};
@@ -121,21 +155,21 @@ describe('content_editor/extensions/attachment', () => {
mock.onPost().reply(httpStatus.OK, successResponse);
});
- it('inserts an image with src set to the encoded image file and uploading true', async () => {
- const expectedDoc = doc(p(image({ uploading: true, src: base64EncodedFile })));
+ it('inserts a media content with src set to the encoded content and uploading true', async () => {
+ const expectedDoc = doc(p(mediaType({ uploading: true, src: base64EncodedFile })));
await expectDocumentAfterTransaction({
number: 1,
expectedDoc,
- action: () => tiptapEditor.commands.uploadAttachment({ file: imageFile }),
+ action: () => tiptapEditor.commands.uploadAttachment({ file }),
});
});
- it('updates the inserted image with canonicalSrc when upload is successful', async () => {
+ it('updates the inserted content with canonicalSrc when upload is successful', async () => {
const expectedDoc = doc(
p(
- image({
- canonicalSrc: 'test-file.png',
+ mediaType({
+ canonicalSrc: file.name,
src: base64EncodedFile,
alt: 'test-file',
uploading: false,
@@ -146,7 +180,7 @@ describe('content_editor/extensions/attachment', () => {
await expectDocumentAfterTransaction({
number: 2,
expectedDoc,
- action: () => tiptapEditor.commands.uploadAttachment({ file: imageFile }),
+ action: () => tiptapEditor.commands.uploadAttachment({ file }),
});
});
});
@@ -162,17 +196,19 @@ describe('content_editor/extensions/attachment', () => {
await expectDocumentAfterTransaction({
number: 2,
expectedDoc,
- action: () => tiptapEditor.commands.uploadAttachment({ file: imageFile }),
+ action: () => tiptapEditor.commands.uploadAttachment({ file }),
});
});
- it('emits an alert event that includes an error message', (done) => {
- tiptapEditor.commands.uploadAttachment({ file: imageFile });
+ it('emits an alert event that includes an error message', () => {
+ tiptapEditor.commands.uploadAttachment({ file });
- eventHub.$on('alert', ({ message, variant }) => {
- expect(variant).toBe(VARIANT_DANGER);
- expect(message).toBe('An error occurred while uploading the image. Please try again.');
- done();
+ return new Promise((resolve) => {
+ eventHub.$on('alert', ({ message, variant }) => {
+ expect(variant).toBe(VARIANT_DANGER);
+ expect(message).toBe('An error occurred while uploading the file. Please try again.');
+ resolve();
+ });
});
});
});
@@ -243,13 +279,12 @@ describe('content_editor/extensions/attachment', () => {
});
});
- it('emits an alert event that includes an error message', (done) => {
+ it('emits an alert event that includes an error message', () => {
tiptapEditor.commands.uploadAttachment({ file: attachmentFile });
eventHub.$on('alert', ({ message, variant }) => {
expect(variant).toBe(VARIANT_DANGER);
expect(message).toBe('An error occurred while uploading the file. Please try again.');
- done();
});
});
});
diff --git a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js
index 05fa0f79ef0..02e5b1dc271 100644
--- a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js
+++ b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js
@@ -1,5 +1,5 @@
import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight';
-import { createTestEditor } from '../test_utils';
+import { createTestEditor, createDocBuilder, triggerNodeInputRule } from '../test_utils';
const CODE_BLOCK_HTML = `<pre class="code highlight js-syntax-highlight language-javascript" lang="javascript" v-pre="true">
<code>
@@ -12,34 +12,78 @@ const CODE_BLOCK_HTML = `<pre class="code highlight js-syntax-highlight language
describe('content_editor/extensions/code_block_highlight', () => {
let parsedCodeBlockHtmlFixture;
let tiptapEditor;
+ let doc;
+ let codeBlock;
+ let languageLoader;
const parseHTML = (html) => new DOMParser().parseFromString(html, 'text/html');
const preElement = () => parsedCodeBlockHtmlFixture.querySelector('pre');
beforeEach(() => {
- tiptapEditor = createTestEditor({ extensions: [CodeBlockHighlight] });
- parsedCodeBlockHtmlFixture = parseHTML(CODE_BLOCK_HTML);
+ languageLoader = { loadLanguages: jest.fn() };
+ tiptapEditor = createTestEditor({
+ extensions: [CodeBlockHighlight.configure({ languageLoader })],
+ });
- tiptapEditor.commands.setContent(CODE_BLOCK_HTML);
+ ({
+ builders: { doc, codeBlock },
+ } = createDocBuilder({
+ tiptapEditor,
+ names: {
+ codeBlock: { nodeType: CodeBlockHighlight.name },
+ },
+ }));
});
- it('extracts language and params attributes from Markdown API output', () => {
- const language = preElement().getAttribute('lang');
+ describe('when parsing HTML', () => {
+ beforeEach(() => {
+ parsedCodeBlockHtmlFixture = parseHTML(CODE_BLOCK_HTML);
- expect(tiptapEditor.getJSON().content[0].attrs).toMatchObject({
- language,
+ tiptapEditor.commands.setContent(CODE_BLOCK_HTML);
+ });
+ it('extracts language and params attributes from Markdown API output', () => {
+ const language = preElement().getAttribute('lang');
+
+ expect(tiptapEditor.getJSON().content[0].attrs).toMatchObject({
+ 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');
});
- });
- it('adds code, highlight, and js-syntax-highlight to code block element', () => {
- const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre');
+ it('adds content-editor-code-block class to the pre element', () => {
+ const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre');
- expect(editorHtmlOutput.classList.toString()).toContain('code highlight js-syntax-highlight');
+ expect(editorHtmlOutput.classList.toString()).toContain('content-editor-code-block');
+ });
});
- it('adds content-editor-code-block class to the pre element', () => {
- const editorHtmlOutput = parseHTML(tiptapEditor.getHTML()).querySelector('pre');
+ describe.each`
+ inputRule
+ ${'```'}
+ ${'~~~'}
+ `('when typing $inputRule input rule', ({ inputRule }) => {
+ const language = 'javascript';
+
+ beforeEach(() => {
+ triggerNodeInputRule({
+ tiptapEditor,
+ inputRuleText: `${inputRule}${language} `,
+ });
+ });
+
+ it('creates a new code block and loads related language', () => {
+ const expectedDoc = doc(codeBlock({ language }));
- expect(editorHtmlOutput.classList.toString()).toContain('content-editor-code-block');
+ expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON());
+ });
+
+ it('loads language when language loader is available', () => {
+ expect(languageLoader.loadLanguages).toHaveBeenCalledWith([language]);
+ });
});
});
diff --git a/spec/frontend/content_editor/extensions/frontmatter_spec.js b/spec/frontend/content_editor/extensions/frontmatter_spec.js
index a8cbad6ef81..4f80c2cb81a 100644
--- a/spec/frontend/content_editor/extensions/frontmatter_spec.js
+++ b/spec/frontend/content_editor/extensions/frontmatter_spec.js
@@ -23,7 +23,7 @@ describe('content_editor/extensions/frontmatter', () => {
});
it('does not insert a frontmatter block when executing code block input rule', () => {
- const expectedDoc = doc(codeBlock(''));
+ const expectedDoc = doc(codeBlock({ language: 'plaintext' }, ''));
const inputRuleText = '``` ';
triggerNodeInputRule({ tiptapEditor, inputRuleText });
diff --git a/spec/frontend/content_editor/services/code_block_language_loader_spec.js b/spec/frontend/content_editor/services/code_block_language_loader_spec.js
new file mode 100644
index 00000000000..905c1685b94
--- /dev/null
+++ b/spec/frontend/content_editor/services/code_block_language_loader_spec.js
@@ -0,0 +1,120 @@
+import codeBlockLanguageBlocker from '~/content_editor/services/code_block_language_loader';
+import waitForPromises from 'helpers/wait_for_promises';
+import { backtickInputRegex } from '~/content_editor/extensions/code_block_highlight';
+
+describe('content_editor/services/code_block_language_loader', () => {
+ let languageLoader;
+ let lowlight;
+
+ beforeEach(() => {
+ lowlight = {
+ languages: [],
+ registerLanguage: jest
+ .fn()
+ .mockImplementation((language) => lowlight.languages.push(language)),
+ registered: jest.fn().mockImplementation((language) => lowlight.languages.includes(language)),
+ };
+ languageLoader = codeBlockLanguageBlocker;
+ languageLoader.lowlight = lowlight;
+ });
+
+ describe('findLanguageBySyntax', () => {
+ it.each`
+ syntax | language
+ ${'javascript'} | ${{ syntax: 'javascript', label: 'Javascript' }}
+ ${'js'} | ${{ syntax: 'javascript', label: 'Javascript' }}
+ ${'jsx'} | ${{ syntax: 'javascript', label: 'Javascript' }}
+ `('returns a language by syntax and its variants', ({ syntax, language }) => {
+ expect(languageLoader.findLanguageBySyntax(syntax)).toMatchObject(language);
+ });
+
+ it('returns Custom (syntax) if the language does not exist', () => {
+ expect(languageLoader.findLanguageBySyntax('foobar')).toMatchObject({
+ syntax: 'foobar',
+ label: 'Custom (foobar)',
+ });
+ });
+
+ it('returns plaintext if no syntax is passed', () => {
+ expect(languageLoader.findLanguageBySyntax('')).toMatchObject({
+ syntax: 'plaintext',
+ label: 'Plain text',
+ });
+ });
+ });
+
+ describe('filterLanguages', () => {
+ it('filters languages by the given search term', () => {
+ expect(languageLoader.filterLanguages('ts')).toEqual([
+ { label: 'Device Tree', syntax: 'dts' },
+ { label: 'Kotlin', syntax: 'kotlin', variants: 'kt, kts' },
+ { label: 'TypeScript', syntax: 'typescript', variants: 'ts, tsx' },
+ ]);
+ });
+ });
+
+ describe('loadLanguages', () => {
+ it('loads highlight.js language packages identified by a list of languages', async () => {
+ const languages = ['javascript', 'ruby'];
+
+ await languageLoader.loadLanguages(languages);
+
+ languages.forEach((language) => {
+ expect(lowlight.registerLanguage).toHaveBeenCalledWith(language, expect.any(Function));
+ });
+ });
+
+ describe('when language is already registered', () => {
+ it('does not load the language again', async () => {
+ const languages = ['javascript'];
+
+ await languageLoader.loadLanguages(languages);
+ await languageLoader.loadLanguages(languages);
+
+ expect(lowlight.registerLanguage).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+
+ describe('loadLanguagesFromDOM', () => {
+ it('loads highlight.js language packages identified by pre tags in a DOM fragment', async () => {
+ const parser = new DOMParser();
+ const { body } = parser.parseFromString(
+ `
+ <pre lang="javascript"></pre>
+ <pre lang="ruby"></pre>
+ `,
+ 'text/html',
+ );
+
+ await languageLoader.loadLanguagesFromDOM(body);
+
+ expect(lowlight.registerLanguage).toHaveBeenCalledWith('javascript', expect.any(Function));
+ expect(lowlight.registerLanguage).toHaveBeenCalledWith('ruby', expect.any(Function));
+ });
+ });
+
+ describe('loadLanguageFromInputRule', () => {
+ it('loads highlight.js language packages identified from the input rule', async () => {
+ const match = new RegExp(backtickInputRegex).exec('```js ');
+ const attrs = languageLoader.loadLanguageFromInputRule(match);
+
+ await waitForPromises();
+
+ expect(attrs).toEqual({ language: 'javascript' });
+ expect(lowlight.registerLanguage).toHaveBeenCalledWith('javascript', expect.any(Function));
+ });
+ });
+
+ describe('isLanguageLoaded', () => {
+ it('returns true when a language is registered', async () => {
+ const language = 'javascript';
+
+ expect(languageLoader.isLanguageLoaded(language)).toBe(false);
+
+ await languageLoader.loadLanguages([language]);
+
+ expect(languageLoader.isLanguageLoaded(language)).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/content_editor/services/content_editor_spec.js b/spec/frontend/content_editor/services/content_editor_spec.js
index 3bc72b13302..5b7a27b501d 100644
--- a/spec/frontend/content_editor/services/content_editor_spec.js
+++ b/spec/frontend/content_editor/services/content_editor_spec.js
@@ -11,6 +11,7 @@ describe('content_editor/services/content_editor', () => {
let contentEditor;
let serializer;
let deserializer;
+ let languageLoader;
let eventHub;
let doc;
let p;
@@ -27,8 +28,15 @@ describe('content_editor/services/content_editor', () => {
serializer = { deserialize: jest.fn() };
deserializer = { deserialize: jest.fn() };
+ languageLoader = { loadLanguagesFromDOM: jest.fn() };
eventHub = eventHubFactory();
- contentEditor = new ContentEditor({ tiptapEditor, serializer, deserializer, eventHub });
+ contentEditor = new ContentEditor({
+ tiptapEditor,
+ serializer,
+ deserializer,
+ eventHub,
+ languageLoader,
+ });
});
describe('.dispose', () => {
@@ -43,10 +51,12 @@ describe('content_editor/services/content_editor', () => {
describe('when setSerializedContent succeeds', () => {
let document;
+ const dom = {};
+ const testMarkdown = '**bold text**';
beforeEach(() => {
document = doc(p('document'));
- deserializer.deserialize.mockResolvedValueOnce({ document });
+ deserializer.deserialize.mockResolvedValueOnce({ document, dom });
});
it('emits loadingContent and loadingSuccess event in the eventHub', () => {
@@ -59,14 +69,20 @@ describe('content_editor/services/content_editor', () => {
expect(loadingContentEmitted).toBe(true);
});
- contentEditor.setSerializedContent('**bold text**');
+ contentEditor.setSerializedContent(testMarkdown);
});
it('sets the deserialized document in the tiptap editor object', async () => {
- await contentEditor.setSerializedContent('**bold text**');
+ await contentEditor.setSerializedContent(testMarkdown);
expect(contentEditor.tiptapEditor.state.doc.toJSON()).toEqual(document.toJSON());
});
+
+ it('passes deserialized DOM document to language loader', async () => {
+ await contentEditor.setSerializedContent(testMarkdown);
+
+ expect(languageLoader.loadLanguagesFromDOM).toHaveBeenCalledWith(dom);
+ });
});
describe('when setSerializedContent fails', () => {
diff --git a/spec/frontend/contributors/store/actions_spec.js b/spec/frontend/contributors/store/actions_spec.js
index a4054ab1fc8..ef0ff8ca208 100644
--- a/spec/frontend/contributors/store/actions_spec.js
+++ b/spec/frontend/contributors/store/actions_spec.js
@@ -17,10 +17,14 @@ describe('Contributors store actions', () => {
mock = new MockAdapter(axios);
});
- it('should commit SET_CHART_DATA with received response', (done) => {
+ afterEach(() => {
+ mock.restore();
+ });
+
+ it('should commit SET_CHART_DATA with received response', () => {
mock.onGet().reply(200, chartData);
- testAction(
+ return testAction(
actions.fetchChartData,
{ endpoint },
{},
@@ -30,30 +34,22 @@ describe('Contributors store actions', () => {
{ type: types.SET_LOADING_STATE, payload: false },
],
[],
- () => {
- mock.restore();
- done();
- },
);
});
- it('should show flash on API error', (done) => {
+ it('should show flash on API error', async () => {
mock.onGet().reply(400, 'Not Found');
- testAction(
+ await testAction(
actions.fetchChartData,
{ endpoint },
{},
[{ type: types.SET_LOADING_STATE, payload: true }],
[],
- () => {
- expect(createFlash).toHaveBeenCalledWith({
- message: expect.stringMatching('error'),
- });
- mock.restore();
- done();
- },
);
+ expect(createFlash).toHaveBeenCalledWith({
+ message: expect.stringMatching('error'),
+ });
});
});
});
diff --git a/spec/frontend/create_cluster/gke_cluster/stores/actions_spec.js b/spec/frontend/create_cluster/gke_cluster/stores/actions_spec.js
index 55c502b96bb..c365cb6a9f4 100644
--- a/spec/frontend/create_cluster/gke_cluster/stores/actions_spec.js
+++ b/spec/frontend/create_cluster/gke_cluster/stores/actions_spec.js
@@ -14,53 +14,49 @@ import {
describe('GCP Cluster Dropdown Store Actions', () => {
describe('setProject', () => {
- it('should set project', (done) => {
- testAction(
+ it('should set project', () => {
+ return testAction(
actions.setProject,
selectedProjectMock,
{ selectedProject: {} },
[{ type: 'SET_PROJECT', payload: selectedProjectMock }],
[],
- done,
);
});
});
describe('setZone', () => {
- it('should set zone', (done) => {
- testAction(
+ it('should set zone', () => {
+ return testAction(
actions.setZone,
selectedZoneMock,
{ selectedZone: '' },
[{ type: 'SET_ZONE', payload: selectedZoneMock }],
[],
- done,
);
});
});
describe('setMachineType', () => {
- it('should set machine type', (done) => {
- testAction(
+ it('should set machine type', () => {
+ return testAction(
actions.setMachineType,
selectedMachineTypeMock,
{ selectedMachineType: '' },
[{ type: 'SET_MACHINE_TYPE', payload: selectedMachineTypeMock }],
[],
- done,
);
});
});
describe('setIsValidatingProjectBilling', () => {
- it('should set machine type', (done) => {
- testAction(
+ it('should set machine type', () => {
+ return testAction(
actions.setIsValidatingProjectBilling,
true,
{ isValidatingProjectBilling: null },
[{ type: 'SET_IS_VALIDATING_PROJECT_BILLING', payload: true }],
[],
- done,
);
});
});
@@ -94,8 +90,8 @@ describe('GCP Cluster Dropdown Store Actions', () => {
});
describe('validateProjectBilling', () => {
- it('checks project billing status from Google API', (done) => {
- testAction(
+ it('checks project billing status from Google API', () => {
+ return testAction(
actions.validateProjectBilling,
true,
{
@@ -110,7 +106,6 @@ describe('GCP Cluster Dropdown Store Actions', () => {
{ type: 'SET_PROJECT_BILLING_STATUS', payload: true },
],
[{ type: 'setIsValidatingProjectBilling', payload: false }],
- done,
);
});
});
diff --git a/spec/frontend/crm/contact_form_spec.js b/spec/frontend/crm/contact_form_spec.js
deleted file mode 100644
index 0edab4f5ec5..00000000000
--- a/spec/frontend/crm/contact_form_spec.js
+++ /dev/null
@@ -1,157 +0,0 @@
-import { GlAlert } from '@gitlab/ui';
-import Vue from 'vue';
-import VueApollo from 'vue-apollo';
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import createMockApollo from 'helpers/mock_apollo_helper';
-import waitForPromises from 'helpers/wait_for_promises';
-import ContactForm from '~/crm/components/contact_form.vue';
-import createContactMutation from '~/crm/components/queries/create_contact.mutation.graphql';
-import updateContactMutation from '~/crm/components/queries/update_contact.mutation.graphql';
-import getGroupContactsQuery from '~/crm/components/queries/get_group_contacts.query.graphql';
-import {
- createContactMutationErrorResponse,
- createContactMutationResponse,
- getGroupContactsQueryResponse,
- updateContactMutationErrorResponse,
- updateContactMutationResponse,
-} from './mock_data';
-
-describe('Customer relations contact form component', () => {
- Vue.use(VueApollo);
- let wrapper;
- let fakeApollo;
- let mutation;
- let queryHandler;
-
- const findSaveContactButton = () => wrapper.findByTestId('save-contact-button');
- const findCancelButton = () => wrapper.findByTestId('cancel-button');
- const findForm = () => wrapper.find('form');
- const findError = () => wrapper.findComponent(GlAlert);
-
- const mountComponent = ({ mountFunction = shallowMountExtended, editForm = false } = {}) => {
- fakeApollo = createMockApollo([[mutation, queryHandler]]);
- fakeApollo.clients.defaultClient.cache.writeQuery({
- query: getGroupContactsQuery,
- variables: { groupFullPath: 'flightjs' },
- data: getGroupContactsQueryResponse.data,
- });
- const propsData = { drawerOpen: true };
- if (editForm)
- propsData.contact = { firstName: 'First', lastName: 'Last', email: 'email@example.com' };
- wrapper = mountFunction(ContactForm, {
- provide: { groupId: 26, groupFullPath: 'flightjs' },
- apolloProvider: fakeApollo,
- propsData,
- });
- };
-
- beforeEach(() => {
- mutation = createContactMutation;
- queryHandler = jest.fn().mockResolvedValue(createContactMutationResponse);
- });
-
- afterEach(() => {
- wrapper.destroy();
- fakeApollo = null;
- });
-
- describe('Save contact button', () => {
- it('should be disabled when required fields are empty', () => {
- mountComponent();
-
- expect(findSaveContactButton().props('disabled')).toBe(true);
- });
-
- it('should not be disabled when required fields have values', async () => {
- mountComponent();
-
- wrapper.find('#contact-first-name').vm.$emit('input', 'A');
- wrapper.find('#contact-last-name').vm.$emit('input', 'B');
- wrapper.find('#contact-email').vm.$emit('input', 'C');
- await waitForPromises();
-
- expect(findSaveContactButton().props('disabled')).toBe(false);
- });
- });
-
- it("should emit 'close' when cancel button is clicked", () => {
- mountComponent();
-
- findCancelButton().vm.$emit('click');
-
- expect(wrapper.emitted().close).toBeTruthy();
- });
-
- describe('when create mutation is successful', () => {
- it("should emit 'close'", async () => {
- mountComponent();
-
- findForm().trigger('submit');
- await waitForPromises();
-
- expect(wrapper.emitted().close).toBeTruthy();
- });
- });
-
- describe('when create mutation fails', () => {
- it('should show error on reject', async () => {
- queryHandler = jest.fn().mockRejectedValue('ERROR');
- mountComponent();
-
- findForm().trigger('submit');
- await waitForPromises();
-
- expect(findError().exists()).toBe(true);
- });
-
- it('should show error on error response', async () => {
- queryHandler = jest.fn().mockResolvedValue(createContactMutationErrorResponse);
- mountComponent();
-
- findForm().trigger('submit');
- await waitForPromises();
-
- expect(findError().exists()).toBe(true);
- expect(findError().text()).toBe('create contact is invalid.');
- });
- });
-
- describe('when update mutation is successful', () => {
- it("should emit 'close'", async () => {
- mutation = updateContactMutation;
- queryHandler = jest.fn().mockResolvedValue(updateContactMutationResponse);
- mountComponent({ editForm: true });
-
- findForm().trigger('submit');
- await waitForPromises();
-
- expect(wrapper.emitted().close).toBeTruthy();
- });
- });
-
- describe('when update mutation fails', () => {
- beforeEach(() => {
- mutation = updateContactMutation;
- });
-
- it('should show error on reject', async () => {
- queryHandler = jest.fn().mockRejectedValue('ERROR');
- mountComponent({ editForm: true });
- findForm().trigger('submit');
- await waitForPromises();
-
- expect(findError().exists()).toBe(true);
- });
-
- it('should show error on error response', async () => {
- queryHandler = jest.fn().mockResolvedValue(updateContactMutationErrorResponse);
- mountComponent({ editForm: true });
-
- findForm().trigger('submit');
- await waitForPromises();
-
- expect(findError().exists()).toBe(true);
- expect(findError().text()).toBe('update contact is invalid.');
- });
- });
-});
diff --git a/spec/frontend/crm/contact_form_wrapper_spec.js b/spec/frontend/crm/contact_form_wrapper_spec.js
new file mode 100644
index 00000000000..6307889a7aa
--- /dev/null
+++ b/spec/frontend/crm/contact_form_wrapper_spec.js
@@ -0,0 +1,88 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import ContactFormWrapper from '~/crm/contacts/components/contact_form_wrapper.vue';
+import ContactForm from '~/crm/components/form.vue';
+import getGroupContactsQuery from '~/crm/contacts/components/graphql/get_group_contacts.query.graphql';
+import createContactMutation from '~/crm/contacts/components/graphql/create_contact.mutation.graphql';
+import updateContactMutation from '~/crm/contacts/components/graphql/update_contact.mutation.graphql';
+
+describe('Customer relations contact form wrapper', () => {
+ let wrapper;
+
+ const findContactForm = () => wrapper.findComponent(ContactForm);
+
+ const $apollo = {
+ queries: {
+ contacts: {
+ loading: false,
+ },
+ },
+ };
+ const $route = {
+ params: {
+ id: 7,
+ },
+ };
+ const contacts = [{ id: 'gid://gitlab/CustomerRelations::Contact/7' }];
+
+ const mountComponent = ({ isEditMode = false } = {}) => {
+ wrapper = shallowMountExtended(ContactFormWrapper, {
+ propsData: {
+ isEditMode,
+ },
+ provide: {
+ groupFullPath: 'flightjs',
+ groupId: 26,
+ },
+ mocks: {
+ $apollo,
+ $route,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('in edit mode', () => {
+ it('should render contact form with correct props', () => {
+ mountComponent({ isEditMode: true });
+
+ const contactForm = findContactForm();
+ expect(contactForm.props('fields')).toHaveLength(5);
+ expect(contactForm.props('title')).toBe('Edit contact');
+ expect(contactForm.props('successMessage')).toBe('Contact has been updated.');
+ expect(contactForm.props('mutation')).toBe(updateContactMutation);
+ expect(contactForm.props('getQuery')).toMatchObject({
+ query: getGroupContactsQuery,
+ variables: { groupFullPath: 'flightjs' },
+ });
+ expect(contactForm.props('getQueryNodePath')).toBe('group.contacts');
+ expect(contactForm.props('existingId')).toBe(contacts[0].id);
+ expect(contactForm.props('additionalCreateParams')).toMatchObject({
+ groupId: 'gid://gitlab/Group/26',
+ });
+ });
+ });
+
+ describe('in create mode', () => {
+ it('should render contact form with correct props', () => {
+ mountComponent();
+
+ const contactForm = findContactForm();
+ expect(contactForm.props('fields')).toHaveLength(5);
+ expect(contactForm.props('title')).toBe('New contact');
+ expect(contactForm.props('successMessage')).toBe('Contact has been added.');
+ expect(contactForm.props('mutation')).toBe(createContactMutation);
+ expect(contactForm.props('getQuery')).toMatchObject({
+ query: getGroupContactsQuery,
+ variables: { groupFullPath: 'flightjs' },
+ });
+ expect(contactForm.props('getQueryNodePath')).toBe('group.contacts');
+ expect(contactForm.props('existingId')).toBeNull();
+ expect(contactForm.props('additionalCreateParams')).toMatchObject({
+ groupId: 'gid://gitlab/Group/26',
+ });
+ });
+ });
+});
diff --git a/spec/frontend/crm/contacts_root_spec.js b/spec/frontend/crm/contacts_root_spec.js
index b30349305a3..b02d94e9cb1 100644
--- a/spec/frontend/crm/contacts_root_spec.js
+++ b/spec/frontend/crm/contacts_root_spec.js
@@ -5,11 +5,9 @@ import VueRouter from 'vue-router';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import ContactsRoot from '~/crm/components/contacts_root.vue';
-import ContactForm from '~/crm/components/contact_form.vue';
-import getGroupContactsQuery from '~/crm/components/queries/get_group_contacts.query.graphql';
-import { NEW_ROUTE_NAME, EDIT_ROUTE_NAME } from '~/crm/constants';
-import routes from '~/crm/routes';
+import ContactsRoot from '~/crm/contacts/components/contacts_root.vue';
+import getGroupContactsQuery from '~/crm/contacts/components/graphql/get_group_contacts.query.graphql';
+import routes from '~/crm/contacts/routes';
import { getGroupContactsQueryResponse } from './mock_data';
describe('Customer relations contacts root app', () => {
@@ -23,8 +21,6 @@ describe('Customer relations contacts root app', () => {
const findRowByName = (rowName) => wrapper.findAllByRole('row', { name: rowName });
const findIssuesLinks = () => wrapper.findAllByTestId('issues-link');
const findNewContactButton = () => wrapper.findByTestId('new-contact-button');
- const findEditContactButton = () => wrapper.findByTestId('edit-contact-button');
- const findContactForm = () => wrapper.findComponent(ContactForm);
const findError = () => wrapper.findComponent(GlAlert);
const successQueryHandler = jest.fn().mockResolvedValue(getGroupContactsQueryResponse);
@@ -40,8 +36,8 @@ describe('Customer relations contacts root app', () => {
router,
provide: {
groupFullPath: 'flightjs',
- groupIssuesPath: '/issues',
groupId: 26,
+ groupIssuesPath: '/issues',
canAdminCrmContact,
},
apolloProvider: fakeApollo,
@@ -82,71 +78,6 @@ describe('Customer relations contacts root app', () => {
});
});
- describe('contact form', () => {
- it('should not exist by default', async () => {
- mountComponent();
- await waitForPromises();
-
- expect(findContactForm().exists()).toBe(false);
- });
-
- it('should exist when user clicks new contact button', async () => {
- mountComponent();
-
- findNewContactButton().vm.$emit('click');
- await waitForPromises();
-
- expect(findContactForm().exists()).toBe(true);
- });
-
- it('should exist when user navigates directly to `new` route', async () => {
- router.replace({ name: NEW_ROUTE_NAME });
- mountComponent();
- await waitForPromises();
-
- expect(findContactForm().exists()).toBe(true);
- });
-
- it('should exist when user clicks edit contact button', async () => {
- mountComponent({ mountFunction: mountExtended });
- await waitForPromises();
-
- findEditContactButton().vm.$emit('click');
- await waitForPromises();
-
- expect(findContactForm().exists()).toBe(true);
- });
-
- it('should exist when user navigates directly to `edit` route', async () => {
- router.replace({ name: EDIT_ROUTE_NAME, params: { id: 16 } });
- mountComponent();
- await waitForPromises();
-
- expect(findContactForm().exists()).toBe(true);
- });
-
- it('should not exist when new form emits close', async () => {
- router.replace({ name: NEW_ROUTE_NAME });
- mountComponent();
-
- findContactForm().vm.$emit('close');
- await waitForPromises();
-
- expect(findContactForm().exists()).toBe(false);
- });
-
- it('should not exist when edit form emits close', async () => {
- router.replace({ name: EDIT_ROUTE_NAME, params: { id: 16 } });
- mountComponent();
- await waitForPromises();
-
- findContactForm().vm.$emit('close');
- await waitForPromises();
-
- expect(findContactForm().exists()).toBe(false);
- });
- });
-
describe('error', () => {
it('should exist on reject', async () => {
mountComponent({ queryHandler: jest.fn().mockRejectedValue('ERROR') });
diff --git a/spec/frontend/crm/form_spec.js b/spec/frontend/crm/form_spec.js
index 0e3abc05c37..5c349b24ea1 100644
--- a/spec/frontend/crm/form_spec.js
+++ b/spec/frontend/crm/form_spec.js
@@ -6,12 +6,12 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import Form from '~/crm/components/form.vue';
-import routes from '~/crm/routes';
-import createContactMutation from '~/crm/components/queries/create_contact.mutation.graphql';
-import updateContactMutation from '~/crm/components/queries/update_contact.mutation.graphql';
-import getGroupContactsQuery from '~/crm/components/queries/get_group_contacts.query.graphql';
-import createOrganizationMutation from '~/crm/components/queries/create_organization.mutation.graphql';
-import getGroupOrganizationsQuery from '~/crm/components/queries/get_group_organizations.query.graphql';
+import routes from '~/crm/contacts/routes';
+import createContactMutation from '~/crm/contacts/components/graphql/create_contact.mutation.graphql';
+import updateContactMutation from '~/crm/contacts/components/graphql/update_contact.mutation.graphql';
+import getGroupContactsQuery from '~/crm/contacts/components/graphql/get_group_contacts.query.graphql';
+import createOrganizationMutation from '~/crm/organizations/components/graphql/create_organization.mutation.graphql';
+import getGroupOrganizationsQuery from '~/crm/organizations/components/graphql/get_group_organizations.query.graphql';
import {
createContactMutationErrorResponse,
createContactMutationResponse,
@@ -101,6 +101,11 @@ describe('Reusable form component', () => {
{ name: 'phone', label: 'Phone' },
{ name: 'description', label: 'Description' },
],
+ getQuery: {
+ query: getGroupContactsQuery,
+ variables: { groupFullPath: 'flightjs' },
+ },
+ getQueryNodePath: 'group.contacts',
...propsData,
});
};
@@ -108,13 +113,8 @@ describe('Reusable form component', () => {
const mountContactCreate = () => {
const propsData = {
title: 'New contact',
- successMessage: 'Contact has been added',
+ successMessage: 'Contact has been added.',
buttonLabel: 'Create contact',
- getQuery: {
- query: getGroupContactsQuery,
- variables: { groupFullPath: 'flightjs' },
- },
- getQueryNodePath: 'group.contacts',
mutation: createContactMutation,
additionalCreateParams: { groupId: 'gid://gitlab/Group/26' },
};
@@ -124,14 +124,9 @@ describe('Reusable form component', () => {
const mountContactUpdate = () => {
const propsData = {
title: 'Edit contact',
- successMessage: 'Contact has been updated',
+ successMessage: 'Contact has been updated.',
mutation: updateContactMutation,
- existingModel: {
- id: 'gid://gitlab/CustomerRelations::Contact/12',
- firstName: 'First',
- lastName: 'Last',
- email: 'email@example.com',
- },
+ existingId: 'gid://gitlab/CustomerRelations::Contact/12',
};
mountContact({ propsData });
};
@@ -143,6 +138,11 @@ describe('Reusable form component', () => {
{ name: 'defaultRate', label: 'Default rate', input: { type: 'number', step: '0.01' } },
{ name: 'description', label: 'Description' },
],
+ getQuery: {
+ query: getGroupOrganizationsQuery,
+ variables: { groupFullPath: 'flightjs' },
+ },
+ getQueryNodePath: 'group.organizations',
...propsData,
});
};
@@ -150,13 +150,8 @@ describe('Reusable form component', () => {
const mountOrganizationCreate = () => {
const propsData = {
title: 'New organization',
- successMessage: 'Organization has been added',
+ successMessage: 'Organization has been added.',
buttonLabel: 'Create organization',
- getQuery: {
- query: getGroupOrganizationsQuery,
- variables: { groupFullPath: 'flightjs' },
- },
- getQueryNodePath: 'group.organizations',
mutation: createOrganizationMutation,
additionalCreateParams: { groupId: 'gid://gitlab/Group/26' },
};
@@ -167,17 +162,17 @@ describe('Reusable form component', () => {
[FORM_CREATE_CONTACT]: {
mountFunction: mountContactCreate,
mutationErrorResponse: createContactMutationErrorResponse,
- toastMessage: 'Contact has been added',
+ toastMessage: 'Contact has been added.',
},
[FORM_UPDATE_CONTACT]: {
mountFunction: mountContactUpdate,
mutationErrorResponse: updateContactMutationErrorResponse,
- toastMessage: 'Contact has been updated',
+ toastMessage: 'Contact has been updated.',
},
[FORM_CREATE_ORG]: {
mountFunction: mountOrganizationCreate,
mutationErrorResponse: createOrganizationMutationErrorResponse,
- toastMessage: 'Organization has been added',
+ toastMessage: 'Organization has been added.',
},
};
const asTestParams = (...keys) => keys.map((name) => [name, forms[name]]);
diff --git a/spec/frontend/crm/mock_data.js b/spec/frontend/crm/mock_data.js
index e351e101b29..35bc7fb69b4 100644
--- a/spec/frontend/crm/mock_data.js
+++ b/spec/frontend/crm/mock_data.js
@@ -157,3 +157,28 @@ export const createOrganizationMutationErrorResponse = {
},
},
};
+
+export const updateOrganizationMutationResponse = {
+ data: {
+ customerRelationsOrganizationUpdate: {
+ __typeName: 'CustomerRelationsOrganizationUpdatePayload',
+ organization: {
+ __typename: 'CustomerRelationsOrganization',
+ id: 'gid://gitlab/CustomerRelations::Organization/2',
+ name: 'A',
+ defaultRate: null,
+ description: null,
+ },
+ errors: [],
+ },
+ },
+};
+
+export const updateOrganizationMutationErrorResponse = {
+ data: {
+ customerRelationsOrganizationUpdate: {
+ organization: null,
+ errors: ['Description is invalid.'],
+ },
+ },
+};
diff --git a/spec/frontend/crm/new_organization_form_spec.js b/spec/frontend/crm/new_organization_form_spec.js
deleted file mode 100644
index 0a7909774c9..00000000000
--- a/spec/frontend/crm/new_organization_form_spec.js
+++ /dev/null
@@ -1,109 +0,0 @@
-import { GlAlert } from '@gitlab/ui';
-import Vue from 'vue';
-import VueApollo from 'vue-apollo';
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import createMockApollo from 'helpers/mock_apollo_helper';
-import waitForPromises from 'helpers/wait_for_promises';
-import NewOrganizationForm from '~/crm/components/new_organization_form.vue';
-import createOrganizationMutation from '~/crm/components/queries/create_organization.mutation.graphql';
-import getGroupOrganizationsQuery from '~/crm/components/queries/get_group_organizations.query.graphql';
-import {
- createOrganizationMutationErrorResponse,
- createOrganizationMutationResponse,
- getGroupOrganizationsQueryResponse,
-} from './mock_data';
-
-describe('Customer relations organizations root app', () => {
- Vue.use(VueApollo);
- let wrapper;
- let fakeApollo;
- let queryHandler;
-
- const findCreateNewOrganizationButton = () =>
- wrapper.findByTestId('create-new-organization-button');
- const findCancelButton = () => wrapper.findByTestId('cancel-button');
- const findForm = () => wrapper.find('form');
- const findError = () => wrapper.findComponent(GlAlert);
-
- const mountComponent = () => {
- fakeApollo = createMockApollo([[createOrganizationMutation, queryHandler]]);
- fakeApollo.clients.defaultClient.cache.writeQuery({
- query: getGroupOrganizationsQuery,
- variables: { groupFullPath: 'flightjs' },
- data: getGroupOrganizationsQueryResponse.data,
- });
- wrapper = shallowMountExtended(NewOrganizationForm, {
- provide: { groupId: 26, groupFullPath: 'flightjs' },
- apolloProvider: fakeApollo,
- propsData: { drawerOpen: true },
- });
- };
-
- beforeEach(() => {
- queryHandler = jest.fn().mockResolvedValue(createOrganizationMutationResponse);
- });
-
- afterEach(() => {
- wrapper.destroy();
- fakeApollo = null;
- });
-
- describe('Create new organization button', () => {
- it('should be disabled by default', () => {
- mountComponent();
-
- expect(findCreateNewOrganizationButton().attributes('disabled')).toBeTruthy();
- });
-
- it('should not be disabled when first, last and email have values', async () => {
- mountComponent();
-
- wrapper.find('#organization-name').vm.$emit('input', 'A');
- await waitForPromises();
-
- expect(findCreateNewOrganizationButton().attributes('disabled')).toBeFalsy();
- });
- });
-
- it("should emit 'close' when cancel button is clicked", () => {
- mountComponent();
-
- findCancelButton().vm.$emit('click');
-
- expect(wrapper.emitted().close).toBeTruthy();
- });
-
- describe('when query is successful', () => {
- it("should emit 'close'", async () => {
- mountComponent();
-
- findForm().trigger('submit');
- await waitForPromises();
-
- expect(wrapper.emitted().close).toBeTruthy();
- });
- });
-
- describe('when query fails', () => {
- it('should show error on reject', async () => {
- queryHandler = jest.fn().mockRejectedValue('ERROR');
- mountComponent();
-
- findForm().trigger('submit');
- await waitForPromises();
-
- expect(findError().exists()).toBe(true);
- });
-
- it('should show error on error response', async () => {
- queryHandler = jest.fn().mockResolvedValue(createOrganizationMutationErrorResponse);
- mountComponent();
-
- findForm().trigger('submit');
- await waitForPromises();
-
- expect(findError().exists()).toBe(true);
- expect(findError().text()).toBe('create organization is invalid.');
- });
- });
-});
diff --git a/spec/frontend/crm/organization_form_wrapper_spec.js b/spec/frontend/crm/organization_form_wrapper_spec.js
new file mode 100644
index 00000000000..1a5a7c6ca5d
--- /dev/null
+++ b/spec/frontend/crm/organization_form_wrapper_spec.js
@@ -0,0 +1,88 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import OrganizationFormWrapper from '~/crm/organizations/components/organization_form_wrapper.vue';
+import OrganizationForm from '~/crm/components/form.vue';
+import getGroupOrganizationsQuery from '~/crm/organizations/components/graphql/get_group_organizations.query.graphql';
+import createOrganizationMutation from '~/crm/organizations/components/graphql/create_organization.mutation.graphql';
+import updateOrganizationMutation from '~/crm/organizations/components/graphql/update_organization.mutation.graphql';
+
+describe('Customer relations organization form wrapper', () => {
+ let wrapper;
+
+ const findOrganizationForm = () => wrapper.findComponent(OrganizationForm);
+
+ const $apollo = {
+ queries: {
+ organizations: {
+ loading: false,
+ },
+ },
+ };
+ const $route = {
+ params: {
+ id: 7,
+ },
+ };
+ const organizations = [{ id: 'gid://gitlab/CustomerRelations::Organization/7' }];
+
+ const mountComponent = ({ isEditMode = false } = {}) => {
+ wrapper = shallowMountExtended(OrganizationFormWrapper, {
+ propsData: {
+ isEditMode,
+ },
+ provide: {
+ groupFullPath: 'flightjs',
+ groupId: 26,
+ },
+ mocks: {
+ $apollo,
+ $route,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('in edit mode', () => {
+ it('should render organization form with correct props', () => {
+ mountComponent({ isEditMode: true });
+
+ const organizationForm = findOrganizationForm();
+ expect(organizationForm.props('fields')).toHaveLength(3);
+ expect(organizationForm.props('title')).toBe('Edit organization');
+ expect(organizationForm.props('successMessage')).toBe('Organization has been updated.');
+ expect(organizationForm.props('mutation')).toBe(updateOrganizationMutation);
+ expect(organizationForm.props('getQuery')).toMatchObject({
+ query: getGroupOrganizationsQuery,
+ variables: { groupFullPath: 'flightjs' },
+ });
+ expect(organizationForm.props('getQueryNodePath')).toBe('group.organizations');
+ expect(organizationForm.props('existingId')).toBe(organizations[0].id);
+ expect(organizationForm.props('additionalCreateParams')).toMatchObject({
+ groupId: 'gid://gitlab/Group/26',
+ });
+ });
+ });
+
+ describe('in create mode', () => {
+ it('should render organization form with correct props', () => {
+ mountComponent();
+
+ const organizationForm = findOrganizationForm();
+ expect(organizationForm.props('fields')).toHaveLength(3);
+ expect(organizationForm.props('title')).toBe('New organization');
+ expect(organizationForm.props('successMessage')).toBe('Organization has been added.');
+ expect(organizationForm.props('mutation')).toBe(createOrganizationMutation);
+ expect(organizationForm.props('getQuery')).toMatchObject({
+ query: getGroupOrganizationsQuery,
+ variables: { groupFullPath: 'flightjs' },
+ });
+ expect(organizationForm.props('getQueryNodePath')).toBe('group.organizations');
+ expect(organizationForm.props('existingId')).toBeNull();
+ expect(organizationForm.props('additionalCreateParams')).toMatchObject({
+ groupId: 'gid://gitlab/Group/26',
+ });
+ });
+ });
+});
diff --git a/spec/frontend/crm/organizations_root_spec.js b/spec/frontend/crm/organizations_root_spec.js
index aef417964f4..231208d938e 100644
--- a/spec/frontend/crm/organizations_root_spec.js
+++ b/spec/frontend/crm/organizations_root_spec.js
@@ -5,11 +5,9 @@ import VueRouter from 'vue-router';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import OrganizationsRoot from '~/crm/components/organizations_root.vue';
-import NewOrganizationForm from '~/crm/components/new_organization_form.vue';
-import { NEW_ROUTE_NAME } from '~/crm/constants';
-import routes from '~/crm/routes';
-import getGroupOrganizationsQuery from '~/crm/components/queries/get_group_organizations.query.graphql';
+import OrganizationsRoot from '~/crm/organizations/components/organizations_root.vue';
+import routes from '~/crm/organizations/routes';
+import getGroupOrganizationsQuery from '~/crm/organizations/components/graphql/get_group_organizations.query.graphql';
import { getGroupOrganizationsQueryResponse } from './mock_data';
describe('Customer relations organizations root app', () => {
@@ -23,7 +21,6 @@ describe('Customer relations organizations root app', () => {
const findRowByName = (rowName) => wrapper.findAllByRole('row', { name: rowName });
const findIssuesLinks = () => wrapper.findAllByTestId('issues-link');
const findNewOrganizationButton = () => wrapper.findByTestId('new-organization-button');
- const findNewOrganizationForm = () => wrapper.findComponent(NewOrganizationForm);
const findError = () => wrapper.findComponent(GlAlert);
const successQueryHandler = jest.fn().mockResolvedValue(getGroupOrganizationsQueryResponse);
@@ -37,7 +34,11 @@ describe('Customer relations organizations root app', () => {
fakeApollo = createMockApollo([[getGroupOrganizationsQuery, queryHandler]]);
wrapper = mountFunction(OrganizationsRoot, {
router,
- provide: { canAdminCrmOrganization, groupFullPath: 'flightjs', groupIssuesPath: '/issues' },
+ provide: {
+ canAdminCrmOrganization,
+ groupFullPath: 'flightjs',
+ groupIssuesPath: '/issues',
+ },
apolloProvider: fakeApollo,
});
};
@@ -76,42 +77,6 @@ describe('Customer relations organizations root app', () => {
});
});
- describe('new organization form', () => {
- it('should not exist by default', async () => {
- mountComponent();
- await waitForPromises();
-
- expect(findNewOrganizationForm().exists()).toBe(false);
- });
-
- it('should exist when user clicks new contact button', async () => {
- mountComponent();
-
- findNewOrganizationButton().vm.$emit('click');
- await waitForPromises();
-
- expect(findNewOrganizationForm().exists()).toBe(true);
- });
-
- it('should exist when user navigates directly to /new', async () => {
- router.replace({ name: NEW_ROUTE_NAME });
- mountComponent();
- await waitForPromises();
-
- expect(findNewOrganizationForm().exists()).toBe(true);
- });
-
- it('should not exist when form emits close', async () => {
- router.replace({ name: NEW_ROUTE_NAME });
- mountComponent();
-
- findNewOrganizationForm().vm.$emit('close');
- await waitForPromises();
-
- expect(findNewOrganizationForm().exists()).toBe(false);
- });
- });
-
it('should render error message on reject', async () => {
mountComponent({ queryHandler: jest.fn().mockRejectedValue('ERROR') });
await waitForPromises();
diff --git a/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap b/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap
index 4ecf82a4714..402e55347af 100644
--- a/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap
+++ b/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap
@@ -5,16 +5,19 @@ exports[`Design note component should match the snapshot 1`] = `
class="design-note note-form"
id="note_123"
>
- <user-avatar-link-stub
- imgalt="foo-bar"
- imgcssclasses=""
- imgsize="40"
- imgsrc=""
- linkhref=""
- tooltipplacement="top"
- tooltiptext=""
- username=""
- />
+ <gl-avatar-link-stub
+ class="gl-float-left gl-mr-3"
+ href="https://gitlab.com/user"
+ >
+ <gl-avatar-stub
+ alt="avatar"
+ entityid="0"
+ entityname="foo-bar"
+ shape="circle"
+ size="32"
+ src="https://gitlab.com/avatar"
+ />
+ </gl-avatar-link-stub>
<div
class="gl-display-flex gl-justify-content-space-between"
@@ -22,8 +25,10 @@ exports[`Design note component should match the snapshot 1`] = `
<div>
<gl-link-stub
class="js-user-link"
+ data-testid="user-link"
data-user-id="1"
data-username="foo-bar"
+ href="https://gitlab.com/user"
>
<span
class="note-header-author-name gl-font-weight-bold"
@@ -69,8 +74,9 @@ exports[`Design note component should match the snapshot 1`] = `
</div>
<div
- class="note-text js-note-text md"
+ class="note-text md"
data-qa-selector="note_content"
+ data-testid="note-text"
/>
</timeline-entry-item-stub>
diff --git a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js
index bbf2190ad47..77935fbde11 100644
--- a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js
+++ b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js
@@ -31,7 +31,6 @@ describe('Design discussions component', () => {
const findReplyForm = () => wrapper.find(DesignReplyForm);
const findRepliesWidget = () => wrapper.find(ToggleRepliesWidget);
const findResolveButton = () => wrapper.find('[data-testid="resolve-button"]');
- const findResolveIcon = () => wrapper.find('[data-testid="resolve-icon"]');
const findResolvedMessage = () => wrapper.find('[data-testid="resolved-message"]');
const findResolveLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findResolveCheckbox = () => wrapper.find('[data-testid="resolve-checkbox"]');
@@ -117,7 +116,7 @@ describe('Design discussions component', () => {
});
it('does not render an icon to resolve a thread', () => {
- expect(findResolveIcon().exists()).toBe(false);
+ expect(findResolveButton().exists()).toBe(false);
});
it('does not render a checkbox in reply form', async () => {
@@ -147,7 +146,7 @@ describe('Design discussions component', () => {
});
it('renders a correct icon to resolve a thread', () => {
- expect(findResolveIcon().props('name')).toBe('check-circle');
+ expect(findResolveButton().props('icon')).toBe('check-circle');
});
it('renders a checkbox with Resolve thread text in reply form', async () => {
@@ -203,7 +202,7 @@ describe('Design discussions component', () => {
});
it('renders a correct icon to resolve a thread', () => {
- expect(findResolveIcon().props('name')).toBe('check-circle-filled');
+ expect(findResolveButton().props('icon')).toBe('check-circle-filled');
});
it('emit todo:toggle when discussion is resolved', async () => {
diff --git a/spec/frontend/design_management/components/design_notes/design_note_spec.js b/spec/frontend/design_management/components/design_notes/design_note_spec.js
index 35fd1273270..1f84fde9f7f 100644
--- a/spec/frontend/design_management/components/design_notes/design_note_spec.js
+++ b/spec/frontend/design_management/components/design_notes/design_note_spec.js
@@ -1,10 +1,10 @@
-import { shallowMount } from '@vue/test-utils';
import { ApolloMutation } from 'vue-apollo';
import { nextTick } from 'vue';
+import { GlAvatar, GlAvatarLink } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import DesignNote from '~/design_management/components/design_notes/design_note.vue';
import DesignReplyForm from '~/design_management/components/design_notes/design_reply_form.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
-import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
const scrollIntoViewMock = jest.fn();
const note = {
@@ -12,6 +12,8 @@ const note = {
author: {
id: 'gid://gitlab/User/1',
username: 'foo-bar',
+ avatarUrl: 'https://gitlab.com/avatar',
+ webUrl: 'https://gitlab.com/user',
},
body: 'test',
userPermissions: {
@@ -30,14 +32,15 @@ const mutate = jest.fn().mockResolvedValue({ data: { updateNote: {} } });
describe('Design note component', () => {
let wrapper;
- const findUserAvatar = () => wrapper.find(UserAvatarLink);
- const findUserLink = () => wrapper.find('.js-user-link');
- const findReplyForm = () => wrapper.find(DesignReplyForm);
- const findEditButton = () => wrapper.find('.js-note-edit');
- const findNoteContent = () => wrapper.find('.js-note-text');
+ const findUserAvatar = () => wrapper.findComponent(GlAvatar);
+ const findUserAvatarLink = () => wrapper.findComponent(GlAvatarLink);
+ const findUserLink = () => wrapper.findByTestId('user-link');
+ const findReplyForm = () => wrapper.findComponent(DesignReplyForm);
+ const findEditButton = () => wrapper.findByTestId('note-edit');
+ const findNoteContent = () => wrapper.findByTestId('note-text');
function createComponent(props = {}, data = { isEditing: false }) {
- wrapper = shallowMount(DesignNote, {
+ wrapper = shallowMountExtended(DesignNote, {
propsData: {
note: {},
...props,
@@ -71,12 +74,24 @@ describe('Design note component', () => {
expect(wrapper.element).toMatchSnapshot();
});
- it('should render an author', () => {
+ it('should render avatar with correct props', () => {
+ createComponent({
+ note,
+ });
+
+ expect(findUserAvatar().props()).toMatchObject({
+ src: note.author.avatarUrl,
+ entityName: note.author.username,
+ });
+
+ expect(findUserAvatarLink().attributes('href')).toBe(note.author.webUrl);
+ });
+
+ it('should render author details', () => {
createComponent({
note,
});
- expect(findUserAvatar().exists()).toBe(true);
expect(findUserLink().exists()).toBe(true);
});
@@ -107,7 +122,7 @@ describe('Design note component', () => {
},
});
- findEditButton().trigger('click');
+ findEditButton().vm.$emit('click');
await nextTick();
expect(findReplyForm().exists()).toBe(true);
diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js
index a240a41959f..87531e8b645 100644
--- a/spec/frontend/design_management/pages/index_spec.js
+++ b/spec/frontend/design_management/pages/index_spec.js
@@ -183,7 +183,7 @@ describe('Design management index page', () => {
[moveDesignMutation, moveDesignHandler],
];
- fakeApollo = createMockApollo(requestHandlers);
+ fakeApollo = createMockApollo(requestHandlers, {}, { addTypename: true });
wrapper = shallowMount(Index, {
apolloProvider: fakeApollo,
router,
diff --git a/spec/frontend/diffs/components/commit_item_spec.js b/spec/frontend/diffs/components/commit_item_spec.js
index d887029124f..eee17e118a0 100644
--- a/spec/frontend/diffs/components/commit_item_spec.js
+++ b/spec/frontend/diffs/components/commit_item_spec.js
@@ -11,7 +11,9 @@ jest.mock('~/user_popovers');
const TEST_AUTHOR_NAME = 'test';
const TEST_AUTHOR_EMAIL = 'test+test@gitlab.com';
const TEST_AUTHOR_GRAVATAR = `${TEST_HOST}/avatar/test?s=40`;
-const TEST_SIGNATURE_HTML = '<a>Legit commit</a>';
+const TEST_SIGNATURE_HTML = `<a class="btn gpg-status-box valid" data-content="signature-content" data-html="true" data-placement="top" data-title="signature-title" data-toggle="popover" role="button" tabindex="0">
+ Verified
+</a>`;
const TEST_PIPELINE_STATUS_PATH = `${TEST_HOST}/pipeline/status`;
describe('diffs/components/commit_item', () => {
@@ -82,7 +84,7 @@ describe('diffs/components/commit_item', () => {
const imgElement = avatarElement.find('img');
expect(avatarElement.attributes('href')).toBe(commit.author.web_url);
- expect(imgElement.classes()).toContain('s40');
+ expect(imgElement.classes()).toContain('s32');
expect(imgElement.attributes('alt')).toBe(commit.author.name);
expect(imgElement.attributes('src')).toBe(commit.author.avatar_url);
});
@@ -156,8 +158,9 @@ describe('diffs/components/commit_item', () => {
it('renders signature html', () => {
const actionsElement = getCommitActionsElement();
+ const signatureElement = actionsElement.find('.gpg-status-box');
- expect(actionsElement.html()).toContain(TEST_SIGNATURE_HTML);
+ expect(signatureElement.html()).toBe(TEST_SIGNATURE_HTML);
});
});
diff --git a/spec/frontend/diffs/components/diff_line_note_form_spec.js b/spec/frontend/diffs/components/diff_line_note_form_spec.js
index 0ccf996e220..fb9dc22ce25 100644
--- a/spec/frontend/diffs/components/diff_line_note_form_spec.js
+++ b/spec/frontend/diffs/components/diff_line_note_form_spec.js
@@ -4,7 +4,7 @@ import DiffLineNoteForm from '~/diffs/components/diff_line_note_form.vue';
import { createStore } from '~/mr_notes/stores';
import NoteForm from '~/notes/components/note_form.vue';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
-import { noteableDataMock } from '../../notes/mock_data';
+import { noteableDataMock } from 'jest/notes/mock_data';
import diffFileMockData from '../mock_data/diff_file';
jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal', () => {
@@ -98,7 +98,7 @@ describe('DiffLineNoteForm', () => {
});
describe('saveNoteForm', () => {
- it('should call saveNote action with proper params', (done) => {
+ it('should call saveNote action with proper params', async () => {
const saveDiffDiscussionSpy = jest
.spyOn(wrapper.vm, 'saveDiffDiscussion')
.mockReturnValue(Promise.resolve());
@@ -123,16 +123,11 @@ describe('DiffLineNoteForm', () => {
lineRange,
};
- wrapper.vm
- .handleSaveNote('note body')
- .then(() => {
- expect(saveDiffDiscussionSpy).toHaveBeenCalledWith({
- note: 'note body',
- formData,
- });
- })
- .then(done)
- .catch(done.fail);
+ await wrapper.vm.handleSaveNote('note body');
+ expect(saveDiffDiscussionSpy).toHaveBeenCalledWith({
+ note: 'note body',
+ formData,
+ });
});
});
});
diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js
index d6a2aa104cd..3b567fbc704 100644
--- a/spec/frontend/diffs/store/actions_spec.js
+++ b/spec/frontend/diffs/store/actions_spec.js
@@ -9,46 +9,7 @@ import {
INLINE_DIFF_VIEW_TYPE,
PARALLEL_DIFF_VIEW_TYPE,
} from '~/diffs/constants';
-import {
- setBaseConfig,
- fetchDiffFilesBatch,
- fetchDiffFilesMeta,
- fetchCoverageFiles,
- assignDiscussionsToDiff,
- removeDiscussionsFromDiff,
- startRenderDiffsQueue,
- setInlineDiffViewType,
- setParallelDiffViewType,
- showCommentForm,
- cancelCommentForm,
- loadMoreLines,
- scrollToLineIfNeededInline,
- scrollToLineIfNeededParallel,
- loadCollapsedDiff,
- toggleFileDiscussions,
- saveDiffDiscussion,
- setHighlightedRow,
- toggleTreeOpen,
- scrollToFile,
- setShowTreeList,
- renderFileForDiscussionId,
- setRenderTreeList,
- setShowWhitespace,
- setRenderIt,
- receiveFullDiffError,
- fetchFullDiff,
- toggleFullDiff,
- switchToFullDiffFromRenamedFile,
- setFileCollapsedByUser,
- setExpandedDiffLines,
- setSuggestPopoverDismissed,
- changeCurrentCommit,
- moveToNeighboringCommit,
- setCurrentDiffFileIdFromNote,
- navigateToDiffFileIndex,
- setFileByFile,
- reviewFile,
-} from '~/diffs/store/actions';
+import * as diffActions from '~/diffs/store/actions';
import * as types from '~/diffs/store/mutation_types';
import * as utils from '~/diffs/store/utils';
import * as treeWorkerUtils from '~/diffs/utils/tree_worker_utils';
@@ -62,6 +23,8 @@ import { diffMetadata } from '../mock_data/diff_metadata';
jest.mock('~/flash');
describe('DiffsStoreActions', () => {
+ let mock;
+
useLocalStorageSpy();
const originalMethods = {
@@ -83,15 +46,20 @@ describe('DiffsStoreActions', () => {
});
});
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
afterEach(() => {
['requestAnimationFrame', 'requestIdleCallback'].forEach((method) => {
global[method] = originalMethods[method];
});
createFlash.mockClear();
+ mock.restore();
});
describe('setBaseConfig', () => {
- it('should set given endpoint and project path', (done) => {
+ it('should set given endpoint and project path', () => {
const endpoint = '/diffs/set/endpoint';
const endpointMetadata = '/diffs/set/endpoint/metadata';
const endpointBatch = '/diffs/set/endpoint/batch';
@@ -104,8 +72,8 @@ describe('DiffsStoreActions', () => {
b: ['y', 'hash:a'],
};
- testAction(
- setBaseConfig,
+ return testAction(
+ diffActions.setBaseConfig,
{
endpoint,
endpointBatch,
@@ -153,23 +121,12 @@ describe('DiffsStoreActions', () => {
},
],
[],
- done,
);
});
});
describe('fetchDiffFilesBatch', () => {
- let mock;
-
- beforeEach(() => {
- mock = new MockAdapter(axios);
- });
-
- afterEach(() => {
- mock.restore();
- });
-
- it('should fetch batch diff files', (done) => {
+ it('should fetch batch diff files', () => {
const endpointBatch = '/fetch/diffs_batch';
const res1 = { diff_files: [{ file_hash: 'test' }], pagination: { total_pages: 7 } };
const res2 = { diff_files: [{ file_hash: 'test2' }], pagination: { total_pages: 7 } };
@@ -199,8 +156,8 @@ describe('DiffsStoreActions', () => {
)
.reply(200, res2);
- testAction(
- fetchDiffFilesBatch,
+ return testAction(
+ diffActions.fetchDiffFilesBatch,
{},
{ endpointBatch, diffViewType: 'inline', diffFiles: [] },
[
@@ -216,7 +173,6 @@ describe('DiffsStoreActions', () => {
{ type: types.SET_BATCH_LOADING_STATE, payload: 'error' },
],
[{ type: 'startRenderDiffsQueue' }, { type: 'startRenderDiffsQueue' }],
- done,
);
});
@@ -229,13 +185,14 @@ describe('DiffsStoreActions', () => {
({ viewStyle, otherView }) => {
const endpointBatch = '/fetch/diffs_batch';
- fetchDiffFilesBatch({
- commit: () => {},
- state: {
- endpointBatch: `${endpointBatch}?view=${otherView}`,
- diffViewType: viewStyle,
- },
- })
+ diffActions
+ .fetchDiffFilesBatch({
+ commit: () => {},
+ state: {
+ endpointBatch: `${endpointBatch}?view=${otherView}`,
+ diffViewType: viewStyle,
+ },
+ })
.then(() => {
expect(mock.history.get[0].url).toContain(`view=${viewStyle}`);
expect(mock.history.get[0].url).not.toContain(`view=${otherView}`);
@@ -248,19 +205,16 @@ describe('DiffsStoreActions', () => {
describe('fetchDiffFilesMeta', () => {
const endpointMetadata = '/fetch/diffs_metadata.json?view=inline';
const noFilesData = { ...diffMetadata };
- let mock;
beforeEach(() => {
- mock = new MockAdapter(axios);
-
delete noFilesData.diff_files;
mock.onGet(endpointMetadata).reply(200, diffMetadata);
});
- it('should fetch diff meta information', (done) => {
- testAction(
- fetchDiffFilesMeta,
+ it('should fetch diff meta information', () => {
+ return testAction(
+ diffActions.fetchDiffFilesMeta,
{},
{ endpointMetadata, diffViewType: 'inline' },
[
@@ -275,55 +229,41 @@ describe('DiffsStoreActions', () => {
},
],
[],
- () => {
- mock.restore();
- done();
- },
);
});
});
describe('fetchCoverageFiles', () => {
- let mock;
const endpointCoverage = '/fetch';
- beforeEach(() => {
- mock = new MockAdapter(axios);
- });
-
- afterEach(() => mock.restore());
-
- it('should commit SET_COVERAGE_DATA with received response', (done) => {
+ it('should commit SET_COVERAGE_DATA with received response', () => {
const data = { files: { 'app.js': { 1: 0, 2: 1 } } };
mock.onGet(endpointCoverage).reply(200, { data });
- testAction(
- fetchCoverageFiles,
+ return testAction(
+ diffActions.fetchCoverageFiles,
{},
{ endpointCoverage },
[{ type: types.SET_COVERAGE_DATA, payload: { data } }],
[],
- done,
);
});
- it('should show flash on API error', (done) => {
+ it('should show flash on API error', async () => {
mock.onGet(endpointCoverage).reply(400);
- testAction(fetchCoverageFiles, {}, { endpointCoverage }, [], [], () => {
- expect(createFlash).toHaveBeenCalledTimes(1);
- expect(createFlash).toHaveBeenCalledWith({
- message: expect.stringMatching('Something went wrong'),
- });
- done();
+ await testAction(diffActions.fetchCoverageFiles, {}, { endpointCoverage }, [], []);
+ expect(createFlash).toHaveBeenCalledTimes(1);
+ expect(createFlash).toHaveBeenCalledWith({
+ message: expect.stringMatching('Something went wrong'),
});
});
});
describe('setHighlightedRow', () => {
it('should mark currently selected diff and set lineHash and fileHash of highlightedRow', () => {
- testAction(setHighlightedRow, 'ABC_123', {}, [
+ return testAction(diffActions.setHighlightedRow, 'ABC_123', {}, [
{ type: types.SET_HIGHLIGHTED_ROW, payload: 'ABC_123' },
{ type: types.SET_CURRENT_DIFF_FILE, payload: 'ABC' },
]);
@@ -335,7 +275,7 @@ describe('DiffsStoreActions', () => {
window.location.hash = '';
});
- it('should merge discussions into diffs', (done) => {
+ it('should merge discussions into diffs', () => {
window.location.hash = 'ABC_123';
const state = {
@@ -397,8 +337,8 @@ describe('DiffsStoreActions', () => {
const discussions = [singleDiscussion];
- testAction(
- assignDiscussionsToDiff,
+ return testAction(
+ diffActions.assignDiscussionsToDiff,
discussions,
state,
[
@@ -425,26 +365,24 @@ describe('DiffsStoreActions', () => {
},
],
[],
- done,
);
});
- it('dispatches setCurrentDiffFileIdFromNote with note ID', (done) => {
+ it('dispatches setCurrentDiffFileIdFromNote with note ID', () => {
window.location.hash = 'note_123';
- testAction(
- assignDiscussionsToDiff,
+ return testAction(
+ diffActions.assignDiscussionsToDiff,
[],
{ diffFiles: [] },
[],
[{ type: 'setCurrentDiffFileIdFromNote', payload: '123' }],
- done,
);
});
});
describe('removeDiscussionsFromDiff', () => {
- it('should remove discussions from diffs', (done) => {
+ it('should remove discussions from diffs', () => {
const state = {
diffFiles: [
{
@@ -480,8 +418,8 @@ describe('DiffsStoreActions', () => {
line_code: 'ABC_1_1',
};
- testAction(
- removeDiscussionsFromDiff,
+ return testAction(
+ diffActions.removeDiscussionsFromDiff,
singleDiscussion,
state,
[
@@ -495,7 +433,6 @@ describe('DiffsStoreActions', () => {
},
],
[],
- done,
);
});
});
@@ -528,7 +465,7 @@ describe('DiffsStoreActions', () => {
});
};
- startRenderDiffsQueue({ state, commit: pseudoCommit });
+ diffActions.startRenderDiffsQueue({ state, commit: pseudoCommit });
expect(state.diffFiles[0].renderIt).toBe(true);
expect(state.diffFiles[1].renderIt).toBe(true);
@@ -536,69 +473,61 @@ describe('DiffsStoreActions', () => {
});
describe('setInlineDiffViewType', () => {
- it('should set diff view type to inline and also set the cookie properly', (done) => {
- testAction(
- setInlineDiffViewType,
+ it('should set diff view type to inline and also set the cookie properly', async () => {
+ await testAction(
+ diffActions.setInlineDiffViewType,
null,
{},
[{ type: types.SET_DIFF_VIEW_TYPE, payload: INLINE_DIFF_VIEW_TYPE }],
[],
- () => {
- expect(Cookies.get('diff_view')).toEqual(INLINE_DIFF_VIEW_TYPE);
- done();
- },
);
+ expect(Cookies.get('diff_view')).toEqual(INLINE_DIFF_VIEW_TYPE);
});
});
describe('setParallelDiffViewType', () => {
- it('should set diff view type to parallel and also set the cookie properly', (done) => {
- testAction(
- setParallelDiffViewType,
+ it('should set diff view type to parallel and also set the cookie properly', async () => {
+ await testAction(
+ diffActions.setParallelDiffViewType,
null,
{},
[{ type: types.SET_DIFF_VIEW_TYPE, payload: PARALLEL_DIFF_VIEW_TYPE }],
[],
- () => {
- expect(Cookies.get(DIFF_VIEW_COOKIE_NAME)).toEqual(PARALLEL_DIFF_VIEW_TYPE);
- done();
- },
);
+ expect(Cookies.get(DIFF_VIEW_COOKIE_NAME)).toEqual(PARALLEL_DIFF_VIEW_TYPE);
});
});
describe('showCommentForm', () => {
- it('should call mutation to show comment form', (done) => {
+ it('should call mutation to show comment form', () => {
const payload = { lineCode: 'lineCode', fileHash: 'hash' };
- testAction(
- showCommentForm,
+ return testAction(
+ diffActions.showCommentForm,
payload,
{},
[{ type: types.TOGGLE_LINE_HAS_FORM, payload: { ...payload, hasForm: true } }],
[],
- done,
);
});
});
describe('cancelCommentForm', () => {
- it('should call mutation to cancel comment form', (done) => {
+ it('should call mutation to cancel comment form', () => {
const payload = { lineCode: 'lineCode', fileHash: 'hash' };
- testAction(
- cancelCommentForm,
+ return testAction(
+ diffActions.cancelCommentForm,
payload,
{},
[{ type: types.TOGGLE_LINE_HAS_FORM, payload: { ...payload, hasForm: false } }],
[],
- done,
);
});
});
describe('loadMoreLines', () => {
- it('should call mutation to show comment form', (done) => {
+ it('should call mutation to show comment form', () => {
const endpoint = '/diffs/load/more/lines';
const params = { since: 6, to: 26 };
const lineNumbers = { oldLineNumber: 3, newLineNumber: 5 };
@@ -606,12 +535,11 @@ describe('DiffsStoreActions', () => {
const isExpandDown = false;
const nextLineNumbers = {};
const options = { endpoint, params, lineNumbers, fileHash, isExpandDown, nextLineNumbers };
- const mock = new MockAdapter(axios);
const contextLines = { contextLines: [{ lineCode: 6 }] };
mock.onGet(endpoint).reply(200, contextLines);
- testAction(
- loadMoreLines,
+ return testAction(
+ diffActions.loadMoreLines,
options,
{},
[
@@ -621,31 +549,23 @@ describe('DiffsStoreActions', () => {
},
],
[],
- () => {
- mock.restore();
- done();
- },
);
});
});
describe('loadCollapsedDiff', () => {
const state = { showWhitespace: true };
- it('should fetch data and call mutation with response and the give parameter', (done) => {
+ it('should fetch data and call mutation with response and the give parameter', () => {
const file = { hash: 123, load_collapsed_diff_url: '/load/collapsed/diff/url' };
const data = { hash: 123, parallelDiffLines: [{ lineCode: 1 }] };
- const mock = new MockAdapter(axios);
const commit = jest.fn();
mock.onGet(file.loadCollapsedDiffUrl).reply(200, data);
- loadCollapsedDiff({ commit, getters: { commitId: null }, state }, file)
+ return diffActions
+ .loadCollapsedDiff({ commit, getters: { commitId: null }, state }, file)
.then(() => {
expect(commit).toHaveBeenCalledWith(types.ADD_COLLAPSED_DIFFS, { file, data });
-
- mock.restore();
- done();
- })
- .catch(done.fail);
+ });
});
it('should fetch data without commit ID', () => {
@@ -656,7 +576,7 @@ describe('DiffsStoreActions', () => {
jest.spyOn(axios, 'get').mockReturnValue(Promise.resolve({ data: {} }));
- loadCollapsedDiff({ commit() {}, getters, state }, file);
+ diffActions.loadCollapsedDiff({ commit() {}, getters, state }, file);
expect(axios.get).toHaveBeenCalledWith(file.load_collapsed_diff_url, {
params: { commit_id: null, w: '0' },
@@ -671,7 +591,7 @@ describe('DiffsStoreActions', () => {
jest.spyOn(axios, 'get').mockReturnValue(Promise.resolve({ data: {} }));
- loadCollapsedDiff({ commit() {}, getters, state }, file);
+ diffActions.loadCollapsedDiff({ commit() {}, getters, state }, file);
expect(axios.get).toHaveBeenCalledWith(file.load_collapsed_diff_url, {
params: { commit_id: '123', w: '0' },
@@ -689,7 +609,7 @@ describe('DiffsStoreActions', () => {
const dispatch = jest.fn();
- toggleFileDiscussions({ getters, dispatch });
+ diffActions.toggleFileDiscussions({ getters, dispatch });
expect(dispatch).toHaveBeenCalledWith(
'collapseDiscussion',
@@ -707,7 +627,7 @@ describe('DiffsStoreActions', () => {
const dispatch = jest.fn();
- toggleFileDiscussions({ getters, dispatch });
+ diffActions.toggleFileDiscussions({ getters, dispatch });
expect(dispatch).toHaveBeenCalledWith(
'expandDiscussion',
@@ -725,7 +645,7 @@ describe('DiffsStoreActions', () => {
const dispatch = jest.fn();
- toggleFileDiscussions({ getters, dispatch });
+ diffActions.toggleFileDiscussions({ getters, dispatch });
expect(dispatch).toHaveBeenCalledWith(
'expandDiscussion',
@@ -743,7 +663,7 @@ describe('DiffsStoreActions', () => {
it('should not call handleLocationHash when there is not hash', () => {
window.location.hash = '';
- scrollToLineIfNeededInline({}, lineMock);
+ diffActions.scrollToLineIfNeededInline({}, lineMock);
expect(commonUtils.handleLocationHash).not.toHaveBeenCalled();
});
@@ -751,7 +671,7 @@ describe('DiffsStoreActions', () => {
it('should not call handleLocationHash when the hash does not match any line', () => {
window.location.hash = 'XYZ_456';
- scrollToLineIfNeededInline({}, lineMock);
+ diffActions.scrollToLineIfNeededInline({}, lineMock);
expect(commonUtils.handleLocationHash).not.toHaveBeenCalled();
});
@@ -759,14 +679,14 @@ describe('DiffsStoreActions', () => {
it('should call handleLocationHash only when the hash matches a line', () => {
window.location.hash = 'ABC_123';
- scrollToLineIfNeededInline(
+ diffActions.scrollToLineIfNeededInline(
{},
{
lineCode: 'ABC_456',
},
);
- scrollToLineIfNeededInline({}, lineMock);
- scrollToLineIfNeededInline(
+ diffActions.scrollToLineIfNeededInline({}, lineMock);
+ diffActions.scrollToLineIfNeededInline(
{},
{
lineCode: 'XYZ_456',
@@ -789,7 +709,7 @@ describe('DiffsStoreActions', () => {
it('should not call handleLocationHash when there is not hash', () => {
window.location.hash = '';
- scrollToLineIfNeededParallel({}, lineMock);
+ diffActions.scrollToLineIfNeededParallel({}, lineMock);
expect(commonUtils.handleLocationHash).not.toHaveBeenCalled();
});
@@ -797,7 +717,7 @@ describe('DiffsStoreActions', () => {
it('should not call handleLocationHash when the hash does not match any line', () => {
window.location.hash = 'XYZ_456';
- scrollToLineIfNeededParallel({}, lineMock);
+ diffActions.scrollToLineIfNeededParallel({}, lineMock);
expect(commonUtils.handleLocationHash).not.toHaveBeenCalled();
});
@@ -805,7 +725,7 @@ describe('DiffsStoreActions', () => {
it('should call handleLocationHash only when the hash matches a line', () => {
window.location.hash = 'ABC_123';
- scrollToLineIfNeededParallel(
+ diffActions.scrollToLineIfNeededParallel(
{},
{
left: null,
@@ -814,8 +734,8 @@ describe('DiffsStoreActions', () => {
},
},
);
- scrollToLineIfNeededParallel({}, lineMock);
- scrollToLineIfNeededParallel(
+ diffActions.scrollToLineIfNeededParallel({}, lineMock);
+ diffActions.scrollToLineIfNeededParallel(
{},
{
left: null,
@@ -831,7 +751,7 @@ describe('DiffsStoreActions', () => {
});
describe('saveDiffDiscussion', () => {
- it('dispatches actions', (done) => {
+ it('dispatches actions', () => {
const commitId = 'something';
const formData = {
diffFile: { ...mockDiffFile },
@@ -856,33 +776,29 @@ describe('DiffsStoreActions', () => {
}
});
- saveDiffDiscussion({ state, dispatch }, { note, formData })
- .then(() => {
- expect(dispatch).toHaveBeenCalledTimes(5);
- expect(dispatch).toHaveBeenNthCalledWith(1, 'saveNote', expect.any(Object), {
- root: true,
- });
+ return diffActions.saveDiffDiscussion({ state, dispatch }, { note, formData }).then(() => {
+ expect(dispatch).toHaveBeenCalledTimes(5);
+ expect(dispatch).toHaveBeenNthCalledWith(1, 'saveNote', expect.any(Object), {
+ root: true,
+ });
- const postData = dispatch.mock.calls[0][1];
- expect(postData.data.note.commit_id).toBe(commitId);
+ const postData = dispatch.mock.calls[0][1];
+ expect(postData.data.note.commit_id).toBe(commitId);
- expect(dispatch).toHaveBeenNthCalledWith(2, 'updateDiscussion', 'test', { root: true });
- expect(dispatch).toHaveBeenNthCalledWith(3, 'assignDiscussionsToDiff', ['discussion']);
- })
- .then(done)
- .catch(done.fail);
+ expect(dispatch).toHaveBeenNthCalledWith(2, 'updateDiscussion', 'test', { root: true });
+ expect(dispatch).toHaveBeenNthCalledWith(3, 'assignDiscussionsToDiff', ['discussion']);
+ });
});
});
describe('toggleTreeOpen', () => {
- it('commits TOGGLE_FOLDER_OPEN', (done) => {
- testAction(
- toggleTreeOpen,
+ it('commits TOGGLE_FOLDER_OPEN', () => {
+ return testAction(
+ diffActions.toggleTreeOpen,
'path',
{},
[{ type: types.TOGGLE_FOLDER_OPEN, payload: 'path' }],
[],
- done,
);
});
});
@@ -904,7 +820,7 @@ describe('DiffsStoreActions', () => {
},
};
- scrollToFile({ state, commit, getters }, { path: 'path' });
+ diffActions.scrollToFile({ state, commit, getters }, { path: 'path' });
expect(document.location.hash).toBe('#test');
});
@@ -918,28 +834,27 @@ describe('DiffsStoreActions', () => {
},
};
- scrollToFile({ state, commit, getters }, { path: 'path' });
+ diffActions.scrollToFile({ state, commit, getters }, { path: 'path' });
expect(commit).toHaveBeenCalledWith(types.SET_CURRENT_DIFF_FILE, 'test');
});
});
describe('setShowTreeList', () => {
- it('commits toggle', (done) => {
- testAction(
- setShowTreeList,
+ it('commits toggle', () => {
+ return testAction(
+ diffActions.setShowTreeList,
{ showTreeList: true },
{},
[{ type: types.SET_SHOW_TREE_LIST, payload: true }],
[],
- done,
);
});
it('updates localStorage', () => {
jest.spyOn(localStorage, 'setItem').mockImplementation(() => {});
- setShowTreeList({ commit() {} }, { showTreeList: true });
+ diffActions.setShowTreeList({ commit() {} }, { showTreeList: true });
expect(localStorage.setItem).toHaveBeenCalledWith('mr_tree_show', true);
});
@@ -947,7 +862,7 @@ describe('DiffsStoreActions', () => {
it('does not update localStorage', () => {
jest.spyOn(localStorage, 'setItem').mockImplementation(() => {});
- setShowTreeList({ commit() {} }, { showTreeList: true, saving: false });
+ diffActions.setShowTreeList({ commit() {} }, { showTreeList: true, saving: false });
expect(localStorage.setItem).not.toHaveBeenCalled();
});
@@ -994,7 +909,7 @@ describe('DiffsStoreActions', () => {
it('renders and expands file for the given discussion id', () => {
const localState = state({ collapsed: true, renderIt: false });
- renderFileForDiscussionId({ rootState, state: localState, commit }, '123');
+ diffActions.renderFileForDiscussionId({ rootState, state: localState, commit }, '123');
expect(commit).toHaveBeenCalledWith('RENDER_FILE', localState.diffFiles[0]);
expect($emit).toHaveBeenCalledTimes(1);
@@ -1004,7 +919,7 @@ describe('DiffsStoreActions', () => {
it('jumps to discussion on already rendered and expanded file', () => {
const localState = state({ collapsed: false, renderIt: true });
- renderFileForDiscussionId({ rootState, state: localState, commit }, '123');
+ diffActions.renderFileForDiscussionId({ rootState, state: localState, commit }, '123');
expect(commit).not.toHaveBeenCalled();
expect($emit).toHaveBeenCalledTimes(1);
@@ -1013,19 +928,18 @@ describe('DiffsStoreActions', () => {
});
describe('setRenderTreeList', () => {
- it('commits SET_RENDER_TREE_LIST', (done) => {
- testAction(
- setRenderTreeList,
+ it('commits SET_RENDER_TREE_LIST', () => {
+ return testAction(
+ diffActions.setRenderTreeList,
{ renderTreeList: true },
{},
[{ type: types.SET_RENDER_TREE_LIST, payload: true }],
[],
- done,
);
});
it('sets localStorage', () => {
- setRenderTreeList({ commit() {} }, { renderTreeList: true });
+ diffActions.setRenderTreeList({ commit() {} }, { renderTreeList: true });
expect(localStorage.setItem).toHaveBeenCalledWith('mr_diff_tree_list', true);
});
@@ -1034,11 +948,9 @@ describe('DiffsStoreActions', () => {
describe('setShowWhitespace', () => {
const endpointUpdateUser = 'user/prefs';
let putSpy;
- let mock;
let gon;
beforeEach(() => {
- mock = new MockAdapter(axios);
putSpy = jest.spyOn(axios, 'put');
gon = window.gon;
@@ -1047,25 +959,23 @@ describe('DiffsStoreActions', () => {
});
afterEach(() => {
- mock.restore();
window.gon = gon;
});
- it('commits SET_SHOW_WHITESPACE', (done) => {
- testAction(
- setShowWhitespace,
+ it('commits SET_SHOW_WHITESPACE', () => {
+ return testAction(
+ diffActions.setShowWhitespace,
{ showWhitespace: true, updateDatabase: false },
{},
[{ type: types.SET_SHOW_WHITESPACE, payload: true }],
[],
- done,
);
});
it('saves to the database when the user is logged in', async () => {
window.gon = { current_user_id: 12345 };
- await setShowWhitespace(
+ await diffActions.setShowWhitespace(
{ state: { endpointUpdateUser }, commit() {} },
{ showWhitespace: true, updateDatabase: true },
);
@@ -1076,7 +986,7 @@ describe('DiffsStoreActions', () => {
it('does not try to save to the API if the user is not logged in', async () => {
window.gon = {};
- await setShowWhitespace(
+ await diffActions.setShowWhitespace(
{ state: { endpointUpdateUser }, commit() {} },
{ showWhitespace: true, updateDatabase: true },
);
@@ -1085,7 +995,7 @@ describe('DiffsStoreActions', () => {
});
it('emits eventHub event', async () => {
- await setShowWhitespace(
+ await diffActions.setShowWhitespace(
{ state: {}, commit() {} },
{ showWhitespace: true, updateDatabase: false },
);
@@ -1095,53 +1005,47 @@ describe('DiffsStoreActions', () => {
});
describe('setRenderIt', () => {
- it('commits RENDER_FILE', (done) => {
- testAction(setRenderIt, 'file', {}, [{ type: types.RENDER_FILE, payload: 'file' }], [], done);
+ it('commits RENDER_FILE', () => {
+ return testAction(
+ diffActions.setRenderIt,
+ 'file',
+ {},
+ [{ type: types.RENDER_FILE, payload: 'file' }],
+ [],
+ );
});
});
describe('receiveFullDiffError', () => {
- it('updates state with the file that did not load', (done) => {
- testAction(
- receiveFullDiffError,
+ it('updates state with the file that did not load', () => {
+ return testAction(
+ diffActions.receiveFullDiffError,
'file',
{},
[{ type: types.RECEIVE_FULL_DIFF_ERROR, payload: 'file' }],
[],
- done,
);
});
});
describe('fetchFullDiff', () => {
- let mock;
-
- beforeEach(() => {
- mock = new MockAdapter(axios);
- });
-
- afterEach(() => {
- mock.restore();
- });
-
describe('success', () => {
beforeEach(() => {
mock.onGet(`${TEST_HOST}/context`).replyOnce(200, ['test']);
});
- it('commits the success and dispatches an action to expand the new lines', (done) => {
+ it('commits the success and dispatches an action to expand the new lines', () => {
const file = {
context_lines_path: `${TEST_HOST}/context`,
file_path: 'test',
file_hash: 'test',
};
- testAction(
- fetchFullDiff,
+ return testAction(
+ diffActions.fetchFullDiff,
file,
null,
[{ type: types.RECEIVE_FULL_DIFF_SUCCESS, payload: { filePath: 'test' } }],
[{ type: 'setExpandedDiffLines', payload: { file, data: ['test'] } }],
- done,
);
});
});
@@ -1151,14 +1055,13 @@ describe('DiffsStoreActions', () => {
mock.onGet(`${TEST_HOST}/context`).replyOnce(500);
});
- it('dispatches receiveFullDiffError', (done) => {
- testAction(
- fetchFullDiff,
+ it('dispatches receiveFullDiffError', () => {
+ return testAction(
+ diffActions.fetchFullDiff,
{ context_lines_path: `${TEST_HOST}/context`, file_path: 'test', file_hash: 'test' },
null,
[],
[{ type: 'receiveFullDiffError', payload: 'test' }],
- done,
);
});
});
@@ -1173,14 +1076,13 @@ describe('DiffsStoreActions', () => {
};
});
- it('dispatches fetchFullDiff when file is not expanded', (done) => {
- testAction(
- toggleFullDiff,
+ it('dispatches fetchFullDiff when file is not expanded', () => {
+ return testAction(
+ diffActions.toggleFullDiff,
'test',
state,
[{ type: types.REQUEST_FULL_DIFF, payload: 'test' }],
[{ type: 'fetchFullDiff', payload: state.diffFiles[0] }],
- done,
);
});
});
@@ -1202,16 +1104,13 @@ describe('DiffsStoreActions', () => {
};
const testData = [{ rich_text: 'test' }, { rich_text: 'file2' }];
let renamedFile;
- let mock;
beforeEach(() => {
- mock = new MockAdapter(axios);
jest.spyOn(utils, 'prepareLineForRenamedFile').mockImplementation(() => preparedLine);
});
afterEach(() => {
renamedFile = null;
- mock.restore();
});
describe('success', () => {
@@ -1228,7 +1127,7 @@ describe('DiffsStoreActions', () => {
'performs the correct mutations and starts a render queue for view type $diffViewType',
({ diffViewType }) => {
return testAction(
- switchToFullDiffFromRenamedFile,
+ diffActions.switchToFullDiffFromRenamedFile,
{ diffFile: renamedFile },
{ diffViewType },
[
@@ -1249,9 +1148,9 @@ describe('DiffsStoreActions', () => {
});
describe('setFileUserCollapsed', () => {
- it('commits SET_FILE_COLLAPSED', (done) => {
- testAction(
- setFileCollapsedByUser,
+ it('commits SET_FILE_COLLAPSED', () => {
+ return testAction(
+ diffActions.setFileCollapsedByUser,
{ filePath: 'test', collapsed: true },
null,
[
@@ -1261,7 +1160,6 @@ describe('DiffsStoreActions', () => {
},
],
[],
- done,
);
});
});
@@ -1273,11 +1171,11 @@ describe('DiffsStoreActions', () => {
});
});
- it('commits SET_CURRENT_VIEW_DIFF_FILE_LINES when lines less than MAX_RENDERING_DIFF_LINES', (done) => {
+ it('commits SET_CURRENT_VIEW_DIFF_FILE_LINES when lines less than MAX_RENDERING_DIFF_LINES', () => {
utils.convertExpandLines.mockImplementation(() => ['test']);
- testAction(
- setExpandedDiffLines,
+ return testAction(
+ diffActions.setExpandedDiffLines,
{ file: { file_path: 'path' }, data: [] },
{ diffViewType: 'inline' },
[
@@ -1287,16 +1185,15 @@ describe('DiffsStoreActions', () => {
},
],
[],
- done,
);
});
- it('commits ADD_CURRENT_VIEW_DIFF_FILE_LINES when lines more than MAX_RENDERING_DIFF_LINES', (done) => {
+ it('commits ADD_CURRENT_VIEW_DIFF_FILE_LINES when lines more than MAX_RENDERING_DIFF_LINES', () => {
const lines = new Array(501).fill().map((_, i) => `line-${i}`);
utils.convertExpandLines.mockReturnValue(lines);
- testAction(
- setExpandedDiffLines,
+ return testAction(
+ diffActions.setExpandedDiffLines,
{ file: { file_path: 'path' }, data: [] },
{ diffViewType: 'inline' },
[
@@ -1312,41 +1209,34 @@ describe('DiffsStoreActions', () => {
{ type: 'TOGGLE_DIFF_FILE_RENDERING_MORE', payload: 'path' },
],
[],
- done,
);
});
});
describe('setSuggestPopoverDismissed', () => {
- it('commits SET_SHOW_SUGGEST_POPOVER', (done) => {
+ it('commits SET_SHOW_SUGGEST_POPOVER', async () => {
const state = { dismissEndpoint: `${TEST_HOST}/-/user_callouts` };
- const mock = new MockAdapter(axios);
mock.onPost(state.dismissEndpoint).reply(200, {});
jest.spyOn(axios, 'post');
- testAction(
- setSuggestPopoverDismissed,
+ await testAction(
+ diffActions.setSuggestPopoverDismissed,
null,
state,
[{ type: types.SET_SHOW_SUGGEST_POPOVER }],
[],
- () => {
- expect(axios.post).toHaveBeenCalledWith(state.dismissEndpoint, {
- feature_name: 'suggest_popover_dismissed',
- });
-
- mock.restore();
- done();
- },
);
+ expect(axios.post).toHaveBeenCalledWith(state.dismissEndpoint, {
+ feature_name: 'suggest_popover_dismissed',
+ });
});
});
describe('changeCurrentCommit', () => {
it('commits the new commit information and re-requests the diff metadata for the commit', () => {
return testAction(
- changeCurrentCommit,
+ diffActions.changeCurrentCommit,
{ commitId: 'NEW' },
{
commit: {
@@ -1384,7 +1274,7 @@ describe('DiffsStoreActions', () => {
({ commitId, commit, msg }) => {
const err = new Error(msg);
const actionReturn = testAction(
- changeCurrentCommit,
+ diffActions.changeCurrentCommit,
{ commitId },
{
endpoint: 'URL/OLD',
@@ -1410,7 +1300,7 @@ describe('DiffsStoreActions', () => {
'for the direction "$direction", dispatches the action to move to the SHA "$expected"',
({ direction, expected, currentCommit }) => {
return testAction(
- moveToNeighboringCommit,
+ diffActions.moveToNeighboringCommit,
{ direction },
{ commit: currentCommit },
[],
@@ -1431,7 +1321,7 @@ describe('DiffsStoreActions', () => {
'given `{ "isloading": $diffsAreLoading, "commit": $currentCommit }` in state, no actions are dispatched',
({ direction, diffsAreLoading, currentCommit }) => {
return testAction(
- moveToNeighboringCommit,
+ diffActions.moveToNeighboringCommit,
{ direction },
{ commit: currentCommit, isLoading: diffsAreLoading },
[],
@@ -1450,7 +1340,7 @@ describe('DiffsStoreActions', () => {
notesById: { 1: { discussion_id: '2' } },
};
- setCurrentDiffFileIdFromNote({ commit, state, rootGetters }, '1');
+ diffActions.setCurrentDiffFileIdFromNote({ commit, state, rootGetters }, '1');
expect(commit).toHaveBeenCalledWith(types.SET_CURRENT_DIFF_FILE, '123');
});
@@ -1463,7 +1353,7 @@ describe('DiffsStoreActions', () => {
notesById: { 1: { discussion_id: '2' } },
};
- setCurrentDiffFileIdFromNote({ commit, state, rootGetters }, '1');
+ diffActions.setCurrentDiffFileIdFromNote({ commit, state, rootGetters }, '1');
expect(commit).not.toHaveBeenCalled();
});
@@ -1476,21 +1366,20 @@ describe('DiffsStoreActions', () => {
notesById: { 1: { discussion_id: '2' } },
};
- setCurrentDiffFileIdFromNote({ commit, state, rootGetters }, '1');
+ diffActions.setCurrentDiffFileIdFromNote({ commit, state, rootGetters }, '1');
expect(commit).not.toHaveBeenCalled();
});
});
describe('navigateToDiffFileIndex', () => {
- it('commits SET_CURRENT_DIFF_FILE', (done) => {
- testAction(
- navigateToDiffFileIndex,
+ it('commits SET_CURRENT_DIFF_FILE', () => {
+ return testAction(
+ diffActions.navigateToDiffFileIndex,
0,
{ diffFiles: [{ file_hash: '123' }] },
[{ type: types.SET_CURRENT_DIFF_FILE, payload: '123' }],
[],
- done,
);
});
});
@@ -1498,19 +1387,13 @@ describe('DiffsStoreActions', () => {
describe('setFileByFile', () => {
const updateUserEndpoint = 'user/prefs';
let putSpy;
- let mock;
beforeEach(() => {
- mock = new MockAdapter(axios);
putSpy = jest.spyOn(axios, 'put');
mock.onPut(updateUserEndpoint).reply(200, {});
});
- afterEach(() => {
- mock.restore();
- });
-
it.each`
value
${true}
@@ -1519,7 +1402,7 @@ describe('DiffsStoreActions', () => {
'commits SET_FILE_BY_FILE and persists the File-by-File user preference with the new value $value',
async ({ value }) => {
await testAction(
- setFileByFile,
+ diffActions.setFileByFile,
{ fileByFile: value },
{
viewDiffsFileByFile: null,
@@ -1551,7 +1434,7 @@ describe('DiffsStoreActions', () => {
const commitSpy = jest.fn();
const getterSpy = jest.fn().mockReturnValue([]);
- reviewFile(
+ diffActions.reviewFile(
{
commit: commitSpy,
getters: {
diff --git a/spec/frontend/diffs/store/utils_spec.js b/spec/frontend/diffs/store/utils_spec.js
index 55c0141552d..03bcaab0d2b 100644
--- a/spec/frontend/diffs/store/utils_spec.js
+++ b/spec/frontend/diffs/store/utils_spec.js
@@ -13,7 +13,7 @@ import {
} from '~/diffs/constants';
import * as utils from '~/diffs/store/utils';
import { MERGE_REQUEST_NOTEABLE_TYPE } from '~/notes/constants';
-import { noteableDataMock } from '../../notes/mock_data';
+import { noteableDataMock } from 'jest/notes/mock_data';
import diffFileMockData from '../mock_data/diff_file';
import { diffMetadata } from '../mock_data/diff_metadata';
diff --git a/spec/frontend/editor/components/helpers.js b/spec/frontend/editor/components/helpers.js
new file mode 100644
index 00000000000..3e6cd2a236d
--- /dev/null
+++ b/spec/frontend/editor/components/helpers.js
@@ -0,0 +1,12 @@
+import { EDITOR_TOOLBAR_RIGHT_GROUP } from '~/editor/constants';
+
+export const buildButton = (id = 'foo-bar-btn', options = {}) => {
+ return {
+ __typename: 'Item',
+ id,
+ label: options.label || 'Foo Bar Button',
+ icon: options.icon || 'foo-bar',
+ selected: options.selected || false,
+ group: options.group || EDITOR_TOOLBAR_RIGHT_GROUP,
+ };
+};
diff --git a/spec/frontend/editor/components/source_editor_toolbar_button_spec.js b/spec/frontend/editor/components/source_editor_toolbar_button_spec.js
new file mode 100644
index 00000000000..5135091af4a
--- /dev/null
+++ b/spec/frontend/editor/components/source_editor_toolbar_button_spec.js
@@ -0,0 +1,146 @@
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import { GlButton } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import SourceEditorToolbarButton from '~/editor/components/source_editor_toolbar_button.vue';
+import getToolbarItemQuery from '~/editor/graphql/get_item.query.graphql';
+import updateToolbarItemMutation from '~/editor/graphql/update_item.mutation.graphql';
+import { buildButton } from './helpers';
+
+Vue.use(VueApollo);
+
+describe('Source Editor Toolbar button', () => {
+ let wrapper;
+ let mockApollo;
+ const defaultBtn = buildButton();
+
+ const findButton = () => wrapper.findComponent(GlButton);
+
+ const createComponentWithApollo = ({ propsData } = {}) => {
+ mockApollo = createMockApollo();
+ mockApollo.clients.defaultClient.cache.writeQuery({
+ query: getToolbarItemQuery,
+ variables: { id: defaultBtn.id },
+ data: {
+ item: {
+ ...defaultBtn,
+ },
+ },
+ });
+
+ wrapper = shallowMount(SourceEditorToolbarButton, {
+ propsData,
+ apolloProvider: mockApollo,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ mockApollo = null;
+ });
+
+ describe('default', () => {
+ const defaultProps = {
+ category: 'primary',
+ variant: 'default',
+ };
+ const customProps = {
+ category: 'secondary',
+ variant: 'info',
+ };
+ it('renders a default button without props', async () => {
+ createComponentWithApollo();
+ const btn = findButton();
+ expect(btn.exists()).toBe(true);
+ expect(btn.props()).toMatchObject(defaultProps);
+ });
+
+ it('renders a button based on the props passed', async () => {
+ createComponentWithApollo({
+ propsData: {
+ button: customProps,
+ },
+ });
+ const btn = findButton();
+ expect(btn.props()).toMatchObject(customProps);
+ });
+ });
+
+ describe('button updates', () => {
+ it('it properly updates button on Apollo cache update', async () => {
+ const { id } = defaultBtn;
+
+ createComponentWithApollo({
+ propsData: {
+ button: {
+ id,
+ },
+ },
+ });
+
+ expect(findButton().props('selected')).toBe(false);
+
+ mockApollo.clients.defaultClient.cache.writeQuery({
+ query: getToolbarItemQuery,
+ variables: { id },
+ data: {
+ item: {
+ ...defaultBtn,
+ selected: true,
+ },
+ },
+ });
+
+ jest.runOnlyPendingTimers();
+ await nextTick();
+
+ expect(findButton().props('selected')).toBe(true);
+ });
+ });
+
+ describe('click handler', () => {
+ it('fires the click handler on the button when available', () => {
+ const spy = jest.fn();
+ createComponentWithApollo({
+ propsData: {
+ button: {
+ onClick: spy,
+ },
+ },
+ });
+ expect(spy).not.toHaveBeenCalled();
+ findButton().vm.$emit('click');
+ expect(spy).toHaveBeenCalled();
+ });
+ it('emits the "click" event', () => {
+ createComponentWithApollo();
+ jest.spyOn(wrapper.vm, '$emit');
+ expect(wrapper.vm.$emit).not.toHaveBeenCalled();
+ findButton().vm.$emit('click');
+ expect(wrapper.vm.$emit).toHaveBeenCalledWith('click');
+ });
+ it('triggers the mutation exposing the changed "selected" prop', () => {
+ const { id } = defaultBtn;
+ createComponentWithApollo({
+ propsData: {
+ button: {
+ id,
+ },
+ },
+ });
+ jest.spyOn(wrapper.vm.$apollo, 'mutate');
+ expect(wrapper.vm.$apollo.mutate).not.toHaveBeenCalled();
+ findButton().vm.$emit('click');
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
+ mutation: updateToolbarItemMutation,
+ variables: {
+ id,
+ propsToUpdate: {
+ selected: true,
+ },
+ },
+ });
+ });
+ });
+});
diff --git a/spec/frontend/editor/components/source_editor_toolbar_spec.js b/spec/frontend/editor/components/source_editor_toolbar_spec.js
new file mode 100644
index 00000000000..6e99eadbd97
--- /dev/null
+++ b/spec/frontend/editor/components/source_editor_toolbar_spec.js
@@ -0,0 +1,116 @@
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import { GlButtonGroup } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import SourceEditorToolbar from '~/editor/components/source_editor_toolbar.vue';
+import SourceEditorToolbarButton from '~/editor/components/source_editor_toolbar_button.vue';
+import { EDITOR_TOOLBAR_LEFT_GROUP, EDITOR_TOOLBAR_RIGHT_GROUP } from '~/editor/constants';
+import getToolbarItemsQuery from '~/editor/graphql/get_items.query.graphql';
+import { buildButton } from './helpers';
+
+Vue.use(VueApollo);
+
+describe('Source Editor Toolbar', () => {
+ let wrapper;
+ let mockApollo;
+
+ const findButtons = () => wrapper.findAllComponents(SourceEditorToolbarButton);
+
+ const createApolloMockWithCache = (items = []) => {
+ mockApollo = createMockApollo();
+ mockApollo.clients.defaultClient.cache.writeQuery({
+ query: getToolbarItemsQuery,
+ data: {
+ items: {
+ nodes: items,
+ },
+ },
+ });
+ };
+
+ const createComponentWithApollo = (items = []) => {
+ createApolloMockWithCache(items);
+ wrapper = shallowMount(SourceEditorToolbar, {
+ apolloProvider: mockApollo,
+ stubs: {
+ GlButtonGroup,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ mockApollo = null;
+ });
+
+ describe('groups', () => {
+ it.each`
+ group | expectedGroup
+ ${EDITOR_TOOLBAR_LEFT_GROUP} | ${EDITOR_TOOLBAR_LEFT_GROUP}
+ ${EDITOR_TOOLBAR_RIGHT_GROUP} | ${EDITOR_TOOLBAR_RIGHT_GROUP}
+ ${undefined} | ${EDITOR_TOOLBAR_RIGHT_GROUP}
+ ${'non-existing'} | ${EDITOR_TOOLBAR_RIGHT_GROUP}
+ `('puts item with group="$group" into $expectedGroup group', ({ group, expectedGroup }) => {
+ const item = buildButton('first', {
+ group,
+ });
+ createComponentWithApollo([item]);
+ expect(findButtons()).toHaveLength(1);
+ [EDITOR_TOOLBAR_RIGHT_GROUP, EDITOR_TOOLBAR_LEFT_GROUP].forEach((g) => {
+ if (g === expectedGroup) {
+ expect(wrapper.vm.getGroupItems(g)).toEqual([expect.objectContaining({ id: 'first' })]);
+ } else {
+ expect(wrapper.vm.getGroupItems(g)).toHaveLength(0);
+ }
+ });
+ });
+ });
+
+ describe('buttons update', () => {
+ it('it properly updates buttons on Apollo cache update', async () => {
+ const item = buildButton('first', {
+ group: EDITOR_TOOLBAR_RIGHT_GROUP,
+ });
+ createComponentWithApollo();
+
+ expect(findButtons()).toHaveLength(0);
+
+ mockApollo.clients.defaultClient.cache.writeQuery({
+ query: getToolbarItemsQuery,
+ data: {
+ items: {
+ nodes: [item],
+ },
+ },
+ });
+
+ jest.runOnlyPendingTimers();
+ await nextTick();
+
+ expect(findButtons()).toHaveLength(1);
+ });
+ });
+
+ describe('click handler', () => {
+ it('emits the "click" event when a button is clicked', () => {
+ const item1 = buildButton('first', {
+ group: EDITOR_TOOLBAR_LEFT_GROUP,
+ });
+ const item2 = buildButton('second', {
+ group: EDITOR_TOOLBAR_RIGHT_GROUP,
+ });
+ createComponentWithApollo([item1, item2]);
+ jest.spyOn(wrapper.vm, '$emit');
+ expect(wrapper.vm.$emit).not.toHaveBeenCalled();
+
+ findButtons().at(0).vm.$emit('click');
+ expect(wrapper.vm.$emit).toHaveBeenCalledWith('click', item1);
+
+ findButtons().at(1).vm.$emit('click');
+ expect(wrapper.vm.$emit).toHaveBeenCalledWith('click', item2);
+
+ expect(wrapper.vm.$emit.mock.calls).toHaveLength(2);
+ });
+ });
+});
diff --git a/spec/frontend/editor/schema/ci/ci_schema_spec.js b/spec/frontend/editor/schema/ci/ci_schema_spec.js
new file mode 100644
index 00000000000..628c34a27c1
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/ci_schema_spec.js
@@ -0,0 +1,90 @@
+import Ajv from 'ajv';
+import AjvFormats from 'ajv-formats';
+import CiSchema from '~/editor/schema/ci.json';
+
+// JSON POSITIVE TESTS
+import AllowFailureJson from './json_tests/positive_tests/allow_failure.json';
+import EnvironmentJson from './json_tests/positive_tests/environment.json';
+import GitlabCiDependenciesJson from './json_tests/positive_tests/gitlab-ci-dependencies.json';
+import GitlabCiJson from './json_tests/positive_tests/gitlab-ci.json';
+import InheritJson from './json_tests/positive_tests/inherit.json';
+import MultipleCachesJson from './json_tests/positive_tests/multiple-caches.json';
+import RetryJson from './json_tests/positive_tests/retry.json';
+import TerraformReportJson from './json_tests/positive_tests/terraform_report.json';
+import VariablesMixStringAndUserInputJson from './json_tests/positive_tests/variables_mix_string_and_user_input.json';
+import VariablesJson from './json_tests/positive_tests/variables.json';
+
+// JSON NEGATIVE TESTS
+import DefaultNoAdditionalPropertiesJson from './json_tests/negative_tests/default_no_additional_properties.json';
+import InheritDefaultNoAdditionalPropertiesJson from './json_tests/negative_tests/inherit_default_no_additional_properties.json';
+import JobVariablesMustNotContainObjectsJson from './json_tests/negative_tests/job_variables_must_not_contain_objects.json';
+import ReleaseAssetsLinksEmptyJson from './json_tests/negative_tests/release_assets_links_empty.json';
+import ReleaseAssetsLinksInvalidLinkTypeJson from './json_tests/negative_tests/release_assets_links_invalid_link_type.json';
+import ReleaseAssetsLinksMissingJson from './json_tests/negative_tests/release_assets_links_missing.json';
+import RetryUnknownWhenJson from './json_tests/negative_tests/retry_unknown_when.json';
+
+// YAML POSITIVE TEST
+import CacheYaml from './yaml_tests/positive_tests/cache.yml';
+import FilterYaml from './yaml_tests/positive_tests/filter.yml';
+import IncludeYaml from './yaml_tests/positive_tests/include.yml';
+import RulesYaml from './yaml_tests/positive_tests/rules.yml';
+
+// YAML NEGATIVE TEST
+import CacheNegativeYaml from './yaml_tests/negative_tests/cache.yml';
+import IncludeNegativeYaml from './yaml_tests/negative_tests/include.yml';
+
+const ajv = new Ajv({
+ strictTypes: false,
+ strictTuples: false,
+ allowMatchingProperties: true,
+});
+
+AjvFormats(ajv);
+const schema = ajv.compile(CiSchema);
+
+describe('positive tests', () => {
+ it.each(
+ Object.entries({
+ // JSON
+ AllowFailureJson,
+ EnvironmentJson,
+ GitlabCiDependenciesJson,
+ GitlabCiJson,
+ InheritJson,
+ MultipleCachesJson,
+ RetryJson,
+ TerraformReportJson,
+ VariablesMixStringAndUserInputJson,
+ VariablesJson,
+
+ // YAML
+ CacheYaml,
+ FilterYaml,
+ IncludeYaml,
+ RulesYaml,
+ }),
+ )('schema validates %s', (_, input) => {
+ expect(input).toValidateJsonSchema(schema);
+ });
+});
+
+describe('negative tests', () => {
+ it.each(
+ Object.entries({
+ // JSON
+ DefaultNoAdditionalPropertiesJson,
+ JobVariablesMustNotContainObjectsJson,
+ InheritDefaultNoAdditionalPropertiesJson,
+ ReleaseAssetsLinksEmptyJson,
+ ReleaseAssetsLinksInvalidLinkTypeJson,
+ ReleaseAssetsLinksMissingJson,
+ RetryUnknownWhenJson,
+
+ // YAML
+ CacheNegativeYaml,
+ IncludeNegativeYaml,
+ }),
+ )('schema validates %s', (_, input) => {
+ expect(input).not.toValidateJsonSchema(schema);
+ });
+});
diff --git a/spec/frontend/editor/schema/ci/json_tests/negative_tests/default_no_additional_properties.json b/spec/frontend/editor/schema/ci/json_tests/negative_tests/default_no_additional_properties.json
new file mode 100644
index 00000000000..955c19ef1ab
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/json_tests/negative_tests/default_no_additional_properties.json
@@ -0,0 +1,12 @@
+{
+ "default": {
+ "secrets": {
+ "DATABASE_PASSWORD": {
+ "vault": "production/db/password"
+ }
+ },
+ "environment": {
+ "name": "test"
+ }
+ }
+}
diff --git a/spec/frontend/editor/schema/ci/json_tests/negative_tests/inherit_default_no_additional_properties.json b/spec/frontend/editor/schema/ci/json_tests/negative_tests/inherit_default_no_additional_properties.json
new file mode 100644
index 00000000000..7411e4c2434
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/json_tests/negative_tests/inherit_default_no_additional_properties.json
@@ -0,0 +1,8 @@
+{
+ "karma": {
+ "inherit": {
+ "default": ["secrets"]
+ },
+ "script": "karma"
+ }
+}
diff --git a/spec/frontend/editor/schema/ci/json_tests/negative_tests/job_variables_must_not_contain_objects.json b/spec/frontend/editor/schema/ci/json_tests/negative_tests/job_variables_must_not_contain_objects.json
new file mode 100644
index 00000000000..bfdbf26ee70
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/json_tests/negative_tests/job_variables_must_not_contain_objects.json
@@ -0,0 +1,12 @@
+{
+ "gitlab-ci-variables-object": {
+ "stage": "test",
+ "script": ["true"],
+ "variables": {
+ "DEPLOY_ENVIRONMENT": {
+ "value": "staging",
+ "description": "The deployment target. Change this variable to 'canary' or 'production' if needed."
+ }
+ }
+ }
+}
diff --git a/spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_empty.json b/spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_empty.json
new file mode 100644
index 00000000000..84a1aa14698
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_empty.json
@@ -0,0 +1,13 @@
+{
+ "gitlab-ci-release-assets-links-empty": {
+ "script": "dostuff",
+ "stage": "deploy",
+ "release": {
+ "description": "Created using the release-cli $EXTRA_DESCRIPTION",
+ "tag_name": "$CI_COMMIT_TAG",
+ "assets": {
+ "links": []
+ }
+ }
+ }
+}
diff --git a/spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_invalid_link_type.json b/spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_invalid_link_type.json
new file mode 100644
index 00000000000..048911aefa3
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_invalid_link_type.json
@@ -0,0 +1,24 @@
+{
+ "gitlab-ci-release-assets-links-invalid-link-type": {
+ "script": "dostuff",
+ "stage": "deploy",
+ "release": {
+ "description": "Created using the release-cli $EXTRA_DESCRIPTION",
+ "tag_name": "$CI_COMMIT_TAG",
+ "assets": {
+ "links": [
+ {
+ "name": "asset1",
+ "url": "https://example.com/assets/1"
+ },
+ {
+ "name": "asset2",
+ "url": "https://example.com/assets/2",
+ "filepath": "/pretty/url/1",
+ "link_type": "invalid"
+ }
+ ]
+ }
+ }
+ }
+}
diff --git a/spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_missing.json b/spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_missing.json
new file mode 100644
index 00000000000..6f0b5a3bff8
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_missing.json
@@ -0,0 +1,11 @@
+{
+ "gitlab-ci-release-assets-links-missing": {
+ "script": "dostuff",
+ "stage": "deploy",
+ "release": {
+ "description": "Created using the release-cli $EXTRA_DESCRIPTION",
+ "tag_name": "$CI_COMMIT_TAG",
+ "assets": {}
+ }
+ }
+}
diff --git a/spec/frontend/editor/schema/ci/json_tests/negative_tests/retry_unknown_when.json b/spec/frontend/editor/schema/ci/json_tests/negative_tests/retry_unknown_when.json
new file mode 100644
index 00000000000..433504f52c6
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/json_tests/negative_tests/retry_unknown_when.json
@@ -0,0 +1,9 @@
+{
+ "gitlab-ci-retry-object-unknown-when": {
+ "stage": "test",
+ "script": "rspec",
+ "retry": {
+ "when": "gitlab-ci-retry-object-unknown-when"
+ }
+ }
+}
diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/allow_failure.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/allow_failure.json
new file mode 100644
index 00000000000..44d42116c1a
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/allow_failure.json
@@ -0,0 +1,19 @@
+{
+ "job1": {
+ "stage": "test",
+ "script": ["execute_script_that_will_fail"],
+ "allow_failure": true
+ },
+ "job2": {
+ "script": ["exit 1"],
+ "allow_failure": {
+ "exit_codes": 137
+ }
+ },
+ "job3": {
+ "script": ["exit 137"],
+ "allow_failure": {
+ "exit_codes": [137, 255]
+ }
+ }
+}
diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/environment.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/environment.json
new file mode 100644
index 00000000000..0c6f7935063
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/environment.json
@@ -0,0 +1,75 @@
+{
+ "deploy to production 1": {
+ "stage": "deploy",
+ "script": "git push production HEAD: master",
+ "environment": "production"
+ },
+ "deploy to production 2": {
+ "stage": "deploy",
+ "script": "git push production HEAD:master",
+ "environment": {
+ "name": "production"
+ }
+ },
+ "deploy to production 3": {
+ "stage": "deploy",
+ "script": "git push production HEAD:master",
+ "environment": {
+ "name": "production",
+ "url": "https://prod.example.com"
+ }
+ },
+ "review_app 1": {
+ "stage": "deploy",
+ "script": "make deploy-app",
+ "environment": {
+ "name": "review/$CI_COMMIT_REF_NAME",
+ "url": "https://$CI_ENVIRONMENT_SLUG.example.com",
+ "on_stop": "stop_review_app"
+ }
+ },
+ "stop_review_app": {
+ "stage": "deploy",
+ "variables": {
+ "GIT_STRATEGY": "none"
+ },
+ "script": "make delete-app",
+ "when": "manual",
+ "environment": {
+ "name": "review/$CI_COMMIT_REF_NAME",
+ "action": "stop"
+ }
+ },
+ "review_app 2": {
+ "script": "deploy-review-app",
+ "environment": {
+ "name": "review/$CI_COMMIT_REF_NAME",
+ "auto_stop_in": "1 day"
+ }
+ },
+ "deploy 1": {
+ "stage": "deploy",
+ "script": "make deploy-app",
+ "environment": {
+ "name": "production",
+ "kubernetes": {
+ "namespace": "production"
+ }
+ }
+ },
+ "deploy 2": {
+ "script": "echo",
+ "environment": {
+ "name": "customer-portal",
+ "deployment_tier": "production"
+ }
+ },
+ "deploy as review app": {
+ "stage": "deploy",
+ "script": "make deploy",
+ "environment": {
+ "name": "review/$CI_COMMIT_REF_NAME",
+ "url": "https://$CI_ENVIRONMENT_SLUG.example.com/"
+ }
+ }
+}
diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci-dependencies.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci-dependencies.json
new file mode 100644
index 00000000000..5ffa7fa799e
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci-dependencies.json
@@ -0,0 +1,68 @@
+{
+ ".build_config": {
+ "before_script": ["echo test"]
+ },
+ ".build_script": "echo build script",
+ "default": {
+ "image": "ruby:2.5",
+ "services": ["docker:dind"],
+ "cache": {
+ "paths": ["vendor/"]
+ },
+ "before_script": ["bundle install --path vendor/"],
+ "after_script": ["rm -rf tmp/"]
+ },
+ "stages": ["install", "build", "test", "deploy"],
+ "image": "foo:latest",
+ "install task1": {
+ "image": "node:latest",
+ "stage": "install",
+ "script": "npm install",
+ "artifacts": {
+ "paths": ["node_modules/"]
+ }
+ },
+ "build dev": {
+ "image": "node:latest",
+ "stage": "build",
+ "needs": [
+ {
+ "job": "install task1"
+ }
+ ],
+ "script": "npm run build:dev"
+ },
+ "build prod": {
+ "image": "node:latest",
+ "stage": "build",
+ "needs": ["install task1"],
+ "script": "npm run build:prod"
+ },
+ "test": {
+ "image": "node:latest",
+ "stage": "build",
+ "needs": [
+ "install task1",
+ {
+ "job": "build dev",
+ "artifacts": true
+ }
+ ],
+ "script": "npm run test"
+ },
+ "deploy it": {
+ "image": "node:latest",
+ "stage": "deploy",
+ "needs": [
+ {
+ "job": "build dev",
+ "artifacts": false
+ },
+ {
+ "job": "build prod",
+ "artifacts": true
+ }
+ ],
+ "script": "npm run test"
+ }
+}
diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci.json
new file mode 100644
index 00000000000..89420bbc35f
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci.json
@@ -0,0 +1,350 @@
+{
+ ".build_config": {
+ "before_script": ["echo test"]
+ },
+ ".build_script": "echo build script",
+ ".example_variables": {
+ "foo": "hello",
+ "bar": 42
+ },
+ ".example_services": [
+ "docker:dind",
+ {
+ "name": "sql:latest",
+ "command": ["/usr/bin/super-sql", "run"]
+ }
+ ],
+ "default": {
+ "image": "ruby:2.5",
+ "services": ["docker:dind"],
+ "cache": {
+ "paths": ["vendor/"]
+ },
+ "before_script": ["bundle install --path vendor/"],
+ "after_script": ["rm -rf tmp/"],
+ "tags": ["ruby", "postgres"],
+ "artifacts": {
+ "name": "%CI_COMMIT_REF_NAME%",
+ "expose_as": "artifact 1",
+ "paths": ["path/to/file.txt", "target/*.war"],
+ "when": "on_failure"
+ },
+ "retry": 2,
+ "timeout": "3 hours 30 minutes",
+ "interruptible": true
+ },
+ "stages": ["build", "test", "deploy", "random"],
+ "image": "foo:latest",
+ "services": ["sql:latest"],
+ "before_script": ["echo test", "echo test2"],
+ "after_script": [],
+ "cache": {
+ "key": "asd",
+ "paths": ["dist/", ".foo"],
+ "untracked": false,
+ "policy": "pull"
+ },
+ "variables": {
+ "STAGE": "yep",
+ "PROD": "nope"
+ },
+ "include": [
+ "https://gitlab.com/awesome-project/raw/master/.before-script-template.yml",
+ "/templates/.after-script-template.yml",
+ { "template": "Auto-DevOps.gitlab-ci.yml" },
+ {
+ "project": "my-group/my-project",
+ "ref": "master",
+ "file": "/templates/.gitlab-ci-template.yml"
+ },
+ {
+ "project": "my-group/my-project",
+ "ref": "master",
+ "file": ["/templates/.gitlab-ci-template.yml", "/templates/another-template-to-include.yml"]
+ }
+ ],
+ "build": {
+ "image": {
+ "name": "node:latest"
+ },
+ "services": [],
+ "stage": "build",
+ "script": "npm run build",
+ "before_script": ["npm install"],
+ "rules": [
+ {
+ "if": "moo",
+ "changes": ["Moofile"],
+ "exists": ["main.cow"],
+ "when": "delayed",
+ "start_in": "3 hours"
+ }
+ ],
+ "retry": {
+ "max": 1,
+ "when": "stuck_or_timeout_failure"
+ },
+ "cache": {
+ "key": "$CI_COMMIT_REF_NAME",
+ "paths": ["node_modules/"],
+ "policy": "pull-push"
+ },
+ "artifacts": {
+ "paths": ["dist/"],
+ "expose_as": "link_name_in_merge_request",
+ "name": "bundles",
+ "when": "on_success",
+ "expire_in": "1 week",
+ "reports": {
+ "junit": "result.xml",
+ "cobertura": "cobertura-coverage.xml",
+ "codequality": "codequality.json",
+ "sast": "sast.json",
+ "dependency_scanning": "scan.json",
+ "container_scanning": "scan2.json",
+ "dast": "dast.json",
+ "license_management": "license.json",
+ "performance": "performance.json",
+ "metrics": "metrics.txt"
+ }
+ },
+ "variables": {
+ "FOO_BAR": "..."
+ },
+ "only": {
+ "kubernetes": "active",
+ "variables": ["$FOO_BAR == '...'"],
+ "changes": ["/path/to/file", "/another/file"]
+ },
+ "except": ["master", "tags"],
+ "tags": ["docker"],
+ "allow_failure": true,
+ "when": "manual"
+ },
+ "error-report": {
+ "when": "on_failure",
+ "script": "report error",
+ "stage": "test"
+ },
+ "test": {
+ "image": {
+ "name": "node:latest",
+ "entrypoint": [""]
+ },
+ "stage": "test",
+ "script": "npm test",
+ "parallel": 5,
+ "retry": {
+ "max": 2,
+ "when": [
+ "runner_system_failure",
+ "stuck_or_timeout_failure",
+ "script_failure",
+ "unknown_failure",
+ "always"
+ ]
+ },
+ "artifacts": {
+ "reports": {
+ "junit": ["result.xml"],
+ "cobertura": ["cobertura-coverage.xml"],
+ "codequality": ["codequality.json"],
+ "sast": ["sast.json"],
+ "dependency_scanning": ["scan.json"],
+ "container_scanning": ["scan2.json"],
+ "dast": ["dast.json"],
+ "license_management": ["license.json"],
+ "performance": ["performance.json"],
+ "metrics": ["metrics.txt"]
+ }
+ },
+ "coverage": "/Cycles: \\d+\\.\\d+$/",
+ "dependencies": []
+ },
+ "docker": {
+ "script": "docker build -t foo:latest",
+ "when": "delayed",
+ "start_in": "10 min",
+ "timeout": "1h",
+ "retry": 1,
+ "only": {
+ "changes": ["Dockerfile", "docker/scripts/*"]
+ }
+ },
+ "deploy": {
+ "services": [
+ {
+ "name": "sql:latest",
+ "entrypoint": [""],
+ "command": ["/usr/bin/super-sql", "run"],
+ "alias": "super-sql"
+ },
+ "sql:latest",
+ {
+ "name": "sql:latest",
+ "alias": "default-sql"
+ }
+ ],
+ "script": "dostuff",
+ "stage": "deploy",
+ "environment": {
+ "name": "prod",
+ "url": "http://example.com",
+ "on_stop": "stop-deploy"
+ },
+ "only": ["master"],
+ "release": {
+ "name": "Release $CI_COMMIT_TAG",
+ "description": "Created using the release-cli $EXTRA_DESCRIPTION",
+ "tag_name": "$CI_COMMIT_TAG",
+ "ref": "$CI_COMMIT_TAG",
+ "milestones": ["m1", "m2", "m3"],
+ "released_at": "2020-07-15T08:00:00Z",
+ "assets": {
+ "links": [
+ {
+ "name": "asset1",
+ "url": "https://example.com/assets/1"
+ },
+ {
+ "name": "asset2",
+ "url": "https://example.com/assets/2",
+ "filepath": "/pretty/url/1",
+ "link_type": "other"
+ },
+ {
+ "name": "asset3",
+ "url": "https://example.com/assets/3",
+ "link_type": "runbook"
+ },
+ {
+ "name": "asset4",
+ "url": "https://example.com/assets/4",
+ "link_type": "package"
+ },
+ {
+ "name": "asset5",
+ "url": "https://example.com/assets/5",
+ "link_type": "image"
+ }
+ ]
+ }
+ }
+ },
+ ".performance-tmpl": {
+ "after_script": ["echo after"],
+ "before_script": ["echo before"],
+ "variables": {
+ "SCRIPT_NOT_REQUIRED": "true"
+ }
+ },
+ "performance-a": {
+ "extends": ".performance-tmpl",
+ "script": "echo test"
+ },
+ "performance-b": {
+ "extends": ".performance-tmpl"
+ },
+ "workflow": {
+ "rules": [
+ {
+ "if": "$CI_COMMIT_REF_NAME =~ /-wip$/",
+ "when": "never"
+ },
+ {
+ "if": "$CI_COMMIT_TAG",
+ "when": "never"
+ },
+ {
+ "when": "always"
+ }
+ ]
+ },
+ "job": {
+ "script": "echo Hello, Rules!",
+ "rules": [
+ {
+ "if": "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == \"master\"",
+ "when": "manual",
+ "allow_failure": true
+ }
+ ]
+ },
+ "microservice_a": {
+ "trigger": {
+ "include": "path/to/microservice_a.yml"
+ }
+ },
+ "microservice_b": {
+ "trigger": {
+ "include": [{ "local": "path/to/microservice_b.yml" }, { "template": "SAST.gitlab-cy.yml" }],
+ "strategy": "depend"
+ }
+ },
+ "child-pipeline": {
+ "stage": "test",
+ "trigger": {
+ "include": [
+ {
+ "artifact": "generated-config.yml",
+ "job": "generate-config"
+ }
+ ]
+ }
+ },
+ "child-pipeline-simple": {
+ "stage": "test",
+ "trigger": {
+ "include": "other/file.yml"
+ }
+ },
+ "complex": {
+ "stage": "deploy",
+ "trigger": {
+ "project": "my/deployment",
+ "branch": "stable"
+ }
+ },
+ "parallel-integer": {
+ "stage": "test",
+ "script": ["echo ${CI_NODE_INDEX} ${CI_NODE_TOTAL}"],
+ "parallel": 5
+ },
+ "parallel-matrix-simple": {
+ "stage": "test",
+ "script": ["echo ${MY_VARIABLE}"],
+ "parallel": {
+ "matrix": [
+ {
+ "MY_VARIABLE": 0
+ },
+ {
+ "MY_VARIABLE": "sample"
+ },
+ {
+ "MY_VARIABLE": ["element0", 1, "element2"]
+ }
+ ]
+ }
+ },
+ "parallel-matrix-gitlab-docs": {
+ "stage": "deploy",
+ "script": ["bin/deploy"],
+ "parallel": {
+ "matrix": [
+ {
+ "PROVIDER": "aws",
+ "STACK": ["app1", "app2"]
+ },
+ {
+ "PROVIDER": "ovh",
+ "STACK": ["monitoring", "backup", "app"]
+ },
+ {
+ "PROVIDER": ["gcp", "vultr"],
+ "STACK": ["data", "processing"]
+ }
+ ]
+ }
+ }
+}
diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/inherit.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/inherit.json
new file mode 100644
index 00000000000..3f72afa6ceb
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/inherit.json
@@ -0,0 +1,54 @@
+{
+ "default": {
+ "image": "ruby:2.4",
+ "before_script": ["echo Hello World"]
+ },
+ "variables": {
+ "DOMAIN": "example.com",
+ "WEBHOOK_URL": "https://my-webhook.example.com"
+ },
+ "rubocop": {
+ "inherit": {
+ "default": false,
+ "variables": false
+ },
+ "script": "bundle exec rubocop"
+ },
+ "rspec": {
+ "inherit": {
+ "default": ["image"],
+ "variables": ["WEBHOOK_URL"]
+ },
+ "script": "bundle exec rspec"
+ },
+ "capybara": {
+ "inherit": {
+ "variables": false
+ },
+ "script": "bundle exec capybara"
+ },
+ "karma": {
+ "inherit": {
+ "default": true,
+ "variables": ["DOMAIN"]
+ },
+ "script": "karma"
+ },
+ "inherit literally all": {
+ "inherit": {
+ "default": [
+ "after_script",
+ "artifacts",
+ "before_script",
+ "cache",
+ "image",
+ "interruptible",
+ "retry",
+ "services",
+ "tags",
+ "timeout"
+ ]
+ },
+ "script": "true"
+ }
+}
diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/multiple-caches.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/multiple-caches.json
new file mode 100644
index 00000000000..360938e5ce7
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/multiple-caches.json
@@ -0,0 +1,24 @@
+{
+ "test-job": {
+ "stage": "build",
+ "cache": [
+ {
+ "key": {
+ "files": ["Gemfile.lock"]
+ },
+ "paths": ["vendor/ruby"]
+ },
+ {
+ "key": {
+ "files": ["yarn.lock"]
+ },
+ "paths": [".yarn-cache/"]
+ }
+ ],
+ "script": [
+ "bundle install --path=vendor",
+ "yarn install --cache-folder .yarn-cache",
+ "echo Run tests..."
+ ]
+ }
+}
diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/retry.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/retry.json
new file mode 100644
index 00000000000..1337e5e7bc8
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/retry.json
@@ -0,0 +1,60 @@
+{
+ "gitlab-ci-retry-int": {
+ "stage": "test",
+ "script": "rspec",
+ "retry": 2
+ },
+ "gitlab-ci-retry-object-no-max": {
+ "stage": "test",
+ "script": "rspec",
+ "retry": {
+ "when": "runner_system_failure"
+ }
+ },
+ "gitlab-ci-retry-object-single-when": {
+ "stage": "test",
+ "script": "rspec",
+ "retry": {
+ "max": 2,
+ "when": "runner_system_failure"
+ }
+ },
+ "gitlab-ci-retry-object-multiple-when": {
+ "stage": "test",
+ "script": "rspec",
+ "retry": {
+ "max": 2,
+ "when": ["runner_system_failure", "stuck_or_timeout_failure"]
+ }
+ },
+ "gitlab-ci-retry-object-multiple-when-dupes": {
+ "stage": "test",
+ "script": "rspec",
+ "retry": {
+ "max": 2,
+ "when": ["runner_system_failure", "runner_system_failure"]
+ }
+ },
+ "gitlab-ci-retry-object-all-when": {
+ "stage": "test",
+ "script": "rspec",
+ "retry": {
+ "max": 2,
+ "when": [
+ "always",
+ "unknown_failure",
+ "script_failure",
+ "api_failure",
+ "stuck_or_timeout_failure",
+ "runner_system_failure",
+ "runner_unsupported",
+ "stale_schedule",
+ "job_execution_timeout",
+ "archived_failure",
+ "unmet_prerequisites",
+ "scheduler_failure",
+ "data_integrity_failure"
+ ]
+ }
+ }
+}
diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/terraform_report.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/terraform_report.json
new file mode 100644
index 00000000000..0e444a4ba62
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/terraform_report.json
@@ -0,0 +1,50 @@
+{
+ "image": {
+ "name": "registry.gitlab.com/gitlab-org/gitlab-build-images:terraform",
+ "entrypoint": [
+ "/usr/bin/env",
+ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
+ ]
+ },
+ "variables": {
+ "PLAN": "plan.tfplan",
+ "JSON_PLAN_FILE": "tfplan.json"
+ },
+ "cache": {
+ "paths": [".terraform"]
+ },
+ "before_script": [
+ "alias convert_report=\"jq -r '([.resource_changes[]?.change.actions?]|flatten)|{\"create\":(map(select(.==\"create\"))|length),\"update\":(map(select(.==\"update\"))|length),\"delete\":(map(select(.==\"delete\"))|length)}'\"",
+ "terraform --version",
+ "terraform init"
+ ],
+ "stages": ["validate", "build", "test", "deploy"],
+ "validate": {
+ "stage": "validate",
+ "script": ["terraform validate"]
+ },
+ "plan": {
+ "stage": "build",
+ "script": [
+ "terraform plan -out=$PLAN",
+ "terraform show --json $PLAN | convert_report > $JSON_PLAN_FILE"
+ ],
+ "artifacts": {
+ "name": "plan",
+ "paths": ["$PLAN"],
+ "reports": {
+ "terraform": "$JSON_PLAN_FILE"
+ }
+ }
+ },
+ "apply": {
+ "stage": "deploy",
+ "environment": {
+ "name": "production"
+ },
+ "script": ["terraform apply -input=false $PLAN"],
+ "dependencies": ["plan"],
+ "when": "manual",
+ "only": ["master"]
+ }
+}
diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/variables.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/variables.json
new file mode 100644
index 00000000000..ce59b3fbbec
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/variables.json
@@ -0,0 +1,22 @@
+{
+ "variables": {
+ "DEPLOY_ENVIRONMENT": {
+ "value": "staging",
+ "description": "The deployment target. Change this variable to 'canary' or 'production' if needed."
+ }
+ },
+ "gitlab-ci-variables-string": {
+ "stage": "test",
+ "script": ["true"],
+ "variables": {
+ "TEST_VAR": "String variable"
+ }
+ },
+ "gitlab-ci-variables-integer": {
+ "stage": "test",
+ "script": ["true"],
+ "variables": {
+ "canonical": 685230
+ }
+ }
+}
diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/variables_mix_string_and_user_input.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/variables_mix_string_and_user_input.json
new file mode 100644
index 00000000000..87a9ec05b57
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/variables_mix_string_and_user_input.json
@@ -0,0 +1,10 @@
+{
+ "variables": {
+ "SOME_STR": "--batch-mode --errors --fail-at-end --show-version",
+ "SOME_INT": 10,
+ "SOME_USER_INPUT_FLAG": {
+ "value": "flag value",
+ "description": "Some Flag!"
+ }
+ }
+}
diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/cache.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/cache.yml
new file mode 100644
index 00000000000..ee533f54d3b
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/cache.yml
@@ -0,0 +1,15 @@
+# Covers https://gitlab.com/gitlab-org/gitlab/-/merge_requests/70779
+stages:
+ - prepare
+
+# invalid cache:when value
+job1:
+ stage: prepare
+ cache:
+ when: 0
+
+# invalid cache:when value
+job2:
+ stage: prepare
+ cache:
+ when: 'never'
diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/include.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/include.yml
new file mode 100644
index 00000000000..287150a765f
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/include.yml
@@ -0,0 +1,17 @@
+# Covers https://gitlab.com/gitlab-org/gitlab/-/merge_requests/70779
+stages:
+ - prepare
+
+# missing file property
+childPipeline:
+ stage: prepare
+ trigger:
+ include:
+ - project: 'my-group/my-pipeline-library'
+
+# missing project property
+childPipeline2:
+ stage: prepare
+ trigger:
+ include:
+ - file: '.gitlab-ci.yml'
diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/cache.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/cache.yml
new file mode 100644
index 00000000000..436c7d72699
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/cache.yml
@@ -0,0 +1,25 @@
+# Covers https://gitlab.com/gitlab-org/gitlab/-/merge_requests/70779
+stages:
+ - prepare
+
+# test for cache:when values
+job1:
+ stage: prepare
+ script:
+ - echo 'running job'
+ cache:
+ when: 'on_success'
+
+job2:
+ stage: prepare
+ script:
+ - echo 'running job'
+ cache:
+ when: 'on_failure'
+
+job3:
+ stage: prepare
+ script:
+ - echo 'running job'
+ cache:
+ when: 'always'
diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/filter.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/filter.yml
new file mode 100644
index 00000000000..2b29c24fa3c
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/filter.yml
@@ -0,0 +1,18 @@
+# Covers https://gitlab.com/gitlab-org/gitlab/-/merge_requests/79335
+deploy-template:
+ script:
+ - echo "hello world"
+ only:
+ - foo
+ except:
+ - bar
+
+# null value allowed
+deploy-without-only:
+ extends: deploy-template
+ only:
+
+# null value allowed
+deploy-without-except:
+ extends: deploy-template
+ except:
diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/include.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/include.yml
new file mode 100644
index 00000000000..3497be28058
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/include.yml
@@ -0,0 +1,32 @@
+# Covers https://gitlab.com/gitlab-org/gitlab/-/merge_requests/70779
+
+# test for include:rules
+include:
+ - local: builds.yml
+ rules:
+ - if: '$INCLUDE_BUILDS == "true"'
+ when: always
+
+stages:
+ - prepare
+
+# test for trigger:include
+childPipeline:
+ stage: prepare
+ script:
+ - echo 'creating pipeline...'
+ trigger:
+ include:
+ - project: 'my-group/my-pipeline-library'
+ file: '.gitlab-ci.yml'
+
+# accepts optional ref property
+childPipeline2:
+ stage: prepare
+ script:
+ - echo 'creating pipeline...'
+ trigger:
+ include:
+ - project: 'my-group/my-pipeline-library'
+ file: '.gitlab-ci.yml'
+ ref: 'main'
diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/rules.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/rules.yml
new file mode 100644
index 00000000000..27a199cff13
--- /dev/null
+++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/rules.yml
@@ -0,0 +1,13 @@
+# Covers https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74164
+
+# test for workflow:rules:changes and workflow:rules:exists
+workflow:
+ rules:
+ - if: '$CI_PIPELINE_SOURCE == "schedule"'
+ exists:
+ - Dockerfile
+ changes:
+ - Dockerfile
+ variables:
+ IS_A_FEATURE: 'true'
+ when: always
diff --git a/spec/frontend/environments/deploy_board_component_spec.js b/spec/frontend/environments/deploy_board_component_spec.js
index f0fb4d1027c..6bf87f7b07f 100644
--- a/spec/frontend/environments/deploy_board_component_spec.js
+++ b/spec/frontend/environments/deploy_board_component_spec.js
@@ -23,9 +23,9 @@ describe('Deploy Board', () => {
});
describe('with valid data', () => {
- beforeEach((done) => {
+ beforeEach(() => {
wrapper = createComponent();
- nextTick(done);
+ return nextTick();
});
it('should render percentage with completion value provided', () => {
@@ -127,14 +127,14 @@ describe('Deploy Board', () => {
});
describe('with empty state', () => {
- beforeEach((done) => {
+ beforeEach(() => {
wrapper = createComponent({
deployBoardData: {},
isLoading: false,
isEmpty: true,
logsPath,
});
- nextTick(done);
+ return nextTick();
});
it('should render the empty state', () => {
@@ -146,14 +146,14 @@ describe('Deploy Board', () => {
});
describe('with loading state', () => {
- beforeEach((done) => {
+ beforeEach(() => {
wrapper = createComponent({
deployBoardData: {},
isLoading: true,
isEmpty: false,
logsPath,
});
- nextTick(done);
+ return nextTick();
});
it('should render loading spinner', () => {
@@ -163,7 +163,7 @@ describe('Deploy Board', () => {
describe('has legend component', () => {
let statuses = [];
- beforeEach((done) => {
+ beforeEach(() => {
wrapper = createComponent({
isLoading: false,
isEmpty: false,
@@ -171,7 +171,7 @@ describe('Deploy Board', () => {
deployBoardData: deployBoardMockData,
});
({ statuses } = wrapper.vm);
- nextTick(done);
+ return nextTick();
});
it('with all the possible statuses', () => {
diff --git a/spec/frontend/environments/empty_state_spec.js b/spec/frontend/environments/empty_state_spec.js
new file mode 100644
index 00000000000..974afc6d032
--- /dev/null
+++ b/spec/frontend/environments/empty_state_spec.js
@@ -0,0 +1,53 @@
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import { s__ } from '~/locale';
+import EmptyState from '~/environments/components/empty_state.vue';
+import { ENVIRONMENTS_SCOPE } from '~/environments/constants';
+
+const HELP_PATH = '/help';
+
+describe('~/environments/components/empty_state.vue', () => {
+ let wrapper;
+
+ const createWrapper = ({ propsData = {} } = {}) =>
+ mountExtended(EmptyState, {
+ propsData: {
+ scope: ENVIRONMENTS_SCOPE.AVAILABLE,
+ helpPath: HELP_PATH,
+ ...propsData,
+ },
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('shows an empty state for available environments', () => {
+ wrapper = createWrapper();
+
+ const title = wrapper.findByRole('heading', {
+ name: s__("Environments|You don't have any environments."),
+ });
+
+ expect(title.exists()).toBe(true);
+ });
+
+ it('shows an empty state for stopped environments', () => {
+ wrapper = createWrapper({ propsData: { scope: ENVIRONMENTS_SCOPE.STOPPED } });
+
+ const title = wrapper.findByRole('heading', {
+ name: s__("Environments|You don't have any stopped environments."),
+ });
+
+ expect(title.exists()).toBe(true);
+ });
+
+ it('shows a link to the the help path', () => {
+ wrapper = createWrapper();
+
+ const link = wrapper.findByRole('link', {
+ name: s__('Environments|How do I create an environment?'),
+ });
+
+ expect(link.attributes('href')).toBe(HELP_PATH);
+ });
+});
diff --git a/spec/frontend/environments/emtpy_state_spec.js b/spec/frontend/environments/emtpy_state_spec.js
deleted file mode 100644
index 862d90e50dc..00000000000
--- a/spec/frontend/environments/emtpy_state_spec.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import EmptyState from '~/environments/components/empty_state.vue';
-
-describe('environments empty state', () => {
- let vm;
-
- beforeEach(() => {
- vm = shallowMount(EmptyState, {
- propsData: {
- helpPath: 'bar',
- },
- });
- });
-
- afterEach(() => {
- vm.destroy();
- });
-
- it('renders the empty state', () => {
- expect(vm.find('.js-blank-state-title').text()).toEqual(
- "You don't have any environments right now",
- );
- });
-});
diff --git a/spec/frontend/environments/environment_item_spec.js b/spec/frontend/environments/environment_item_spec.js
index 0b36d2a940d..0761d04229c 100644
--- a/spec/frontend/environments/environment_item_spec.js
+++ b/spec/frontend/environments/environment_item_spec.js
@@ -1,4 +1,5 @@
import { mount } from '@vue/test-utils';
+import { GlAvatarLink, GlAvatar } from '@gitlab/ui';
import { cloneDeep } from 'lodash';
import { format } from 'timeago.js';
import { mockTracking, unmockTracking, triggerEvent } from 'helpers/tracking_helper';
@@ -44,10 +45,16 @@ describe('Environment item', () => {
const findAutoStop = () => wrapper.find('.js-auto-stop');
const findUpcomingDeployment = () => wrapper.find('[data-testid="upcoming-deployment"]');
+ const findLastDeployment = () => wrapper.find('[data-testid="environment-deployment-id-cell"]');
const findUpcomingDeploymentContent = () =>
wrapper.find('[data-testid="upcoming-deployment-content"]');
const findUpcomingDeploymentStatusLink = () =>
wrapper.find('[data-testid="upcoming-deployment-status-link"]');
+ const findLastDeploymentAvatarLink = () => findLastDeployment().findComponent(GlAvatarLink);
+ const findLastDeploymentAvatar = () => findLastDeployment().findComponent(GlAvatar);
+ const findUpcomingDeploymentAvatarLink = () =>
+ findUpcomingDeployment().findComponent(GlAvatarLink);
+ const findUpcomingDeploymentAvatar = () => findUpcomingDeployment().findComponent(GlAvatar);
afterEach(() => {
wrapper.destroy();
@@ -79,9 +86,19 @@ describe('Environment item', () => {
describe('With user information', () => {
it('should render user avatar with link to profile', () => {
- expect(wrapper.find('.js-deploy-user-container').props('linkHref')).toEqual(
- environment.last_deployment.user.web_url,
- );
+ const avatarLink = findLastDeploymentAvatarLink();
+ const avatar = findLastDeploymentAvatar();
+ const { username, avatar_url, web_url } = environment.last_deployment.user;
+
+ expect(avatarLink.attributes('href')).toBe(web_url);
+ expect(avatar.props()).toMatchObject({
+ src: avatar_url,
+ entityName: username,
+ });
+ expect(avatar.attributes()).toMatchObject({
+ title: username,
+ alt: `${username}'s avatar`,
+ });
});
});
@@ -108,9 +125,16 @@ describe('Environment item', () => {
describe('When the envionment has an upcoming deployment', () => {
describe('When the upcoming deployment has a deployable', () => {
it('should render the build ID and user', () => {
- expect(findUpcomingDeploymentContent().text()).toMatchInterpolatedText(
- '#27 by upcoming-username',
- );
+ const avatarLink = findUpcomingDeploymentAvatarLink();
+ const avatar = findUpcomingDeploymentAvatar();
+ const { username, avatar_url, web_url } = environment.upcoming_deployment.user;
+
+ expect(findUpcomingDeploymentContent().text()).toMatchInterpolatedText('#27 by');
+ expect(avatarLink.attributes('href')).toBe(web_url);
+ expect(avatar.props()).toMatchObject({
+ src: avatar_url,
+ entityName: username,
+ });
});
it('should render a status icon with a link and tooltip', () => {
@@ -139,10 +163,17 @@ describe('Environment item', () => {
});
});
- it('should still renders the build ID and user', () => {
- expect(findUpcomingDeploymentContent().text()).toMatchInterpolatedText(
- '#27 by upcoming-username',
- );
+ it('should still render the build ID and user avatar', () => {
+ const avatarLink = findUpcomingDeploymentAvatarLink();
+ const avatar = findUpcomingDeploymentAvatar();
+ const { username, avatar_url, web_url } = environment.upcoming_deployment.user;
+
+ expect(findUpcomingDeploymentContent().text()).toMatchInterpolatedText('#27 by');
+ expect(avatarLink.attributes('href')).toBe(web_url);
+ expect(avatar.props()).toMatchObject({
+ src: avatar_url,
+ entityName: username,
+ });
});
it('should not render the status icon', () => {
@@ -383,7 +414,7 @@ describe('Environment item', () => {
});
it('should hide non-folder properties', () => {
- expect(wrapper.find('[data-testid="environment-deployment-id-cell"]').exists()).toBe(false);
+ expect(findLastDeployment().exists()).toBe(false);
expect(wrapper.find('[data-testid="environment-build-cell"]').exists()).toBe(false);
});
});
diff --git a/spec/frontend/environments/environment_table_spec.js b/spec/frontend/environments/environment_table_spec.js
index c7582e4b06d..666e87c748e 100644
--- a/spec/frontend/environments/environment_table_spec.js
+++ b/spec/frontend/environments/environment_table_spec.js
@@ -122,7 +122,7 @@ describe('Environment table', () => {
expect(wrapper.find('.deploy-board-icon').exists()).toBe(true);
});
- it('should toggle deploy board visibility when arrow is clicked', (done) => {
+ it('should toggle deploy board visibility when arrow is clicked', async () => {
const mockItem = {
name: 'review',
size: 1,
@@ -142,7 +142,6 @@ describe('Environment table', () => {
eventHub.$on('toggleDeployBoard', (env) => {
expect(env.id).toEqual(mockItem.id);
- done();
});
factory({
@@ -154,7 +153,7 @@ describe('Environment table', () => {
},
});
- wrapper.find('.deploy-board-icon').trigger('click');
+ await wrapper.find('.deploy-board-icon').trigger('click');
});
it('should set the environment to change and weight when a change canary weight event is recevied', async () => {
diff --git a/spec/frontend/environments/graphql/mock_data.js b/spec/frontend/environments/graphql/mock_data.js
index 1b7b35702de..7e436476a8f 100644
--- a/spec/frontend/environments/graphql/mock_data.js
+++ b/spec/frontend/environments/graphql/mock_data.js
@@ -543,6 +543,7 @@ export const resolvedEnvironment = {
externalUrl: 'https://example.org',
environmentType: 'review',
nameWithoutType: 'hello',
+ tier: 'development',
lastDeployment: {
id: 78,
iid: 24,
@@ -551,6 +552,7 @@ export const resolvedEnvironment = {
status: 'success',
createdAt: '2022-01-07T15:47:27.415Z',
deployedAt: '2022-01-07T15:47:32.450Z',
+ tierInYaml: 'staging',
tag: false,
isLast: true,
user: {
diff --git a/spec/frontend/environments/new_environment_item_spec.js b/spec/frontend/environments/new_environment_item_spec.js
index 1d7a33fb95b..cf0c8a7e7ca 100644
--- a/spec/frontend/environments/new_environment_item_spec.js
+++ b/spec/frontend/environments/new_environment_item_spec.js
@@ -73,6 +73,34 @@ describe('~/environments/components/new_environment_item.vue', () => {
expect(name.text()).toHaveLength(80);
});
+ describe('tier', () => {
+ it('displays the tier of the environment when defined in yaml', () => {
+ wrapper = createWrapper({ apolloProvider: createApolloProvider() });
+
+ const tier = wrapper.findByTitle(s__('Environment|Deployment tier'));
+
+ expect(tier.text()).toBe(resolvedEnvironment.lastDeployment.tierInYaml);
+ });
+
+ it('does not display the tier if not defined in yaml', () => {
+ const environment = {
+ ...resolvedEnvironment,
+ lastDeployment: {
+ ...resolvedEnvironment.lastDeployment,
+ tierInYaml: null,
+ },
+ };
+ wrapper = createWrapper({
+ propsData: { environment },
+ apolloProvider: createApolloProvider(),
+ });
+
+ const tier = wrapper.findByTitle(s__('Environment|Deployment tier'));
+
+ expect(tier.exists()).toBe(false);
+ });
+ });
+
describe('url', () => {
it('shows a link for the url if one is present', () => {
wrapper = createWrapper({ apolloProvider: createApolloProvider() });
diff --git a/spec/frontend/error_tracking/store/actions_spec.js b/spec/frontend/error_tracking/store/actions_spec.js
index aaaa1194a29..6bac21341a7 100644
--- a/spec/frontend/error_tracking/store/actions_spec.js
+++ b/spec/frontend/error_tracking/store/actions_spec.js
@@ -28,9 +28,9 @@ describe('Sentry common store actions', () => {
const params = { endpoint, redirectUrl, status };
describe('updateStatus', () => {
- it('should handle successful status update', (done) => {
+ it('should handle successful status update', async () => {
mock.onPut().reply(200, {});
- testAction(
+ await testAction(
actions.updateStatus,
params,
{},
@@ -41,20 +41,15 @@ describe('Sentry common store actions', () => {
},
],
[],
- () => {
- done();
- expect(visitUrl).toHaveBeenCalledWith(redirectUrl);
- },
);
+ expect(visitUrl).toHaveBeenCalledWith(redirectUrl);
});
- it('should handle unsuccessful status update', (done) => {
+ it('should handle unsuccessful status update', async () => {
mock.onPut().reply(400, {});
- testAction(actions.updateStatus, params, {}, [], [], () => {
- expect(visitUrl).not.toHaveBeenCalled();
- expect(createFlash).toHaveBeenCalledTimes(1);
- done();
- });
+ await testAction(actions.updateStatus, params, {}, [], []);
+ expect(visitUrl).not.toHaveBeenCalled();
+ expect(createFlash).toHaveBeenCalledTimes(1);
});
});
diff --git a/spec/frontend/error_tracking/store/details/actions_spec.js b/spec/frontend/error_tracking/store/details/actions_spec.js
index 623cb82851d..a3a6f7cc309 100644
--- a/spec/frontend/error_tracking/store/details/actions_spec.js
+++ b/spec/frontend/error_tracking/store/details/actions_spec.js
@@ -28,10 +28,10 @@ describe('Sentry error details store actions', () => {
describe('startPollingStacktrace', () => {
const endpoint = '123/stacktrace';
- it('should commit SET_ERROR with received response', (done) => {
+ it('should commit SET_ERROR with received response', () => {
const payload = { error: [1, 2, 3] };
mockedAdapter.onGet().reply(200, payload);
- testAction(
+ return testAction(
actions.startPollingStacktrace,
{ endpoint },
{},
@@ -40,37 +40,29 @@ describe('Sentry error details store actions', () => {
{ type: types.SET_LOADING_STACKTRACE, payload: false },
],
[],
- () => {
- done();
- },
);
});
- it('should show flash on API error', (done) => {
+ it('should show flash on API error', async () => {
mockedAdapter.onGet().reply(400);
- testAction(
+ await testAction(
actions.startPollingStacktrace,
{ endpoint },
{},
[{ type: types.SET_LOADING_STACKTRACE, payload: false }],
[],
- () => {
- expect(createFlash).toHaveBeenCalledTimes(1);
- done();
- },
);
+ expect(createFlash).toHaveBeenCalledTimes(1);
});
- it('should not restart polling when receiving an empty 204 response', (done) => {
+ it('should not restart polling when receiving an empty 204 response', async () => {
mockedRestart = jest.spyOn(Poll.prototype, 'restart');
mockedAdapter.onGet().reply(204);
- testAction(actions.startPollingStacktrace, { endpoint }, {}, [], [], () => {
- mockedRestart = jest.spyOn(Poll.prototype, 'restart');
- expect(mockedRestart).toHaveBeenCalledTimes(0);
- done();
- });
+ await testAction(actions.startPollingStacktrace, { endpoint }, {}, [], []);
+ mockedRestart = jest.spyOn(Poll.prototype, 'restart');
+ expect(mockedRestart).toHaveBeenCalledTimes(0);
});
});
});
diff --git a/spec/frontend/error_tracking/store/list/actions_spec.js b/spec/frontend/error_tracking/store/list/actions_spec.js
index 5465bde397c..7173f68bb96 100644
--- a/spec/frontend/error_tracking/store/list/actions_spec.js
+++ b/spec/frontend/error_tracking/store/list/actions_spec.js
@@ -20,11 +20,11 @@ describe('error tracking actions', () => {
});
describe('startPolling', () => {
- it('should start polling for data', (done) => {
+ it('should start polling for data', () => {
const payload = { errors: [{ id: 1 }, { id: 2 }] };
mock.onGet().reply(httpStatusCodes.OK, payload);
- testAction(
+ return testAction(
actions.startPolling,
{},
{},
@@ -35,16 +35,13 @@ describe('error tracking actions', () => {
{ type: types.SET_LOADING, payload: false },
],
[{ type: 'stopPolling' }],
- () => {
- done();
- },
);
});
- it('should show flash on API error', (done) => {
+ it('should show flash on API error', async () => {
mock.onGet().reply(httpStatusCodes.BAD_REQUEST);
- testAction(
+ await testAction(
actions.startPolling,
{},
{},
@@ -53,11 +50,8 @@ describe('error tracking actions', () => {
{ type: types.SET_LOADING, payload: false },
],
[],
- () => {
- expect(createFlash).toHaveBeenCalledTimes(1);
- done();
- },
);
+ expect(createFlash).toHaveBeenCalledTimes(1);
});
});
diff --git a/spec/frontend/error_tracking_settings/store/actions_spec.js b/spec/frontend/error_tracking_settings/store/actions_spec.js
index 1b9be042dd4..bcd816c2ae0 100644
--- a/spec/frontend/error_tracking_settings/store/actions_spec.js
+++ b/spec/frontend/error_tracking_settings/store/actions_spec.js
@@ -27,9 +27,9 @@ describe('error tracking settings actions', () => {
refreshCurrentPage.mockClear();
});
- it('should request and transform the project list', (done) => {
+ it('should request and transform the project list', async () => {
mock.onGet(TEST_HOST).reply(() => [200, { projects: projectList }]);
- testAction(
+ await testAction(
actions.fetchProjects,
null,
state,
@@ -41,16 +41,13 @@ describe('error tracking settings actions', () => {
payload: projectList.map(convertObjectPropsToCamelCase),
},
],
- () => {
- expect(mock.history.get.length).toBe(1);
- done();
- },
);
+ expect(mock.history.get.length).toBe(1);
});
- it('should handle a server error', (done) => {
+ it('should handle a server error', async () => {
mock.onGet(`${TEST_HOST}.json`).reply(() => [400]);
- testAction(
+ await testAction(
actions.fetchProjects,
null,
state,
@@ -61,27 +58,23 @@ describe('error tracking settings actions', () => {
type: 'receiveProjectsError',
},
],
- () => {
- expect(mock.history.get.length).toBe(1);
- done();
- },
);
+ expect(mock.history.get.length).toBe(1);
});
- it('should request projects correctly', (done) => {
- testAction(
+ it('should request projects correctly', () => {
+ return testAction(
actions.requestProjects,
null,
state,
[{ type: types.SET_PROJECTS_LOADING, payload: true }, { type: types.RESET_CONNECT }],
[],
- done,
);
});
- it('should receive projects correctly', (done) => {
+ it('should receive projects correctly', () => {
const testPayload = [];
- testAction(
+ return testAction(
actions.receiveProjectsSuccess,
testPayload,
state,
@@ -91,13 +84,12 @@ describe('error tracking settings actions', () => {
{ type: types.SET_PROJECTS_LOADING, payload: false },
],
[],
- done,
);
});
- it('should handle errors when receiving projects', (done) => {
+ it('should handle errors when receiving projects', () => {
const testPayload = [];
- testAction(
+ return testAction(
actions.receiveProjectsError,
testPayload,
state,
@@ -107,7 +99,6 @@ describe('error tracking settings actions', () => {
{ type: types.SET_PROJECTS_LOADING, payload: false },
],
[],
- done,
);
});
});
@@ -126,18 +117,16 @@ describe('error tracking settings actions', () => {
mock.restore();
});
- it('should save the page', (done) => {
+ it('should save the page', async () => {
mock.onPatch(TEST_HOST).reply(200);
- testAction(actions.updateSettings, null, state, [], [{ type: 'requestSettings' }], () => {
- expect(mock.history.patch.length).toBe(1);
- expect(refreshCurrentPage).toHaveBeenCalled();
- done();
- });
+ await testAction(actions.updateSettings, null, state, [], [{ type: 'requestSettings' }]);
+ expect(mock.history.patch.length).toBe(1);
+ expect(refreshCurrentPage).toHaveBeenCalled();
});
- it('should handle a server error', (done) => {
+ it('should handle a server error', async () => {
mock.onPatch(TEST_HOST).reply(400);
- testAction(
+ await testAction(
actions.updateSettings,
null,
state,
@@ -149,57 +138,50 @@ describe('error tracking settings actions', () => {
payload: new Error('Request failed with status code 400'),
},
],
- () => {
- expect(mock.history.patch.length).toBe(1);
- done();
- },
);
+ expect(mock.history.patch.length).toBe(1);
});
- it('should request to save the page', (done) => {
- testAction(
+ it('should request to save the page', () => {
+ return testAction(
actions.requestSettings,
null,
state,
[{ type: types.UPDATE_SETTINGS_LOADING, payload: true }],
[],
- done,
);
});
- it('should handle errors when requesting to save the page', (done) => {
- testAction(
+ it('should handle errors when requesting to save the page', () => {
+ return testAction(
actions.receiveSettingsError,
{},
state,
[{ type: types.UPDATE_SETTINGS_LOADING, payload: false }],
[],
- done,
);
});
});
describe('generic actions to update the store', () => {
const testData = 'test';
- it('should reset the `connect success` flag when updating the api host', (done) => {
- testAction(
+ it('should reset the `connect success` flag when updating the api host', () => {
+ return testAction(
actions.updateApiHost,
testData,
state,
[{ type: types.UPDATE_API_HOST, payload: testData }, { type: types.RESET_CONNECT }],
[],
- done,
);
});
- it('should reset the `connect success` flag when updating the token', (done) => {
- testAction(
+ it('should reset the `connect success` flag when updating the token', () => {
+ return testAction(
actions.updateToken,
testData,
state,
[{ type: types.UPDATE_TOKEN, payload: testData }, { type: types.RESET_CONNECT }],
[],
- done,
);
});
diff --git a/spec/frontend/feature_flags/store/edit/actions_spec.js b/spec/frontend/feature_flags/store/edit/actions_spec.js
index 12fccd79170..b6114cb0c9f 100644
--- a/spec/frontend/feature_flags/store/edit/actions_spec.js
+++ b/spec/frontend/feature_flags/store/edit/actions_spec.js
@@ -40,7 +40,7 @@ describe('Feature flags Edit Module actions', () => {
});
describe('success', () => {
- it('dispatches requestUpdateFeatureFlag and receiveUpdateFeatureFlagSuccess ', (done) => {
+ it('dispatches requestUpdateFeatureFlag and receiveUpdateFeatureFlagSuccess ', () => {
const featureFlag = {
name: 'name',
description: 'description',
@@ -57,7 +57,7 @@ describe('Feature flags Edit Module actions', () => {
};
mock.onPut(mockedState.endpoint, mapStrategiesToRails(featureFlag)).replyOnce(200);
- testAction(
+ return testAction(
updateFeatureFlag,
featureFlag,
mockedState,
@@ -70,16 +70,15 @@ describe('Feature flags Edit Module actions', () => {
type: 'receiveUpdateFeatureFlagSuccess',
},
],
- done,
);
});
});
describe('error', () => {
- it('dispatches requestUpdateFeatureFlag and receiveUpdateFeatureFlagError ', (done) => {
+ it('dispatches requestUpdateFeatureFlag and receiveUpdateFeatureFlagError ', () => {
mock.onPut(`${TEST_HOST}/endpoint.json`).replyOnce(500, { message: [] });
- testAction(
+ return testAction(
updateFeatureFlag,
{
name: 'feature_flag',
@@ -97,28 +96,26 @@ describe('Feature flags Edit Module actions', () => {
payload: { message: [] },
},
],
- done,
);
});
});
});
describe('requestUpdateFeatureFlag', () => {
- it('should commit REQUEST_UPDATE_FEATURE_FLAG mutation', (done) => {
- testAction(
+ it('should commit REQUEST_UPDATE_FEATURE_FLAG mutation', () => {
+ return testAction(
requestUpdateFeatureFlag,
null,
mockedState,
[{ type: types.REQUEST_UPDATE_FEATURE_FLAG }],
[],
- done,
);
});
});
describe('receiveUpdateFeatureFlagSuccess', () => {
- it('should commit RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS mutation', (done) => {
- testAction(
+ it('should commit RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS mutation', () => {
+ return testAction(
receiveUpdateFeatureFlagSuccess,
null,
mockedState,
@@ -128,20 +125,18 @@ describe('Feature flags Edit Module actions', () => {
},
],
[],
- done,
);
});
});
describe('receiveUpdateFeatureFlagError', () => {
- it('should commit RECEIVE_UPDATE_FEATURE_FLAG_ERROR mutation', (done) => {
- testAction(
+ it('should commit RECEIVE_UPDATE_FEATURE_FLAG_ERROR mutation', () => {
+ return testAction(
receiveUpdateFeatureFlagError,
'There was an error',
mockedState,
[{ type: types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR, payload: 'There was an error' }],
[],
- done,
);
});
});
@@ -159,10 +154,10 @@ describe('Feature flags Edit Module actions', () => {
});
describe('success', () => {
- it('dispatches requestFeatureFlag and receiveFeatureFlagSuccess ', (done) => {
+ it('dispatches requestFeatureFlag and receiveFeatureFlagSuccess ', () => {
mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, { id: 1 });
- testAction(
+ return testAction(
fetchFeatureFlag,
{ id: 1 },
mockedState,
@@ -176,16 +171,15 @@ describe('Feature flags Edit Module actions', () => {
payload: { id: 1 },
},
],
- done,
);
});
});
describe('error', () => {
- it('dispatches requestFeatureFlag and receiveUpdateFeatureFlagError ', (done) => {
+ it('dispatches requestFeatureFlag and receiveUpdateFeatureFlagError ', () => {
mock.onGet(`${TEST_HOST}/endpoint.json`, {}).replyOnce(500, {});
- testAction(
+ return testAction(
fetchFeatureFlag,
null,
mockedState,
@@ -198,41 +192,38 @@ describe('Feature flags Edit Module actions', () => {
type: 'receiveFeatureFlagError',
},
],
- done,
);
});
});
});
describe('requestFeatureFlag', () => {
- it('should commit REQUEST_FEATURE_FLAG mutation', (done) => {
- testAction(
+ it('should commit REQUEST_FEATURE_FLAG mutation', () => {
+ return testAction(
requestFeatureFlag,
null,
mockedState,
[{ type: types.REQUEST_FEATURE_FLAG }],
[],
- done,
);
});
});
describe('receiveFeatureFlagSuccess', () => {
- it('should commit RECEIVE_FEATURE_FLAG_SUCCESS mutation', (done) => {
- testAction(
+ it('should commit RECEIVE_FEATURE_FLAG_SUCCESS mutation', () => {
+ return testAction(
receiveFeatureFlagSuccess,
{ id: 1 },
mockedState,
[{ type: types.RECEIVE_FEATURE_FLAG_SUCCESS, payload: { id: 1 } }],
[],
- done,
);
});
});
describe('receiveFeatureFlagError', () => {
- it('should commit RECEIVE_FEATURE_FLAG_ERROR mutation', (done) => {
- testAction(
+ it('should commit RECEIVE_FEATURE_FLAG_ERROR mutation', () => {
+ return testAction(
receiveFeatureFlagError,
null,
mockedState,
@@ -242,20 +233,18 @@ describe('Feature flags Edit Module actions', () => {
},
],
[],
- done,
);
});
});
describe('toggelActive', () => {
- it('should commit TOGGLE_ACTIVE mutation', (done) => {
- testAction(
+ it('should commit TOGGLE_ACTIVE mutation', () => {
+ return testAction(
toggleActive,
true,
mockedState,
[{ type: types.TOGGLE_ACTIVE, payload: true }],
[],
- done,
);
});
});
diff --git a/spec/frontend/feature_flags/store/index/actions_spec.js b/spec/frontend/feature_flags/store/index/actions_spec.js
index a59f99f538c..ce62c3b0473 100644
--- a/spec/frontend/feature_flags/store/index/actions_spec.js
+++ b/spec/frontend/feature_flags/store/index/actions_spec.js
@@ -32,14 +32,13 @@ describe('Feature flags actions', () => {
});
describe('setFeatureFlagsOptions', () => {
- it('should commit SET_FEATURE_FLAGS_OPTIONS mutation', (done) => {
- testAction(
+ it('should commit SET_FEATURE_FLAGS_OPTIONS mutation', () => {
+ return testAction(
setFeatureFlagsOptions,
{ page: '1', scope: 'all' },
mockedState,
[{ type: types.SET_FEATURE_FLAGS_OPTIONS, payload: { page: '1', scope: 'all' } }],
[],
- done,
);
});
});
@@ -57,10 +56,10 @@ describe('Feature flags actions', () => {
});
describe('success', () => {
- it('dispatches requestFeatureFlags and receiveFeatureFlagsSuccess ', (done) => {
+ it('dispatches requestFeatureFlags and receiveFeatureFlagsSuccess ', () => {
mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, getRequestData, {});
- testAction(
+ return testAction(
fetchFeatureFlags,
null,
mockedState,
@@ -74,16 +73,15 @@ describe('Feature flags actions', () => {
type: 'receiveFeatureFlagsSuccess',
},
],
- done,
);
});
});
describe('error', () => {
- it('dispatches requestFeatureFlags and receiveFeatureFlagsError ', (done) => {
+ it('dispatches requestFeatureFlags and receiveFeatureFlagsError ', () => {
mock.onGet(`${TEST_HOST}/endpoint.json`, {}).replyOnce(500, {});
- testAction(
+ return testAction(
fetchFeatureFlags,
null,
mockedState,
@@ -96,28 +94,26 @@ describe('Feature flags actions', () => {
type: 'receiveFeatureFlagsError',
},
],
- done,
);
});
});
});
describe('requestFeatureFlags', () => {
- it('should commit RECEIVE_FEATURE_FLAGS_SUCCESS mutation', (done) => {
- testAction(
+ it('should commit RECEIVE_FEATURE_FLAGS_SUCCESS mutation', () => {
+ return testAction(
requestFeatureFlags,
null,
mockedState,
[{ type: types.REQUEST_FEATURE_FLAGS }],
[],
- done,
);
});
});
describe('receiveFeatureFlagsSuccess', () => {
- it('should commit RECEIVE_FEATURE_FLAGS_SUCCESS mutation', (done) => {
- testAction(
+ it('should commit RECEIVE_FEATURE_FLAGS_SUCCESS mutation', () => {
+ return testAction(
receiveFeatureFlagsSuccess,
{ data: getRequestData, headers: {} },
mockedState,
@@ -128,20 +124,18 @@ describe('Feature flags actions', () => {
},
],
[],
- done,
);
});
});
describe('receiveFeatureFlagsError', () => {
- it('should commit RECEIVE_FEATURE_FLAGS_ERROR mutation', (done) => {
- testAction(
+ it('should commit RECEIVE_FEATURE_FLAGS_ERROR mutation', () => {
+ return testAction(
receiveFeatureFlagsError,
null,
mockedState,
[{ type: types.RECEIVE_FEATURE_FLAGS_ERROR }],
[],
- done,
);
});
});
@@ -159,10 +153,10 @@ describe('Feature flags actions', () => {
});
describe('success', () => {
- it('dispatches requestRotateInstanceId and receiveRotateInstanceIdSuccess ', (done) => {
+ it('dispatches requestRotateInstanceId and receiveRotateInstanceIdSuccess ', () => {
mock.onPost(`${TEST_HOST}/endpoint.json`).replyOnce(200, rotateData, {});
- testAction(
+ return testAction(
rotateInstanceId,
null,
mockedState,
@@ -176,16 +170,15 @@ describe('Feature flags actions', () => {
type: 'receiveRotateInstanceIdSuccess',
},
],
- done,
);
});
});
describe('error', () => {
- it('dispatches requestRotateInstanceId and receiveRotateInstanceIdError ', (done) => {
+ it('dispatches requestRotateInstanceId and receiveRotateInstanceIdError ', () => {
mock.onGet(`${TEST_HOST}/endpoint.json`, {}).replyOnce(500, {});
- testAction(
+ return testAction(
rotateInstanceId,
null,
mockedState,
@@ -198,28 +191,26 @@ describe('Feature flags actions', () => {
type: 'receiveRotateInstanceIdError',
},
],
- done,
);
});
});
});
describe('requestRotateInstanceId', () => {
- it('should commit REQUEST_ROTATE_INSTANCE_ID mutation', (done) => {
- testAction(
+ it('should commit REQUEST_ROTATE_INSTANCE_ID mutation', () => {
+ return testAction(
requestRotateInstanceId,
null,
mockedState,
[{ type: types.REQUEST_ROTATE_INSTANCE_ID }],
[],
- done,
);
});
});
describe('receiveRotateInstanceIdSuccess', () => {
- it('should commit RECEIVE_ROTATE_INSTANCE_ID_SUCCESS mutation', (done) => {
- testAction(
+ it('should commit RECEIVE_ROTATE_INSTANCE_ID_SUCCESS mutation', () => {
+ return testAction(
receiveRotateInstanceIdSuccess,
{ data: rotateData, headers: {} },
mockedState,
@@ -230,20 +221,18 @@ describe('Feature flags actions', () => {
},
],
[],
- done,
);
});
});
describe('receiveRotateInstanceIdError', () => {
- it('should commit RECEIVE_ROTATE_INSTANCE_ID_ERROR mutation', (done) => {
- testAction(
+ it('should commit RECEIVE_ROTATE_INSTANCE_ID_ERROR mutation', () => {
+ return testAction(
receiveRotateInstanceIdError,
null,
mockedState,
[{ type: types.RECEIVE_ROTATE_INSTANCE_ID_ERROR }],
[],
- done,
);
});
});
@@ -262,10 +251,10 @@ describe('Feature flags actions', () => {
mock.restore();
});
describe('success', () => {
- it('dispatches updateFeatureFlag and receiveUpdateFeatureFlagSuccess', (done) => {
+ it('dispatches updateFeatureFlag and receiveUpdateFeatureFlagSuccess', () => {
mock.onPut(featureFlag.update_path).replyOnce(200, featureFlag, {});
- testAction(
+ return testAction(
toggleFeatureFlag,
featureFlag,
mockedState,
@@ -280,15 +269,15 @@ describe('Feature flags actions', () => {
type: 'receiveUpdateFeatureFlagSuccess',
},
],
- done,
);
});
});
+
describe('error', () => {
- it('dispatches updateFeatureFlag and receiveUpdateFeatureFlagSuccess', (done) => {
+ it('dispatches updateFeatureFlag and receiveUpdateFeatureFlagSuccess', () => {
mock.onPut(featureFlag.update_path).replyOnce(500);
- testAction(
+ return testAction(
toggleFeatureFlag,
featureFlag,
mockedState,
@@ -303,7 +292,6 @@ describe('Feature flags actions', () => {
type: 'receiveUpdateFeatureFlagError',
},
],
- done,
);
});
});
@@ -315,8 +303,8 @@ describe('Feature flags actions', () => {
}));
});
- it('commits UPDATE_FEATURE_FLAG with the given flag', (done) => {
- testAction(
+ it('commits UPDATE_FEATURE_FLAG with the given flag', () => {
+ return testAction(
updateFeatureFlag,
featureFlag,
mockedState,
@@ -327,7 +315,6 @@ describe('Feature flags actions', () => {
},
],
[],
- done,
);
});
});
@@ -338,8 +325,8 @@ describe('Feature flags actions', () => {
}));
});
- it('commits RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS with the given flag', (done) => {
- testAction(
+ it('commits RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS with the given flag', () => {
+ return testAction(
receiveUpdateFeatureFlagSuccess,
featureFlag,
mockedState,
@@ -350,7 +337,6 @@ describe('Feature flags actions', () => {
},
],
[],
- done,
);
});
});
@@ -361,8 +347,8 @@ describe('Feature flags actions', () => {
}));
});
- it('commits RECEIVE_UPDATE_FEATURE_FLAG_ERROR with the given flag id', (done) => {
- testAction(
+ it('commits RECEIVE_UPDATE_FEATURE_FLAG_ERROR with the given flag id', () => {
+ return testAction(
receiveUpdateFeatureFlagError,
featureFlag.id,
mockedState,
@@ -373,22 +359,20 @@ describe('Feature flags actions', () => {
},
],
[],
- done,
);
});
});
describe('clearAlert', () => {
- it('should commit RECEIVE_CLEAR_ALERT', (done) => {
+ it('should commit RECEIVE_CLEAR_ALERT', () => {
const alertIndex = 3;
- testAction(
+ return testAction(
clearAlert,
alertIndex,
mockedState,
[{ type: 'RECEIVE_CLEAR_ALERT', payload: alertIndex }],
[],
- done,
);
});
});
diff --git a/spec/frontend/feature_flags/store/new/actions_spec.js b/spec/frontend/feature_flags/store/new/actions_spec.js
index 7900b200eb2..1dcd2da1d93 100644
--- a/spec/frontend/feature_flags/store/new/actions_spec.js
+++ b/spec/frontend/feature_flags/store/new/actions_spec.js
@@ -33,7 +33,7 @@ describe('Feature flags New Module Actions', () => {
});
describe('success', () => {
- it('dispatches requestCreateFeatureFlag and receiveCreateFeatureFlagSuccess ', (done) => {
+ it('dispatches requestCreateFeatureFlag and receiveCreateFeatureFlagSuccess ', () => {
const actionParams = {
name: 'name',
description: 'description',
@@ -50,7 +50,7 @@ describe('Feature flags New Module Actions', () => {
};
mock.onPost(mockedState.endpoint, mapStrategiesToRails(actionParams)).replyOnce(200);
- testAction(
+ return testAction(
createFeatureFlag,
actionParams,
mockedState,
@@ -63,13 +63,12 @@ describe('Feature flags New Module Actions', () => {
type: 'receiveCreateFeatureFlagSuccess',
},
],
- done,
);
});
});
describe('error', () => {
- it('dispatches requestCreateFeatureFlag and receiveCreateFeatureFlagError ', (done) => {
+ it('dispatches requestCreateFeatureFlag and receiveCreateFeatureFlagError ', () => {
const actionParams = {
name: 'name',
description: 'description',
@@ -88,7 +87,7 @@ describe('Feature flags New Module Actions', () => {
.onPost(mockedState.endpoint, mapStrategiesToRails(actionParams))
.replyOnce(500, { message: [] });
- testAction(
+ return testAction(
createFeatureFlag,
actionParams,
mockedState,
@@ -102,28 +101,26 @@ describe('Feature flags New Module Actions', () => {
payload: { message: [] },
},
],
- done,
);
});
});
});
describe('requestCreateFeatureFlag', () => {
- it('should commit REQUEST_CREATE_FEATURE_FLAG mutation', (done) => {
- testAction(
+ it('should commit REQUEST_CREATE_FEATURE_FLAG mutation', () => {
+ return testAction(
requestCreateFeatureFlag,
null,
mockedState,
[{ type: types.REQUEST_CREATE_FEATURE_FLAG }],
[],
- done,
);
});
});
describe('receiveCreateFeatureFlagSuccess', () => {
- it('should commit RECEIVE_CREATE_FEATURE_FLAG_SUCCESS mutation', (done) => {
- testAction(
+ it('should commit RECEIVE_CREATE_FEATURE_FLAG_SUCCESS mutation', () => {
+ return testAction(
receiveCreateFeatureFlagSuccess,
null,
mockedState,
@@ -133,20 +130,18 @@ describe('Feature flags New Module Actions', () => {
},
],
[],
- done,
);
});
});
describe('receiveCreateFeatureFlagError', () => {
- it('should commit RECEIVE_CREATE_FEATURE_FLAG_ERROR mutation', (done) => {
- testAction(
+ it('should commit RECEIVE_CREATE_FEATURE_FLAG_ERROR mutation', () => {
+ return testAction(
receiveCreateFeatureFlagError,
'There was an error',
mockedState,
[{ type: types.RECEIVE_CREATE_FEATURE_FLAG_ERROR, payload: 'There was an error' }],
[],
- done,
);
});
});
diff --git a/spec/frontend/filtered_search/droplab/plugins/ajax_filter_spec.js b/spec/frontend/filtered_search/droplab/plugins/ajax_filter_spec.js
index 88b3fc236e4..212b9ffc8f9 100644
--- a/spec/frontend/filtered_search/droplab/plugins/ajax_filter_spec.js
+++ b/spec/frontend/filtered_search/droplab/plugins/ajax_filter_spec.js
@@ -38,35 +38,25 @@ describe('AjaxFilter', () => {
dummyList.list.appendChild(dynamicList);
});
- it('calls onLoadingFinished after loading data', (done) => {
+ it('calls onLoadingFinished after loading data', async () => {
ajaxSpy = (url) => {
expect(url).toBe('dummy endpoint?dummy search key=');
return Promise.resolve(dummyData);
};
- AjaxFilter.trigger()
- .then(() => {
- expect(dummyConfig.onLoadingFinished.mock.calls.length).toBe(1);
- })
- .then(done)
- .catch(done.fail);
+ await AjaxFilter.trigger();
+ expect(dummyConfig.onLoadingFinished.mock.calls.length).toBe(1);
});
- it('does not call onLoadingFinished if Ajax call fails', (done) => {
+ it('does not call onLoadingFinished if Ajax call fails', async () => {
const dummyError = new Error('My dummy is sick! :-(');
ajaxSpy = (url) => {
expect(url).toBe('dummy endpoint?dummy search key=');
return Promise.reject(dummyError);
};
- AjaxFilter.trigger()
- .then(done.fail)
- .catch((error) => {
- expect(error).toBe(dummyError);
- expect(dummyConfig.onLoadingFinished.mock.calls.length).toBe(0);
- })
- .then(done)
- .catch(done.fail);
+ await expect(AjaxFilter.trigger()).rejects.toEqual(dummyError);
+ expect(dummyConfig.onLoadingFinished.mock.calls.length).toBe(0);
});
});
});
diff --git a/spec/frontend/filtered_search/filtered_search_manager_spec.js b/spec/frontend/filtered_search/filtered_search_manager_spec.js
index 83e7f6c9b3f..911a507af4c 100644
--- a/spec/frontend/filtered_search/filtered_search_manager_spec.js
+++ b/spec/frontend/filtered_search/filtered_search_manager_spec.js
@@ -190,43 +190,40 @@ describe('Filtered Search Manager', () => {
const defaultParams = '?scope=all';
const defaultState = '&state=opened';
- it('should search with a single word', (done) => {
+ it('should search with a single word', () => {
initializeManager();
input.value = 'searchTerm';
visitUrl.mockImplementation((url) => {
expect(url).toEqual(`${defaultParams}&search=searchTerm`);
- done();
});
manager.search();
});
- it('sets default state', (done) => {
+ it('sets default state', () => {
initializeManager({ useDefaultState: true });
input.value = 'searchTerm';
visitUrl.mockImplementation((url) => {
expect(url).toEqual(`${defaultParams}${defaultState}&search=searchTerm`);
- done();
});
manager.search();
});
- it('should search with multiple words', (done) => {
+ it('should search with multiple words', () => {
initializeManager();
input.value = 'awesome search terms';
visitUrl.mockImplementation((url) => {
expect(url).toEqual(`${defaultParams}&search=awesome+search+terms`);
- done();
});
manager.search();
});
- it('should search with special characters', (done) => {
+ it('should search with special characters', () => {
initializeManager();
input.value = '~!@#$%^&*()_+{}:<>,.?/';
@@ -234,13 +231,12 @@ describe('Filtered Search Manager', () => {
expect(url).toEqual(
`${defaultParams}&search=~!%40%23%24%25%5E%26*()_%2B%7B%7D%3A%3C%3E%2C.%3F%2F`,
);
- done();
});
manager.search();
});
- it('should use replacement URL for condition', (done) => {
+ it('should use replacement URL for condition', () => {
initializeManager();
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', '13', true),
@@ -248,7 +244,6 @@ describe('Filtered Search Manager', () => {
visitUrl.mockImplementation((url) => {
expect(url).toEqual(`${defaultParams}&milestone_title=replaced`);
- done();
});
manager.filteredSearchTokenKeys.conditions.push({
@@ -261,7 +256,7 @@ describe('Filtered Search Manager', () => {
manager.search();
});
- it('removes duplicated tokens', (done) => {
+ it('removes duplicated tokens', () => {
initializeManager();
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug')}
@@ -270,7 +265,6 @@ describe('Filtered Search Manager', () => {
visitUrl.mockImplementation((url) => {
expect(url).toEqual(`${defaultParams}&label_name[]=bug`);
- done();
});
manager.search();
diff --git a/spec/frontend/filtered_search/services/recent_searches_service_spec.js b/spec/frontend/filtered_search/services/recent_searches_service_spec.js
index dfa53652eb1..426a60df427 100644
--- a/spec/frontend/filtered_search/services/recent_searches_service_spec.js
+++ b/spec/frontend/filtered_search/services/recent_searches_service_spec.js
@@ -18,53 +18,47 @@ describe('RecentSearchesService', () => {
jest.spyOn(RecentSearchesService, 'isAvailable').mockReturnValue(true);
});
- it('should default to empty array', (done) => {
+ it('should default to empty array', () => {
const fetchItemsPromise = service.fetch();
- fetchItemsPromise
- .then((items) => {
- expect(items).toEqual([]);
- })
- .then(done)
- .catch(done.fail);
+ return fetchItemsPromise.then((items) => {
+ expect(items).toEqual([]);
+ });
});
- it('should reject when unable to parse', (done) => {
+ it('should reject when unable to parse', () => {
jest.spyOn(localStorage, 'getItem').mockReturnValue('fail');
const fetchItemsPromise = service.fetch();
- fetchItemsPromise
- .then(done.fail)
+ return fetchItemsPromise
+ .then(() => {
+ throw new Error();
+ })
.catch((error) => {
expect(error).toEqual(expect.any(SyntaxError));
- })
- .then(done)
- .catch(done.fail);
+ });
});
- it('should reject when service is unavailable', (done) => {
+ it('should reject when service is unavailable', () => {
RecentSearchesService.isAvailable.mockReturnValue(false);
- service
+ return service
.fetch()
- .then(done.fail)
+ .then(() => {
+ throw new Error();
+ })
.catch((error) => {
expect(error).toEqual(expect.any(Error));
- })
- .then(done)
- .catch(done.fail);
+ });
});
- it('should return items from localStorage', (done) => {
+ it('should return items from localStorage', () => {
jest.spyOn(localStorage, 'getItem').mockReturnValue('["foo", "bar"]');
const fetchItemsPromise = service.fetch();
- fetchItemsPromise
- .then((items) => {
- expect(items).toEqual(['foo', 'bar']);
- })
- .then(done)
- .catch(done.fail);
+ return fetchItemsPromise.then((items) => {
+ expect(items).toEqual(['foo', 'bar']);
+ });
});
describe('if .isAvailable returns `false`', () => {
@@ -74,16 +68,16 @@ describe('RecentSearchesService', () => {
jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {});
});
- it('should not call .getItem', (done) => {
- RecentSearchesService.prototype
+ it('should not call .getItem', () => {
+ return RecentSearchesService.prototype
.fetch()
- .then(done.fail)
+ .then(() => {
+ throw new Error();
+ })
.catch((err) => {
expect(err).toEqual(new RecentSearchesServiceError());
expect(localStorage.getItem).not.toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
+ });
});
});
});
diff --git a/spec/frontend/filtered_search/visual_token_value_spec.js b/spec/frontend/filtered_search/visual_token_value_spec.js
index 8ac5b6fbea6..bf526a8d371 100644
--- a/spec/frontend/filtered_search/visual_token_value_spec.js
+++ b/spec/frontend/filtered_search/visual_token_value_spec.js
@@ -46,7 +46,7 @@ describe('Filtered Search Visual Tokens', () => {
jest.spyOn(UsersCache, 'retrieve').mockImplementation((username) => usersCacheSpy(username));
});
- it('ignores error if UsersCache throws', (done) => {
+ it('ignores error if UsersCache throws', async () => {
const dummyError = new Error('Earth rotated backwards');
const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken);
const tokenValue = tokenValueElement.innerText;
@@ -55,16 +55,11 @@ describe('Filtered Search Visual Tokens', () => {
return Promise.reject(dummyError);
};
- subject
- .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue)
- .then(() => {
- expect(createFlash.mock.calls.length).toBe(0);
- })
- .then(done)
- .catch(done.fail);
+ await subject.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue);
+ expect(createFlash.mock.calls.length).toBe(0);
});
- it('does nothing if user cannot be found', (done) => {
+ it('does nothing if user cannot be found', async () => {
const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken);
const tokenValue = tokenValueElement.innerText;
usersCacheSpy = (username) => {
@@ -72,16 +67,11 @@ describe('Filtered Search Visual Tokens', () => {
return Promise.resolve(undefined);
};
- subject
- .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue)
- .then(() => {
- expect(tokenValueElement.innerText).toBe(tokenValue);
- })
- .then(done)
- .catch(done.fail);
+ await subject.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue);
+ expect(tokenValueElement.innerText).toBe(tokenValue);
});
- it('replaces author token with avatar and display name', (done) => {
+ it('replaces author token with avatar and display name', async () => {
const dummyUser = {
name: 'Important Person',
avatar_url: 'https://host.invalid/mypics/avatar.png',
@@ -93,21 +83,16 @@ describe('Filtered Search Visual Tokens', () => {
return Promise.resolve(dummyUser);
};
- subject
- .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue)
- .then(() => {
- expect(tokenValueContainer.dataset.originalValue).toBe(tokenValue);
- expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name);
- const avatar = tokenValueElement.querySelector('img.avatar');
-
- expect(avatar.getAttribute('src')).toBe(dummyUser.avatar_url);
- expect(avatar.getAttribute('alt')).toBe('');
- })
- .then(done)
- .catch(done.fail);
+ await subject.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue);
+ expect(tokenValueContainer.dataset.originalValue).toBe(tokenValue);
+ expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name);
+ const avatar = tokenValueElement.querySelector('img.avatar');
+
+ expect(avatar.getAttribute('src')).toBe(dummyUser.avatar_url);
+ expect(avatar.getAttribute('alt')).toBe('');
});
- it('escapes user name when creating token', (done) => {
+ it('escapes user name when creating token', async () => {
const dummyUser = {
name: '<script>',
avatar_url: `${TEST_HOST}/mypics/avatar.png`,
@@ -119,16 +104,11 @@ describe('Filtered Search Visual Tokens', () => {
return Promise.resolve(dummyUser);
};
- subject
- .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue)
- .then(() => {
- expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name);
- tokenValueElement.querySelector('.avatar').remove();
+ await subject.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue);
+ expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name);
+ tokenValueElement.querySelector('.avatar').remove();
- expect(tokenValueElement.innerHTML.trim()).toBe(escape(dummyUser.name));
- })
- .then(done)
- .catch(done.fail);
+ expect(tokenValueElement.innerHTML.trim()).toBe(escape(dummyUser.name));
});
});
@@ -177,48 +157,33 @@ describe('Filtered Search Visual Tokens', () => {
const findLabel = (tokenValue) =>
labelData.find((label) => tokenValue === `~${DropdownUtils.getEscapedText(label.title)}`);
- it('updates the color of a label token', (done) => {
+ it('updates the color of a label token', async () => {
const { subject, tokenValueContainer, tokenValueElement } = findElements(bugLabelToken);
const tokenValue = tokenValueElement.innerText;
const matchingLabel = findLabel(tokenValue);
- subject
- .updateLabelTokenColor(tokenValueContainer, tokenValue)
- .then(() => {
- expectValueContainerStyle(tokenValueContainer, matchingLabel);
- })
- .then(done)
- .catch(done.fail);
+ await subject.updateLabelTokenColor(tokenValueContainer, tokenValue);
+ expectValueContainerStyle(tokenValueContainer, matchingLabel);
});
- it('updates the color of a label token with spaces', (done) => {
+ it('updates the color of a label token with spaces', async () => {
const { subject, tokenValueContainer, tokenValueElement } = findElements(spaceLabelToken);
const tokenValue = tokenValueElement.innerText;
const matchingLabel = findLabel(tokenValue);
- subject
- .updateLabelTokenColor(tokenValueContainer, tokenValue)
- .then(() => {
- expectValueContainerStyle(tokenValueContainer, matchingLabel);
- })
- .then(done)
- .catch(done.fail);
+ await subject.updateLabelTokenColor(tokenValueContainer, tokenValue);
+ expectValueContainerStyle(tokenValueContainer, matchingLabel);
});
- it('does not change color of a missing label', (done) => {
+ it('does not change color of a missing label', async () => {
const { subject, tokenValueContainer, tokenValueElement } = findElements(missingLabelToken);
const tokenValue = tokenValueElement.innerText;
const matchingLabel = findLabel(tokenValue);
expect(matchingLabel).toBe(undefined);
- subject
- .updateLabelTokenColor(tokenValueContainer, tokenValue)
- .then(() => {
- expect(tokenValueContainer.getAttribute('style')).toBe(null);
- })
- .then(done)
- .catch(done.fail);
+ await subject.updateLabelTokenColor(tokenValueContainer, tokenValue);
+ expect(tokenValueContainer.getAttribute('style')).toBe(null);
});
});
diff --git a/spec/frontend/fixtures/startup_css.rb b/spec/frontend/fixtures/startup_css.rb
index e19a98c3bab..cf7383fa6ca 100644
--- a/spec/frontend/fixtures/startup_css.rb
+++ b/spec/frontend/fixtures/startup_css.rb
@@ -41,12 +41,12 @@ RSpec.describe 'Startup CSS fixtures', type: :controller do
expect(response).to be_successful
end
- # This Feature Flag is off by default
+ # This Feature Flag is on by default
# This ensures that the correct css is generated
- # When the feature flag is off, the general startup will capture it
+ # When the feature flag is on, the general startup will capture it
# This will be removed as part of https://gitlab.com/gitlab-org/gitlab/-/issues/339348
- it "startup_css/project-#{type}-search-ff-on.html" do
- stub_feature_flags(new_header_search: true)
+ it "startup_css/project-#{type}-search-ff-off.html" do
+ stub_feature_flags(new_header_search: false)
get :show, params: {
namespace_id: project.namespace.to_param,
diff --git a/spec/frontend/frequent_items/store/actions_spec.js b/spec/frontend/frequent_items/store/actions_spec.js
index fb0321545c2..3fc3eaf52a2 100644
--- a/spec/frontend/frequent_items/store/actions_spec.js
+++ b/spec/frontend/frequent_items/store/actions_spec.js
@@ -29,136 +29,126 @@ describe('Frequent Items Dropdown Store Actions', () => {
});
describe('setNamespace', () => {
- it('should set namespace', (done) => {
- testAction(
+ it('should set namespace', () => {
+ return testAction(
actions.setNamespace,
mockNamespace,
mockedState,
[{ type: types.SET_NAMESPACE, payload: mockNamespace }],
[],
- done,
);
});
});
describe('setStorageKey', () => {
- it('should set storage key', (done) => {
- testAction(
+ it('should set storage key', () => {
+ return testAction(
actions.setStorageKey,
mockStorageKey,
mockedState,
[{ type: types.SET_STORAGE_KEY, payload: mockStorageKey }],
[],
- done,
);
});
});
describe('requestFrequentItems', () => {
- it('should request frequent items', (done) => {
- testAction(
+ it('should request frequent items', () => {
+ return testAction(
actions.requestFrequentItems,
null,
mockedState,
[{ type: types.REQUEST_FREQUENT_ITEMS }],
[],
- done,
);
});
});
describe('receiveFrequentItemsSuccess', () => {
- it('should set frequent items', (done) => {
- testAction(
+ it('should set frequent items', () => {
+ return testAction(
actions.receiveFrequentItemsSuccess,
mockFrequentProjects,
mockedState,
[{ type: types.RECEIVE_FREQUENT_ITEMS_SUCCESS, payload: mockFrequentProjects }],
[],
- done,
);
});
});
describe('receiveFrequentItemsError', () => {
- it('should set frequent items error state', (done) => {
- testAction(
+ it('should set frequent items error state', () => {
+ return testAction(
actions.receiveFrequentItemsError,
null,
mockedState,
[{ type: types.RECEIVE_FREQUENT_ITEMS_ERROR }],
[],
- done,
);
});
});
describe('fetchFrequentItems', () => {
- it('should dispatch `receiveFrequentItemsSuccess`', (done) => {
+ it('should dispatch `receiveFrequentItemsSuccess`', () => {
mockedState.namespace = mockNamespace;
mockedState.storageKey = mockStorageKey;
- testAction(
+ return testAction(
actions.fetchFrequentItems,
null,
mockedState,
[],
[{ type: 'requestFrequentItems' }, { type: 'receiveFrequentItemsSuccess', payload: [] }],
- done,
);
});
- it('should dispatch `receiveFrequentItemsError`', (done) => {
+ it('should dispatch `receiveFrequentItemsError`', () => {
jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(false);
mockedState.namespace = mockNamespace;
mockedState.storageKey = mockStorageKey;
- testAction(
+ return testAction(
actions.fetchFrequentItems,
null,
mockedState,
[],
[{ type: 'requestFrequentItems' }, { type: 'receiveFrequentItemsError' }],
- done,
);
});
});
describe('requestSearchedItems', () => {
- it('should request searched items', (done) => {
- testAction(
+ it('should request searched items', () => {
+ return testAction(
actions.requestSearchedItems,
null,
mockedState,
[{ type: types.REQUEST_SEARCHED_ITEMS }],
[],
- done,
);
});
});
describe('receiveSearchedItemsSuccess', () => {
- it('should set searched items', (done) => {
- testAction(
+ it('should set searched items', () => {
+ return testAction(
actions.receiveSearchedItemsSuccess,
mockSearchedProjects,
mockedState,
[{ type: types.RECEIVE_SEARCHED_ITEMS_SUCCESS, payload: mockSearchedProjects }],
[],
- done,
);
});
});
describe('receiveSearchedItemsError', () => {
- it('should set searched items error state', (done) => {
- testAction(
+ it('should set searched items error state', () => {
+ return testAction(
actions.receiveSearchedItemsError,
null,
mockedState,
[{ type: types.RECEIVE_SEARCHED_ITEMS_ERROR }],
[],
- done,
);
});
});
@@ -168,10 +158,10 @@ describe('Frequent Items Dropdown Store Actions', () => {
gon.api_version = 'v4';
});
- it('should dispatch `receiveSearchedItemsSuccess`', (done) => {
+ it('should dispatch `receiveSearchedItemsSuccess`', () => {
mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(200, mockSearchedProjects, {});
- testAction(
+ return testAction(
actions.fetchSearchedItems,
null,
mockedState,
@@ -183,45 +173,41 @@ describe('Frequent Items Dropdown Store Actions', () => {
payload: { data: mockSearchedProjects, headers: {} },
},
],
- done,
);
});
- it('should dispatch `receiveSearchedItemsError`', (done) => {
+ it('should dispatch `receiveSearchedItemsError`', () => {
gon.api_version = 'v4';
mock.onGet(/\/api\/v4\/projects.json(.*)$/).replyOnce(500);
- testAction(
+ return testAction(
actions.fetchSearchedItems,
null,
mockedState,
[],
[{ type: 'requestSearchedItems' }, { type: 'receiveSearchedItemsError' }],
- done,
);
});
});
describe('setSearchQuery', () => {
- it('should commit query and dispatch `fetchSearchedItems` when query is present', (done) => {
- testAction(
+ it('should commit query and dispatch `fetchSearchedItems` when query is present', () => {
+ return testAction(
actions.setSearchQuery,
{ query: 'test' },
mockedState,
[{ type: types.SET_SEARCH_QUERY, payload: { query: 'test' } }],
[{ type: 'fetchSearchedItems', payload: { query: 'test' } }],
- done,
);
});
- it('should commit query and dispatch `fetchFrequentItems` when query is empty', (done) => {
- testAction(
+ it('should commit query and dispatch `fetchFrequentItems` when query is empty', () => {
+ return testAction(
actions.setSearchQuery,
null,
mockedState,
[{ type: types.SET_SEARCH_QUERY, payload: null }],
[{ type: 'fetchFrequentItems' }],
- done,
);
});
});
diff --git a/spec/frontend/google_cloud/components/app_spec.js b/spec/frontend/google_cloud/components/app_spec.js
index 50b05fb30e0..0cafe6d3b9d 100644
--- a/spec/frontend/google_cloud/components/app_spec.js
+++ b/spec/frontend/google_cloud/components/app_spec.js
@@ -8,7 +8,7 @@ import GcpError from '~/google_cloud/components/errors/gcp_error.vue';
import NoGcpProjects from '~/google_cloud/components/errors/no_gcp_projects.vue';
const BASE_FEEDBACK_URL =
- 'https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/meta/-/issues/new';
+ 'https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/feedback/-/issues/new';
const SCREEN_COMPONENTS = {
Home,
ServiceAccountsForm,
diff --git a/spec/frontend/gpg_badges_spec.js b/spec/frontend/gpg_badges_spec.js
index 44c70f1ad4d..0bb50fc3e6f 100644
--- a/spec/frontend/gpg_badges_spec.js
+++ b/spec/frontend/gpg_badges_spec.js
@@ -40,30 +40,22 @@ describe('GpgBadges', () => {
mock.restore();
});
- it('does not make a request if there is no container element', (done) => {
+ it('does not make a request if there is no container element', async () => {
setFixtures('');
jest.spyOn(axios, 'get').mockImplementation(() => {});
- GpgBadges.fetch()
- .then(() => {
- expect(axios.get).not.toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
+ await GpgBadges.fetch();
+ expect(axios.get).not.toHaveBeenCalled();
});
- it('throws an error if the endpoint is missing', (done) => {
+ it('throws an error if the endpoint is missing', async () => {
setFixtures('<div class="js-signature-container"></div>');
jest.spyOn(axios, 'get').mockImplementation(() => {});
- GpgBadges.fetch()
- .then(() => done.fail('Expected error to be thrown'))
- .catch((error) => {
- expect(error.message).toBe('Missing commit signatures endpoint!');
- expect(axios.get).not.toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
+ await expect(GpgBadges.fetch()).rejects.toEqual(
+ new Error('Missing commit signatures endpoint!'),
+ );
+ expect(axios.get).not.toHaveBeenCalled();
});
it('fetches commit signatures', async () => {
@@ -104,31 +96,23 @@ describe('GpgBadges', () => {
});
});
- it('displays a loading spinner', (done) => {
+ it('displays a loading spinner', async () => {
mock.onGet(dummyUrl).replyOnce(200);
- GpgBadges.fetch()
- .then(() => {
- expect(document.querySelector('.js-loading-gpg-badge:empty')).toBe(null);
- const spinners = document.querySelectorAll('.js-loading-gpg-badge span.gl-spinner');
+ await GpgBadges.fetch();
+ expect(document.querySelector('.js-loading-gpg-badge:empty')).toBe(null);
+ const spinners = document.querySelectorAll('.js-loading-gpg-badge span.gl-spinner');
- expect(spinners.length).toBe(1);
- done();
- })
- .catch(done.fail);
+ expect(spinners.length).toBe(1);
});
- it('replaces the loading spinner', (done) => {
+ it('replaces the loading spinner', async () => {
mock.onGet(dummyUrl).replyOnce(200, dummyResponse);
- GpgBadges.fetch()
- .then(() => {
- expect(document.querySelector('.js-loading-gpg-badge')).toBe(null);
- const parentContainer = document.querySelector('.parent-container');
+ await GpgBadges.fetch();
+ expect(document.querySelector('.js-loading-gpg-badge')).toBe(null);
+ const parentContainer = document.querySelector('.parent-container');
- expect(parentContainer.innerHTML.trim()).toEqual(dummyBadgeHtml);
- done();
- })
- .catch(done.fail);
+ expect(parentContainer.innerHTML.trim()).toEqual(dummyBadgeHtml);
});
});
diff --git a/spec/frontend/groups/components/item_type_icon_spec.js b/spec/frontend/groups/components/item_type_icon_spec.js
index 9310943841e..f3652f1a410 100644
--- a/spec/frontend/groups/components/item_type_icon_spec.js
+++ b/spec/frontend/groups/components/item_type_icon_spec.js
@@ -8,7 +8,6 @@ describe('ItemTypeIcon', () => {
const defaultProps = {
itemType: ITEM_TYPE.GROUP,
- isGroupOpen: false,
};
const createComponent = (props = {}) => {
@@ -34,20 +33,14 @@ describe('ItemTypeIcon', () => {
});
it.each`
- type | isGroupOpen | icon
- ${ITEM_TYPE.GROUP} | ${true} | ${'folder-open'}
- ${ITEM_TYPE.GROUP} | ${false} | ${'folder-o'}
- ${ITEM_TYPE.PROJECT} | ${true} | ${'bookmark'}
- ${ITEM_TYPE.PROJECT} | ${false} | ${'bookmark'}
- `(
- 'shows "$icon" icon when `itemType` is "$type" and `isGroupOpen` is $isGroupOpen',
- ({ type, isGroupOpen, icon }) => {
- createComponent({
- itemType: type,
- isGroupOpen,
- });
- expect(findGlIcon().props('name')).toBe(icon);
- },
- );
+ type | icon
+ ${ITEM_TYPE.GROUP} | ${'subgroup'}
+ ${ITEM_TYPE.PROJECT} | ${'project'}
+ `('shows "$icon" icon when `itemType` is "$type"', ({ type, icon }) => {
+ createComponent({
+ itemType: type,
+ });
+ expect(findGlIcon().props('name')).toBe(icon);
+ });
});
});
diff --git a/spec/frontend/header_search/components/app_spec.js b/spec/frontend/header_search/components/app_spec.js
index dcbeeeffb2d..f0de5b083ae 100644
--- a/spec/frontend/header_search/components/app_spec.js
+++ b/spec/frontend/header_search/components/app_spec.js
@@ -16,6 +16,7 @@ import {
MOCK_USERNAME,
MOCK_DEFAULT_SEARCH_OPTIONS,
MOCK_SCOPED_SEARCH_OPTIONS,
+ MOCK_SORTED_AUTOCOMPLETE_OPTIONS,
} from '../mock_data';
Vue.use(Vuex);
@@ -108,6 +109,11 @@ describe('HeaderSearchApp', () => {
search | showDefault | showScoped | showAutocomplete | showDropdownNavigation
${null} | ${true} | ${false} | ${false} | ${true}
${''} | ${true} | ${false} | ${false} | ${true}
+ ${'1'} | ${false} | ${false} | ${false} | ${false}
+ ${')'} | ${false} | ${false} | ${false} | ${false}
+ ${'t'} | ${false} | ${false} | ${true} | ${true}
+ ${'te'} | ${false} | ${true} | ${true} | ${true}
+ ${'tes'} | ${false} | ${true} | ${true} | ${true}
${MOCK_SEARCH} | ${false} | ${true} | ${true} | ${true}
`(
'Header Search Dropdown Items',
@@ -115,7 +121,13 @@ describe('HeaderSearchApp', () => {
describe(`when search is ${search}`, () => {
beforeEach(() => {
window.gon.current_username = MOCK_USERNAME;
- createComponent({ search });
+ createComponent(
+ { search },
+ {
+ autocompleteGroupedSearchOptions: () =>
+ search.match(/^[A-Za-z]+$/g) ? MOCK_SORTED_AUTOCOMPLETE_OPTIONS : [],
+ },
+ );
findHeaderSearchInput().vm.$emit('click');
});
diff --git a/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js b/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js
index f427482be46..7952661e2d2 100644
--- a/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js
+++ b/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js
@@ -1,4 +1,4 @@
-import { GlDropdownItem, GlLoadingIcon, GlAvatar, GlAlert } from '@gitlab/ui';
+import { GlDropdownItem, GlLoadingIcon, GlAvatar, GlAlert, GlDropdownDivider } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
@@ -8,8 +8,18 @@ import {
LARGE_AVATAR_PX,
PROJECTS_CATEGORY,
SMALL_AVATAR_PX,
+ ISSUES_CATEGORY,
+ MERGE_REQUEST_CATEGORY,
+ RECENT_EPICS_CATEGORY,
} from '~/header_search/constants';
-import { MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, MOCK_SORTED_AUTOCOMPLETE_OPTIONS } from '../mock_data';
+import {
+ MOCK_GROUPED_AUTOCOMPLETE_OPTIONS,
+ MOCK_SORTED_AUTOCOMPLETE_OPTIONS,
+ MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_SETTINGS_HELP,
+ MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_HELP,
+ MOCK_SEARCH,
+ MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_2,
+} from '../mock_data';
Vue.use(Vuex);
@@ -41,8 +51,14 @@ describe('HeaderSearchAutocompleteItems', () => {
});
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
+ const findGlDropdownDividers = () => wrapper.findAllComponents(GlDropdownDivider);
const findFirstDropdownItem = () => findDropdownItems().at(0);
- const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => w.text());
+ const findDropdownItemTitles = () =>
+ findDropdownItems().wrappers.map((w) => w.findAll('span').at(1).text());
+ const findDropdownItemSubTitles = () =>
+ findDropdownItems()
+ .wrappers.filter((w) => w.findAll('span').length > 2)
+ .map((w) => w.findAll('span').at(2).text());
const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href'));
const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findGlAvatar = () => wrapper.findComponent(GlAvatar);
@@ -87,10 +103,17 @@ describe('HeaderSearchAutocompleteItems', () => {
});
it('renders titles correctly', () => {
- const expectedTitles = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.map((o) => o.label);
+ const expectedTitles = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.map((o) => o.value || o.label);
expect(findDropdownItemTitles()).toStrictEqual(expectedTitles);
});
+ it('renders sub-titles correctly', () => {
+ const expectedSubTitles = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.filter((o) => o.value).map(
+ (o) => o.label,
+ );
+ expect(findDropdownItemSubTitles()).toStrictEqual(expectedSubTitles);
+ });
+
it('renders links correctly', () => {
const expectedLinks = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.map((o) => o.url);
expect(findDropdownItemLinks()).toStrictEqual(expectedLinks);
@@ -98,15 +121,30 @@ describe('HeaderSearchAutocompleteItems', () => {
});
describe.each`
- item | showAvatar | avatarSize
- ${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(LARGE_AVATAR_PX)}
- ${{ data: [{ category: GROUPS_CATEGORY, avatar_url: '/123' }] }} | ${true} | ${String(LARGE_AVATAR_PX)}
- ${{ data: [{ category: 'Help', avatar_url: '' }] }} | ${true} | ${String(SMALL_AVATAR_PX)}
- ${{ data: [{ category: 'Settings' }] }} | ${false} | ${false}
- `('GlAvatar', ({ item, showAvatar, avatarSize }) => {
+ item | showAvatar | avatarSize | searchContext | entityId | entityName
+ ${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ project: { id: 29 } }} | ${'29'} | ${''}
+ ${{ data: [{ category: GROUPS_CATEGORY, avatar_url: '/123' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ group: { id: 12 } }} | ${'12'} | ${''}
+ ${{ data: [{ category: 'Help', avatar_url: '' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${null} | ${'0'} | ${''}
+ ${{ data: [{ category: 'Settings' }] }} | ${false} | ${false} | ${null} | ${false} | ${false}
+ ${{ data: [{ category: GROUPS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ group: { id: 1, name: 'test1' } }} | ${'1'} | ${'test1'}
+ ${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ project: { id: 2, name: 'test2' } }} | ${'2'} | ${'test2'}
+ ${{ data: [{ category: ISSUES_CATEGORY, avatar_url: null }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ project: { id: 3, name: 'test3' } }} | ${'3'} | ${'test3'}
+ ${{ data: [{ category: MERGE_REQUEST_CATEGORY, avatar_url: null }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ project: { id: 4, name: 'test4' } }} | ${'4'} | ${'test4'}
+ ${{ data: [{ category: RECENT_EPICS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ group: { id: 5, name: 'test5' } }} | ${'5'} | ${'test5'}
+ ${{ data: [{ category: GROUPS_CATEGORY, avatar_url: null, group_id: 6, group_name: 'test6' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${null} | ${'6'} | ${'test6'}
+ ${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null, project_id: 7, project_name: 'test7' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${null} | ${'7'} | ${'test7'}
+ ${{ data: [{ category: ISSUES_CATEGORY, avatar_url: null, project_id: 8, project_name: 'test8' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${null} | ${'8'} | ${'test8'}
+ ${{ data: [{ category: MERGE_REQUEST_CATEGORY, avatar_url: null, project_id: 9, project_name: 'test9' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${null} | ${'9'} | ${'test9'}
+ ${{ data: [{ category: RECENT_EPICS_CATEGORY, avatar_url: null, group_id: 10, group_name: 'test10' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${null} | ${'10'} | ${'test10'}
+ ${{ data: [{ category: GROUPS_CATEGORY, avatar_url: null, group_id: 11, group_name: 'test11' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ group: { id: 1, name: 'test1' } }} | ${'11'} | ${'test11'}
+ ${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null, project_id: 12, project_name: 'test12' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ project: { id: 2, name: 'test2' } }} | ${'12'} | ${'test12'}
+ ${{ data: [{ category: ISSUES_CATEGORY, avatar_url: null, project_id: 13, project_name: 'test13' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ project: { id: 3, name: 'test3' } }} | ${'13'} | ${'test13'}
+ ${{ data: [{ category: MERGE_REQUEST_CATEGORY, avatar_url: null, project_id: 14, project_name: 'test14' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ project: { id: 4, name: 'test4' } }} | ${'14'} | ${'test14'}
+ ${{ data: [{ category: RECENT_EPICS_CATEGORY, avatar_url: null, group_id: 15, group_name: 'test15' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ group: { id: 5, name: 'test5' } }} | ${'15'} | ${'test15'}
+ `('GlAvatar', ({ item, showAvatar, avatarSize, searchContext, entityId, entityName }) => {
describe(`when category is ${item.data[0].category} and avatar_url is ${item.data[0].avatar_url}`, () => {
beforeEach(() => {
- createComponent({}, { autocompleteGroupedSearchOptions: () => [item] });
+ createComponent({ searchContext }, { autocompleteGroupedSearchOptions: () => [item] });
});
it(`should${showAvatar ? '' : ' not'} render`, () => {
@@ -116,6 +154,16 @@ describe('HeaderSearchAutocompleteItems', () => {
it(`should set avatarSize to ${avatarSize}`, () => {
expect(findGlAvatar().exists() && findGlAvatar().attributes('size')).toBe(avatarSize);
});
+
+ it(`should set avatar entityId to ${entityId}`, () => {
+ expect(findGlAvatar().exists() && findGlAvatar().attributes('entityid')).toBe(entityId);
+ });
+
+ it(`should set avatar entityName to ${entityName}`, () => {
+ expect(findGlAvatar().exists() && findGlAvatar().attributes('entityname')).toBe(
+ entityName,
+ );
+ });
});
});
});
@@ -140,6 +188,34 @@ describe('HeaderSearchAutocompleteItems', () => {
});
});
});
+
+ describe.each`
+ search | items | dividerCount
+ ${null} | ${[]} | ${0}
+ ${''} | ${[]} | ${0}
+ ${'1'} | ${[]} | ${0}
+ ${')'} | ${[]} | ${0}
+ ${'t'} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_SETTINGS_HELP} | ${1}
+ ${'te'} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_HELP} | ${0}
+ ${'tes'} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_2} | ${1}
+ ${MOCK_SEARCH} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_2} | ${1}
+ `('Header Search Dropdown Dividers', ({ search, items, dividerCount }) => {
+ describe(`when search is ${search}`, () => {
+ beforeEach(() => {
+ createComponent(
+ { search },
+ {
+ autocompleteGroupedSearchOptions: () => items,
+ },
+ {},
+ );
+ });
+
+ it(`component should have ${dividerCount} dividers`, () => {
+ expect(findGlDropdownDividers()).toHaveLength(dividerCount);
+ });
+ });
+ });
});
describe('watchers', () => {
diff --git a/spec/frontend/header_search/components/header_search_scoped_items_spec.js b/spec/frontend/header_search/components/header_search_scoped_items_spec.js
index a65b4d8b813..8788fb23458 100644
--- a/spec/frontend/header_search/components/header_search_scoped_items_spec.js
+++ b/spec/frontend/header_search/components/header_search_scoped_items_spec.js
@@ -1,17 +1,21 @@
-import { GlDropdownItem } from '@gitlab/ui';
+import { GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import { trimText } from 'helpers/text_helper';
import HeaderSearchScopedItems from '~/header_search/components/header_search_scoped_items.vue';
-import { MOCK_SEARCH, MOCK_SCOPED_SEARCH_OPTIONS } from '../mock_data';
+import {
+ MOCK_SEARCH,
+ MOCK_SCOPED_SEARCH_OPTIONS,
+ MOCK_GROUPED_AUTOCOMPLETE_OPTIONS,
+} from '../mock_data';
Vue.use(Vuex);
describe('HeaderSearchScopedItems', () => {
let wrapper;
- const createComponent = (initialState, props) => {
+ const createComponent = (initialState, mockGetters, props) => {
const store = new Vuex.Store({
state: {
search: MOCK_SEARCH,
@@ -19,6 +23,8 @@ describe('HeaderSearchScopedItems', () => {
},
getters: {
scopedSearchOptions: () => MOCK_SCOPED_SEARCH_OPTIONS,
+ autocompleteGroupedSearchOptions: () => MOCK_GROUPED_AUTOCOMPLETE_OPTIONS,
+ ...mockGetters,
},
});
@@ -35,6 +41,7 @@ describe('HeaderSearchScopedItems', () => {
});
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
+ const findGlDropdownDivider = () => wrapper.findComponent(GlDropdownDivider);
const findFirstDropdownItem = () => findDropdownItems().at(0);
const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => trimText(w.text()));
const findDropdownItemAriaLabels = () =>
@@ -79,7 +86,7 @@ describe('HeaderSearchScopedItems', () => {
`('isOptionFocused', ({ currentFocusedOption, isFocused, ariaSelected }) => {
describe(`when currentFocusedOption.html_id is ${currentFocusedOption?.html_id}`, () => {
beforeEach(() => {
- createComponent({}, { currentFocusedOption });
+ createComponent({}, {}, { currentFocusedOption });
});
it(`should${isFocused ? '' : ' not'} have gl-bg-gray-50 applied`, () => {
@@ -91,5 +98,21 @@ describe('HeaderSearchScopedItems', () => {
});
});
});
+
+ describe.each`
+ autosuggestResults | showDivider
+ ${[]} | ${false}
+ ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${true}
+ `('scoped search items', ({ autosuggestResults, showDivider }) => {
+ describe(`when when we have ${autosuggestResults.length} auto-sugest results`, () => {
+ beforeEach(() => {
+ createComponent({}, { autocompleteGroupedSearchOptions: () => autosuggestResults }, {});
+ });
+
+ it(`divider should${showDivider ? '' : ' not'} be shown`, () => {
+ expect(findGlDropdownDivider().exists()).toBe(showDivider);
+ });
+ });
+ });
});
});
diff --git a/spec/frontend/header_search/mock_data.js b/spec/frontend/header_search/mock_data.js
index 1d980679547..b6f0fdcc29d 100644
--- a/spec/frontend/header_search/mock_data.js
+++ b/spec/frontend/header_search/mock_data.js
@@ -96,19 +96,22 @@ export const MOCK_AUTOCOMPLETE_OPTIONS_RES = [
{
category: 'Projects',
id: 1,
- label: 'MockProject1',
+ label: 'Gitlab Org / MockProject1',
+ value: 'MockProject1',
url: 'project/1',
},
{
category: 'Groups',
id: 1,
- label: 'MockGroup1',
+ label: 'Gitlab Org / MockGroup1',
+ value: 'MockGroup1',
url: 'group/1',
},
{
category: 'Projects',
id: 2,
- label: 'MockProject2',
+ label: 'Gitlab Org / MockProject2',
+ value: 'MockProject2',
url: 'project/2',
},
{
@@ -123,21 +126,24 @@ export const MOCK_AUTOCOMPLETE_OPTIONS = [
category: 'Projects',
html_id: 'autocomplete-Projects-0',
id: 1,
- label: 'MockProject1',
+ label: 'Gitlab Org / MockProject1',
+ value: 'MockProject1',
url: 'project/1',
},
{
category: 'Groups',
html_id: 'autocomplete-Groups-1',
id: 1,
- label: 'MockGroup1',
+ label: 'Gitlab Org / MockGroup1',
+ value: 'MockGroup1',
url: 'group/1',
},
{
category: 'Projects',
html_id: 'autocomplete-Projects-2',
id: 2,
- label: 'MockProject2',
+ label: 'Gitlab Org / MockProject2',
+ value: 'MockProject2',
url: 'project/2',
},
{
@@ -157,7 +163,8 @@ export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS = [
html_id: 'autocomplete-Projects-0',
id: 1,
- label: 'MockProject1',
+ label: 'Gitlab Org / MockProject1',
+ value: 'MockProject1',
url: 'project/1',
},
{
@@ -165,7 +172,8 @@ export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS = [
html_id: 'autocomplete-Projects-2',
id: 2,
- label: 'MockProject2',
+ label: 'Gitlab Org / MockProject2',
+ value: 'MockProject2',
url: 'project/2',
},
],
@@ -178,7 +186,8 @@ export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS = [
html_id: 'autocomplete-Groups-1',
id: 1,
- label: 'MockGroup1',
+ label: 'Gitlab Org / MockGroup1',
+ value: 'MockGroup1',
url: 'group/1',
},
],
@@ -202,21 +211,24 @@ export const MOCK_SORTED_AUTOCOMPLETE_OPTIONS = [
category: 'Projects',
html_id: 'autocomplete-Projects-0',
id: 1,
- label: 'MockProject1',
+ label: 'Gitlab Org / MockProject1',
+ value: 'MockProject1',
url: 'project/1',
},
{
category: 'Projects',
html_id: 'autocomplete-Projects-2',
id: 2,
- label: 'MockProject2',
+ label: 'Gitlab Org / MockProject2',
+ value: 'MockProject2',
url: 'project/2',
},
{
category: 'Groups',
html_id: 'autocomplete-Groups-1',
id: 1,
- label: 'MockGroup1',
+ label: 'Gitlab Org / MockGroup1',
+ value: 'MockGroup1',
url: 'group/1',
},
{
@@ -226,3 +238,98 @@ export const MOCK_SORTED_AUTOCOMPLETE_OPTIONS = [
url: 'help/gitlab',
},
];
+
+export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_HELP = [
+ {
+ category: 'Help',
+ data: [
+ {
+ html_id: 'autocomplete-Help-1',
+ category: 'Help',
+ label: 'Rake Tasks Help',
+ url: '/help/raketasks/index',
+ },
+ {
+ html_id: 'autocomplete-Help-2',
+ category: 'Help',
+ label: 'System Hooks Help',
+ url: '/help/system_hooks/system_hooks',
+ },
+ ],
+ },
+];
+
+export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_SETTINGS_HELP = [
+ {
+ category: 'Settings',
+ data: [
+ {
+ html_id: 'autocomplete-Settings-0',
+ category: 'Settings',
+ label: 'User settings',
+ url: '/-/profile',
+ },
+ {
+ html_id: 'autocomplete-Settings-3',
+ category: 'Settings',
+ label: 'Admin Section',
+ url: '/admin',
+ },
+ ],
+ },
+ {
+ category: 'Help',
+ data: [
+ {
+ html_id: 'autocomplete-Help-1',
+ category: 'Help',
+ label: 'Rake Tasks Help',
+ url: '/help/raketasks/index',
+ },
+ {
+ html_id: 'autocomplete-Help-2',
+ category: 'Help',
+ label: 'System Hooks Help',
+ url: '/help/system_hooks/system_hooks',
+ },
+ ],
+ },
+];
+
+export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_2 = [
+ {
+ category: 'Groups',
+ data: [
+ {
+ html_id: 'autocomplete-Groups-0',
+ category: 'Groups',
+ id: 148,
+ label: 'Jashkenas / Test Subgroup / test-subgroup',
+ url: '/jashkenas/test-subgroup/test-subgroup',
+ avatar_url: '',
+ },
+ {
+ html_id: 'autocomplete-Groups-1',
+ category: 'Groups',
+ id: 147,
+ label: 'Jashkenas / Test Subgroup',
+ url: '/jashkenas/test-subgroup',
+ avatar_url: '',
+ },
+ ],
+ },
+ {
+ category: 'Projects',
+ data: [
+ {
+ html_id: 'autocomplete-Projects-2',
+ category: 'Projects',
+ id: 1,
+ value: 'Gitlab Test',
+ label: 'Gitlab Org / Gitlab Test',
+ url: '/gitlab-org/gitlab-test',
+ avatar_url: '/uploads/-/system/project/avatar/1/icons8-gitlab-512.png',
+ },
+ ],
+ },
+];
diff --git a/spec/frontend/header_spec.js b/spec/frontend/header_spec.js
index 0d43accb7e5..937bc9aa478 100644
--- a/spec/frontend/header_spec.js
+++ b/spec/frontend/header_spec.js
@@ -60,7 +60,6 @@ describe('Header', () => {
setFixtures(`
<li class="js-nav-user-dropdown">
<a class="js-buy-pipeline-minutes-link" data-track-action="click_buy_ci_minutes" data-track-label="free" data-track-property="user_dropdown">Buy Pipeline minutes</a>
- <a class="js-upgrade-plan-link" data-track-action="click_upgrade_link" data-track-label="free" data-track-property="user_dropdown">Upgrade</a>
</li>`);
trackingSpy = mockTracking('_category_', $('.js-nav-user-dropdown').element, jest.spyOn);
@@ -81,14 +80,5 @@ describe('Header', () => {
property: 'user_dropdown',
});
});
-
- it('sends a tracking event when the dropdown is opened and contains Upgrade link', () => {
- $('.js-nav-user-dropdown').trigger('shown.bs.dropdown');
-
- expect(trackingSpy).toHaveBeenCalledWith('some:page', 'show_upgrade_link', {
- label: 'free',
- property: 'user_dropdown',
- });
- });
});
});
diff --git a/spec/frontend/ide/components/commit_sidebar/form_spec.js b/spec/frontend/ide/components/commit_sidebar/form_spec.js
index d3b2923ac6c..28f62a9775a 100644
--- a/spec/frontend/ide/components/commit_sidebar/form_spec.js
+++ b/spec/frontend/ide/components/commit_sidebar/form_spec.js
@@ -120,7 +120,7 @@ describe('IDE commit form', () => {
it('renders commit button in compact mode', () => {
expect(findBeginCommitButton().exists()).toBe(true);
- expect(findBeginCommitButton().text()).toBe('Commit…');
+ expect(findBeginCommitButton().text()).toBe('Create commit...');
});
it('does not render form', () => {
diff --git a/spec/frontend/ide/components/ide_side_bar_spec.js b/spec/frontend/ide/components/ide_side_bar_spec.js
index 34f14ef23a4..ace8988b8c9 100644
--- a/spec/frontend/ide/components/ide_side_bar_spec.js
+++ b/spec/frontend/ide/components/ide_side_bar_spec.js
@@ -1,4 +1,4 @@
-import { GlSkeletonLoading } from '@gitlab/ui';
+import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
diff --git a/spec/frontend/ide/components/new_dropdown/upload_spec.js b/spec/frontend/ide/components/new_dropdown/upload_spec.js
index 7303f81aad0..5a7419d6dce 100644
--- a/spec/frontend/ide/components/new_dropdown/upload_spec.js
+++ b/spec/frontend/ide/components/new_dropdown/upload_spec.js
@@ -69,25 +69,21 @@ describe('new dropdown upload', () => {
jest.spyOn(FileReader.prototype, 'readAsText');
});
- it('calls readAsText and creates file in plain text (without encoding) if the file content is plain text', (done) => {
+ it('calls readAsText and creates file in plain text (without encoding) if the file content is plain text', async () => {
const waitForCreate = new Promise((resolve) => vm.$on('create', resolve));
vm.createFile(textTarget, textFile);
expect(FileReader.prototype.readAsText).toHaveBeenCalledWith(textFile);
- waitForCreate
- .then(() => {
- expect(vm.$emit).toHaveBeenCalledWith('create', {
- name: textFile.name,
- type: 'blob',
- content: 'plain text',
- rawPath: '',
- mimeType: 'test/mime-text',
- });
- })
- .then(done)
- .catch(done.fail);
+ await waitForCreate;
+ expect(vm.$emit).toHaveBeenCalledWith('create', {
+ name: textFile.name,
+ type: 'blob',
+ content: 'plain text',
+ rawPath: '',
+ mimeType: 'test/mime-text',
+ });
});
it('creates a blob URL for the content if binary', () => {
diff --git a/spec/frontend/ide/stores/actions/merge_request_spec.js b/spec/frontend/ide/stores/actions/merge_request_spec.js
index e62811a4517..5592e2664c4 100644
--- a/spec/frontend/ide/stores/actions/merge_request_spec.js
+++ b/spec/frontend/ide/stores/actions/merge_request_spec.js
@@ -63,56 +63,47 @@ describe('IDE store merge request actions', () => {
mock.onGet(/api\/(.*)\/projects\/abcproject\/merge_requests/).reply(200, mockData);
});
- it('calls getProjectMergeRequests service method', (done) => {
- store
- .dispatch('getMergeRequestsForBranch', { projectId: TEST_PROJECT, branchId: 'bar' })
- .then(() => {
- expect(service.getProjectMergeRequests).toHaveBeenCalledWith(TEST_PROJECT, {
- source_branch: 'bar',
- source_project_id: TEST_PROJECT_ID,
- state: 'opened',
- order_by: 'created_at',
- per_page: 1,
- });
-
- done();
- })
- .catch(done.fail);
+ it('calls getProjectMergeRequests service method', async () => {
+ await store.dispatch('getMergeRequestsForBranch', {
+ projectId: TEST_PROJECT,
+ branchId: 'bar',
+ });
+ expect(service.getProjectMergeRequests).toHaveBeenCalledWith(TEST_PROJECT, {
+ source_branch: 'bar',
+ source_project_id: TEST_PROJECT_ID,
+ state: 'opened',
+ order_by: 'created_at',
+ per_page: 1,
+ });
});
- it('sets the "Merge Request" Object', (done) => {
- store
- .dispatch('getMergeRequestsForBranch', { projectId: TEST_PROJECT, branchId: 'bar' })
- .then(() => {
- expect(store.state.projects.abcproject.mergeRequests).toEqual({
- 2: expect.objectContaining(mrData),
- });
- done();
- })
- .catch(done.fail);
+ it('sets the "Merge Request" Object', async () => {
+ await store.dispatch('getMergeRequestsForBranch', {
+ projectId: TEST_PROJECT,
+ branchId: 'bar',
+ });
+ expect(store.state.projects.abcproject.mergeRequests).toEqual({
+ 2: expect.objectContaining(mrData),
+ });
});
- it('sets "Current Merge Request" object to the most recent MR', (done) => {
- store
- .dispatch('getMergeRequestsForBranch', { projectId: TEST_PROJECT, branchId: 'bar' })
- .then(() => {
- expect(store.state.currentMergeRequestId).toEqual('2');
- done();
- })
- .catch(done.fail);
+ it('sets "Current Merge Request" object to the most recent MR', async () => {
+ await store.dispatch('getMergeRequestsForBranch', {
+ projectId: TEST_PROJECT,
+ branchId: 'bar',
+ });
+ expect(store.state.currentMergeRequestId).toEqual('2');
});
- it('does nothing if user cannot read MRs', (done) => {
+ it('does nothing if user cannot read MRs', async () => {
store.state.projects[TEST_PROJECT].userPermissions[PERMISSION_READ_MR] = false;
- store
- .dispatch('getMergeRequestsForBranch', { projectId: TEST_PROJECT, branchId: 'bar' })
- .then(() => {
- expect(service.getProjectMergeRequests).not.toHaveBeenCalled();
- expect(store.state.currentMergeRequestId).toBe('');
- })
- .then(done)
- .catch(done.fail);
+ await store.dispatch('getMergeRequestsForBranch', {
+ projectId: TEST_PROJECT,
+ branchId: 'bar',
+ });
+ expect(service.getProjectMergeRequests).not.toHaveBeenCalled();
+ expect(store.state.currentMergeRequestId).toBe('');
});
});
@@ -122,15 +113,13 @@ describe('IDE store merge request actions', () => {
mock.onGet(/api\/(.*)\/projects\/abcproject\/merge_requests/).reply(200, []);
});
- it('does not fail if there are no merge requests for current branch', (done) => {
- store
- .dispatch('getMergeRequestsForBranch', { projectId: TEST_PROJECT, branchId: 'foo' })
- .then(() => {
- expect(store.state.projects[TEST_PROJECT].mergeRequests).toEqual({});
- expect(store.state.currentMergeRequestId).toEqual('');
- done();
- })
- .catch(done.fail);
+ it('does not fail if there are no merge requests for current branch', async () => {
+ await store.dispatch('getMergeRequestsForBranch', {
+ projectId: TEST_PROJECT,
+ branchId: 'foo',
+ });
+ expect(store.state.projects[TEST_PROJECT].mergeRequests).toEqual({});
+ expect(store.state.currentMergeRequestId).toEqual('');
});
});
});
@@ -140,17 +129,18 @@ describe('IDE store merge request actions', () => {
mock.onGet(/api\/(.*)\/projects\/abcproject\/merge_requests/).networkError();
});
- it('flashes message, if error', (done) => {
- store
- .dispatch('getMergeRequestsForBranch', { projectId: TEST_PROJECT, branchId: 'bar' })
+ it('flashes message, if error', () => {
+ return store
+ .dispatch('getMergeRequestsForBranch', {
+ projectId: TEST_PROJECT,
+ branchId: 'bar',
+ })
.catch(() => {
expect(createFlash).toHaveBeenCalled();
expect(createFlash.mock.calls[0][0].message).toBe(
'Error fetching merge requests for bar',
);
- })
- .then(done)
- .catch(done.fail);
+ });
});
});
});
@@ -165,29 +155,15 @@ describe('IDE store merge request actions', () => {
.reply(200, { title: 'mergerequest' });
});
- it('calls getProjectMergeRequestData service method', (done) => {
- store
- .dispatch('getMergeRequestData', { projectId: TEST_PROJECT, mergeRequestId: 1 })
- .then(() => {
- expect(service.getProjectMergeRequestData).toHaveBeenCalledWith(TEST_PROJECT, 1);
-
- done();
- })
- .catch(done.fail);
+ it('calls getProjectMergeRequestData service method', async () => {
+ await store.dispatch('getMergeRequestData', { projectId: TEST_PROJECT, mergeRequestId: 1 });
+ expect(service.getProjectMergeRequestData).toHaveBeenCalledWith(TEST_PROJECT, 1);
});
- it('sets the Merge Request Object', (done) => {
- store
- .dispatch('getMergeRequestData', { projectId: TEST_PROJECT, mergeRequestId: 1 })
- .then(() => {
- expect(store.state.currentMergeRequestId).toBe(1);
- expect(store.state.projects[TEST_PROJECT].mergeRequests['1'].title).toBe(
- 'mergerequest',
- );
-
- done();
- })
- .catch(done.fail);
+ it('sets the Merge Request Object', async () => {
+ await store.dispatch('getMergeRequestData', { projectId: TEST_PROJECT, mergeRequestId: 1 });
+ expect(store.state.currentMergeRequestId).toBe(1);
+ expect(store.state.projects[TEST_PROJECT].mergeRequests['1'].title).toBe('mergerequest');
});
});
@@ -196,32 +172,28 @@ describe('IDE store merge request actions', () => {
mock.onGet(/api\/(.*)\/projects\/abcproject\/merge_requests\/1/).networkError();
});
- it('dispatches error action', (done) => {
+ it('dispatches error action', () => {
const dispatch = jest.fn();
- getMergeRequestData(
+ return getMergeRequestData(
{
commit() {},
dispatch,
state: store.state,
},
{ projectId: TEST_PROJECT, mergeRequestId: 1 },
- )
- .then(done.fail)
- .catch(() => {
- expect(dispatch).toHaveBeenCalledWith('setErrorMessage', {
- text: 'An error occurred while loading the merge request.',
- action: expect.any(Function),
- actionText: 'Please try again',
- actionPayload: {
- projectId: TEST_PROJECT,
- mergeRequestId: 1,
- force: false,
- },
- });
-
- done();
+ ).catch(() => {
+ expect(dispatch).toHaveBeenCalledWith('setErrorMessage', {
+ text: 'An error occurred while loading the merge request.',
+ action: expect.any(Function),
+ actionText: 'Please try again',
+ actionPayload: {
+ projectId: TEST_PROJECT,
+ mergeRequestId: 1,
+ force: false,
+ },
});
+ });
});
});
});
@@ -240,27 +212,22 @@ describe('IDE store merge request actions', () => {
.reply(200, { title: 'mergerequest' });
});
- it('calls getProjectMergeRequestChanges service method', (done) => {
- store
- .dispatch('getMergeRequestChanges', { projectId: TEST_PROJECT, mergeRequestId: 1 })
- .then(() => {
- expect(service.getProjectMergeRequestChanges).toHaveBeenCalledWith(TEST_PROJECT, 1);
-
- done();
- })
- .catch(done.fail);
+ it('calls getProjectMergeRequestChanges service method', async () => {
+ await store.dispatch('getMergeRequestChanges', {
+ projectId: TEST_PROJECT,
+ mergeRequestId: 1,
+ });
+ expect(service.getProjectMergeRequestChanges).toHaveBeenCalledWith(TEST_PROJECT, 1);
});
- it('sets the Merge Request Changes Object', (done) => {
- store
- .dispatch('getMergeRequestChanges', { projectId: TEST_PROJECT, mergeRequestId: 1 })
- .then(() => {
- expect(store.state.projects[TEST_PROJECT].mergeRequests['1'].changes.title).toBe(
- 'mergerequest',
- );
- done();
- })
- .catch(done.fail);
+ it('sets the Merge Request Changes Object', async () => {
+ await store.dispatch('getMergeRequestChanges', {
+ projectId: TEST_PROJECT,
+ mergeRequestId: 1,
+ });
+ expect(store.state.projects[TEST_PROJECT].mergeRequests['1'].changes.title).toBe(
+ 'mergerequest',
+ );
});
});
@@ -269,32 +236,30 @@ describe('IDE store merge request actions', () => {
mock.onGet(/api\/(.*)\/projects\/abcproject\/merge_requests\/1\/changes/).networkError();
});
- it('dispatches error action', (done) => {
+ it('dispatches error action', async () => {
const dispatch = jest.fn();
- getMergeRequestChanges(
- {
- commit() {},
- dispatch,
- state: store.state,
+ await expect(
+ getMergeRequestChanges(
+ {
+ commit() {},
+ dispatch,
+ state: store.state,
+ },
+ { projectId: TEST_PROJECT, mergeRequestId: 1 },
+ ),
+ ).rejects.toEqual(new Error('Merge request changes not loaded abcproject'));
+
+ expect(dispatch).toHaveBeenCalledWith('setErrorMessage', {
+ text: 'An error occurred while loading the merge request changes.',
+ action: expect.any(Function),
+ actionText: 'Please try again',
+ actionPayload: {
+ projectId: TEST_PROJECT,
+ mergeRequestId: 1,
+ force: false,
},
- { projectId: TEST_PROJECT, mergeRequestId: 1 },
- )
- .then(done.fail)
- .catch(() => {
- expect(dispatch).toHaveBeenCalledWith('setErrorMessage', {
- text: 'An error occurred while loading the merge request changes.',
- action: expect.any(Function),
- actionText: 'Please try again',
- actionPayload: {
- projectId: TEST_PROJECT,
- mergeRequestId: 1,
- force: false,
- },
- });
-
- done();
- });
+ });
});
});
});
@@ -312,25 +277,20 @@ describe('IDE store merge request actions', () => {
jest.spyOn(service, 'getProjectMergeRequestVersions');
});
- it('calls getProjectMergeRequestVersions service method', (done) => {
- store
- .dispatch('getMergeRequestVersions', { projectId: TEST_PROJECT, mergeRequestId: 1 })
- .then(() => {
- expect(service.getProjectMergeRequestVersions).toHaveBeenCalledWith(TEST_PROJECT, 1);
-
- done();
- })
- .catch(done.fail);
+ it('calls getProjectMergeRequestVersions service method', async () => {
+ await store.dispatch('getMergeRequestVersions', {
+ projectId: TEST_PROJECT,
+ mergeRequestId: 1,
+ });
+ expect(service.getProjectMergeRequestVersions).toHaveBeenCalledWith(TEST_PROJECT, 1);
});
- it('sets the Merge Request Versions Object', (done) => {
- store
- .dispatch('getMergeRequestVersions', { projectId: TEST_PROJECT, mergeRequestId: 1 })
- .then(() => {
- expect(store.state.projects[TEST_PROJECT].mergeRequests['1'].versions.length).toBe(1);
- done();
- })
- .catch(done.fail);
+ it('sets the Merge Request Versions Object', async () => {
+ await store.dispatch('getMergeRequestVersions', {
+ projectId: TEST_PROJECT,
+ mergeRequestId: 1,
+ });
+ expect(store.state.projects[TEST_PROJECT].mergeRequests['1'].versions.length).toBe(1);
});
});
@@ -339,32 +299,28 @@ describe('IDE store merge request actions', () => {
mock.onGet(/api\/(.*)\/projects\/abcproject\/merge_requests\/1\/versions/).networkError();
});
- it('dispatches error action', (done) => {
+ it('dispatches error action', () => {
const dispatch = jest.fn();
- getMergeRequestVersions(
+ return getMergeRequestVersions(
{
commit() {},
dispatch,
state: store.state,
},
{ projectId: TEST_PROJECT, mergeRequestId: 1 },
- )
- .then(done.fail)
- .catch(() => {
- expect(dispatch).toHaveBeenCalledWith('setErrorMessage', {
- text: 'An error occurred while loading the merge request version data.',
- action: expect.any(Function),
- actionText: 'Please try again',
- actionPayload: {
- projectId: TEST_PROJECT,
- mergeRequestId: 1,
- force: false,
- },
- });
-
- done();
+ ).catch(() => {
+ expect(dispatch).toHaveBeenCalledWith('setErrorMessage', {
+ text: 'An error occurred while loading the merge request version data.',
+ action: expect.any(Function),
+ actionText: 'Please try again',
+ actionPayload: {
+ projectId: TEST_PROJECT,
+ mergeRequestId: 1,
+ force: false,
+ },
});
+ });
});
});
});
@@ -503,37 +459,36 @@ describe('IDE store merge request actions', () => {
);
});
- it('dispatches actions for merge request data', (done) => {
- openMergeRequest({ state: store.state, dispatch: store.dispatch, getters: mockGetters }, mr)
- .then(() => {
- expect(store.dispatch.mock.calls).toEqual([
- ['getMergeRequestData', mr],
- ['setCurrentBranchId', testMergeRequest.source_branch],
- [
- 'getBranchData',
- {
- projectId: mr.projectId,
- branchId: testMergeRequest.source_branch,
- },
- ],
- [
- 'getFiles',
- {
- projectId: mr.projectId,
- branchId: testMergeRequest.source_branch,
- ref: 'abcd2322',
- },
- ],
- ['getMergeRequestVersions', mr],
- ['getMergeRequestChanges', mr],
- ['openMergeRequestChanges', testMergeRequestChanges.changes],
- ]);
- })
- .then(done)
- .catch(done.fail);
+ it('dispatches actions for merge request data', async () => {
+ await openMergeRequest(
+ { state: store.state, dispatch: store.dispatch, getters: mockGetters },
+ mr,
+ );
+ expect(store.dispatch.mock.calls).toEqual([
+ ['getMergeRequestData', mr],
+ ['setCurrentBranchId', testMergeRequest.source_branch],
+ [
+ 'getBranchData',
+ {
+ projectId: mr.projectId,
+ branchId: testMergeRequest.source_branch,
+ },
+ ],
+ [
+ 'getFiles',
+ {
+ projectId: mr.projectId,
+ branchId: testMergeRequest.source_branch,
+ ref: 'abcd2322',
+ },
+ ],
+ ['getMergeRequestVersions', mr],
+ ['getMergeRequestChanges', mr],
+ ['openMergeRequestChanges', testMergeRequestChanges.changes],
+ ]);
});
- it('updates activity bar view and gets file data, if changes are found', (done) => {
+ it('updates activity bar view and gets file data, if changes are found', async () => {
store.state.entries.foo = {
type: 'blob',
path: 'foo',
@@ -548,28 +503,24 @@ describe('IDE store merge request actions', () => {
{ new_path: 'bar', path: 'bar' },
];
- openMergeRequest({ state: store.state, dispatch: store.dispatch, getters: mockGetters }, mr)
- .then(() => {
- expect(store.dispatch).toHaveBeenCalledWith(
- 'openMergeRequestChanges',
- testMergeRequestChanges.changes,
- );
- })
- .then(done)
- .catch(done.fail);
+ await openMergeRequest(
+ { state: store.state, dispatch: store.dispatch, getters: mockGetters },
+ mr,
+ );
+ expect(store.dispatch).toHaveBeenCalledWith(
+ 'openMergeRequestChanges',
+ testMergeRequestChanges.changes,
+ );
});
- it('flashes message, if error', (done) => {
+ it('flashes message, if error', () => {
store.dispatch.mockRejectedValue();
- openMergeRequest(store, mr)
- .catch(() => {
- expect(createFlash).toHaveBeenCalledWith({
- message: expect.any(String),
- });
- })
- .then(done)
- .catch(done.fail);
+ return openMergeRequest(store, mr).catch(() => {
+ expect(createFlash).toHaveBeenCalledWith({
+ message: expect.any(String),
+ });
+ });
});
});
});
diff --git a/spec/frontend/ide/stores/actions/project_spec.js b/spec/frontend/ide/stores/actions/project_spec.js
index e07dcf22860..cc7d39b4d43 100644
--- a/spec/frontend/ide/stores/actions/project_spec.js
+++ b/spec/frontend/ide/stores/actions/project_spec.js
@@ -146,22 +146,16 @@ describe('IDE store project actions', () => {
});
});
- it('calls the service', (done) => {
- store
- .dispatch('refreshLastCommitData', {
- projectId: store.state.currentProjectId,
- branchId: store.state.currentBranchId,
- })
- .then(() => {
- expect(service.getBranchData).toHaveBeenCalledWith('abc/def', 'main');
-
- done();
- })
- .catch(done.fail);
+ it('calls the service', async () => {
+ await store.dispatch('refreshLastCommitData', {
+ projectId: store.state.currentProjectId,
+ branchId: store.state.currentBranchId,
+ });
+ expect(service.getBranchData).toHaveBeenCalledWith('abc/def', 'main');
});
- it('commits getBranchData', (done) => {
- testAction(
+ it('commits getBranchData', () => {
+ return testAction(
refreshLastCommitData,
{
projectId: store.state.currentProjectId,
@@ -181,14 +175,13 @@ describe('IDE store project actions', () => {
],
// action
[],
- done,
);
});
});
describe('showBranchNotFoundError', () => {
- it('dispatches setErrorMessage', (done) => {
- testAction(
+ it('dispatches setErrorMessage', () => {
+ return testAction(
showBranchNotFoundError,
'main',
null,
@@ -204,7 +197,6 @@ describe('IDE store project actions', () => {
},
},
],
- done,
);
});
});
@@ -216,8 +208,8 @@ describe('IDE store project actions', () => {
jest.spyOn(api, 'createBranch').mockResolvedValue();
});
- it('calls API', (done) => {
- createNewBranchFromDefault(
+ it('calls API', async () => {
+ await createNewBranchFromDefault(
{
state: {
currentProjectId: 'project-path',
@@ -230,21 +222,17 @@ describe('IDE store project actions', () => {
dispatch() {},
},
'new-branch-name',
- )
- .then(() => {
- expect(api.createBranch).toHaveBeenCalledWith('project-path', {
- ref: 'main',
- branch: 'new-branch-name',
- });
- })
- .then(done)
- .catch(done.fail);
+ );
+ expect(api.createBranch).toHaveBeenCalledWith('project-path', {
+ ref: 'main',
+ branch: 'new-branch-name',
+ });
});
- it('clears error message', (done) => {
+ it('clears error message', async () => {
const dispatchSpy = jest.fn().mockName('dispatch');
- createNewBranchFromDefault(
+ await createNewBranchFromDefault(
{
state: {
currentProjectId: 'project-path',
@@ -257,16 +245,12 @@ describe('IDE store project actions', () => {
dispatch: dispatchSpy,
},
'new-branch-name',
- )
- .then(() => {
- expect(dispatchSpy).toHaveBeenCalledWith('setErrorMessage', null);
- })
- .then(done)
- .catch(done.fail);
+ );
+ expect(dispatchSpy).toHaveBeenCalledWith('setErrorMessage', null);
});
- it('reloads window', (done) => {
- createNewBranchFromDefault(
+ it('reloads window', async () => {
+ await createNewBranchFromDefault(
{
state: {
currentProjectId: 'project-path',
@@ -279,18 +263,14 @@ describe('IDE store project actions', () => {
dispatch() {},
},
'new-branch-name',
- )
- .then(() => {
- expect(window.location.reload).toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
+ );
+ expect(window.location.reload).toHaveBeenCalled();
});
});
describe('loadEmptyBranch', () => {
- it('creates a blank tree and sets loading state to false', (done) => {
- testAction(
+ it('creates a blank tree and sets loading state to false', () => {
+ return testAction(
loadEmptyBranch,
{ projectId: TEST_PROJECT_ID, branchId: 'main' },
store.state,
@@ -302,20 +282,18 @@ describe('IDE store project actions', () => {
},
],
expect.any(Object),
- done,
);
});
- it('does nothing, if tree already exists', (done) => {
+ it('does nothing, if tree already exists', () => {
const trees = { [`${TEST_PROJECT_ID}/main`]: [] };
- testAction(
+ return testAction(
loadEmptyBranch,
{ projectId: TEST_PROJECT_ID, branchId: 'main' },
{ trees },
[],
[],
- done,
);
});
});
@@ -372,56 +350,48 @@ describe('IDE store project actions', () => {
const branchId = '123-lorem';
const ref = 'abcd2322';
- it('when empty repo, loads empty branch', (done) => {
+ it('when empty repo, loads empty branch', () => {
const mockGetters = { emptyRepo: true };
- testAction(
+ return testAction(
loadBranch,
{ projectId, branchId },
{ ...store.state, ...mockGetters },
[],
[{ type: 'loadEmptyBranch', payload: { projectId, branchId } }],
- done,
);
});
- it('when branch already exists, does nothing', (done) => {
+ it('when branch already exists, does nothing', () => {
store.state.projects[projectId].branches[branchId] = {};
- testAction(loadBranch, { projectId, branchId }, store.state, [], [], done);
+ return testAction(loadBranch, { projectId, branchId }, store.state, [], []);
});
- it('fetches branch data', (done) => {
+ it('fetches branch data', async () => {
const mockGetters = { findBranch: () => ({ commit: { id: ref } }) };
jest.spyOn(store, 'dispatch').mockResolvedValue();
- loadBranch(
+ await loadBranch(
{ getters: mockGetters, state: store.state, dispatch: store.dispatch },
{ projectId, branchId },
- )
- .then(() => {
- expect(store.dispatch.mock.calls).toEqual([
- ['getBranchData', { projectId, branchId }],
- ['getMergeRequestsForBranch', { projectId, branchId }],
- ['getFiles', { projectId, branchId, ref }],
- ]);
- })
- .then(done)
- .catch(done.fail);
+ );
+ expect(store.dispatch.mock.calls).toEqual([
+ ['getBranchData', { projectId, branchId }],
+ ['getMergeRequestsForBranch', { projectId, branchId }],
+ ['getFiles', { projectId, branchId, ref }],
+ ]);
});
- it('shows an error if branch can not be fetched', (done) => {
+ it('shows an error if branch can not be fetched', async () => {
jest.spyOn(store, 'dispatch').mockReturnValue(Promise.reject());
- loadBranch(store, { projectId, branchId })
- .then(done.fail)
- .catch(() => {
- expect(store.dispatch.mock.calls).toEqual([
- ['getBranchData', { projectId, branchId }],
- ['showBranchNotFoundError', branchId],
- ]);
- done();
- });
+ await expect(loadBranch(store, { projectId, branchId })).rejects.toBeUndefined();
+
+ expect(store.dispatch.mock.calls).toEqual([
+ ['getBranchData', { projectId, branchId }],
+ ['showBranchNotFoundError', branchId],
+ ]);
});
});
@@ -449,17 +419,13 @@ describe('IDE store project actions', () => {
jest.spyOn(store, 'dispatch').mockResolvedValue();
});
- it('dispatches branch actions', (done) => {
- openBranch(store, branch)
- .then(() => {
- expect(store.dispatch.mock.calls).toEqual([
- ['setCurrentBranchId', branchId],
- ['loadBranch', { projectId, branchId }],
- ['loadFile', { basePath: undefined }],
- ]);
- })
- .then(done)
- .catch(done.fail);
+ it('dispatches branch actions', async () => {
+ await openBranch(store, branch);
+ expect(store.dispatch.mock.calls).toEqual([
+ ['setCurrentBranchId', branchId],
+ ['loadBranch', { projectId, branchId }],
+ ['loadFile', { basePath: undefined }],
+ ]);
});
});
@@ -468,22 +434,18 @@ describe('IDE store project actions', () => {
jest.spyOn(store, 'dispatch').mockReturnValue(Promise.reject());
});
- it('dispatches correct branch actions', (done) => {
- openBranch(store, branch)
- .then((val) => {
- expect(store.dispatch.mock.calls).toEqual([
- ['setCurrentBranchId', branchId],
- ['loadBranch', { projectId, branchId }],
- ]);
-
- expect(val).toEqual(
- new Error(
- `An error occurred while getting files for - <strong>${projectId}/${branchId}</strong>`,
- ),
- );
- })
- .then(done)
- .catch(done.fail);
+ it('dispatches correct branch actions', async () => {
+ const val = await openBranch(store, branch);
+ expect(store.dispatch.mock.calls).toEqual([
+ ['setCurrentBranchId', branchId],
+ ['loadBranch', { projectId, branchId }],
+ ]);
+
+ expect(val).toEqual(
+ new Error(
+ `An error occurred while getting files for - <strong>${projectId}/${branchId}</strong>`,
+ ),
+ );
});
});
});
diff --git a/spec/frontend/ide/stores/actions/tree_spec.js b/spec/frontend/ide/stores/actions/tree_spec.js
index 8d7328725e9..fc44cbb21ae 100644
--- a/spec/frontend/ide/stores/actions/tree_spec.js
+++ b/spec/frontend/ide/stores/actions/tree_spec.js
@@ -62,27 +62,21 @@ describe('Multi-file store tree actions', () => {
});
});
- it('adds data into tree', (done) => {
- store
- .dispatch('getFiles', basicCallParameters)
- .then(() => {
- projectTree = store.state.trees['abcproject/main'];
-
- expect(projectTree.tree.length).toBe(2);
- expect(projectTree.tree[0].type).toBe('tree');
- expect(projectTree.tree[0].tree[1].name).toBe('fileinfolder.js');
- expect(projectTree.tree[1].type).toBe('blob');
- expect(projectTree.tree[0].tree[0].tree[0].type).toBe('blob');
- expect(projectTree.tree[0].tree[0].tree[0].name).toBe('fileinsubfolder.js');
-
- done();
- })
- .catch(done.fail);
+ it('adds data into tree', async () => {
+ await store.dispatch('getFiles', basicCallParameters);
+ projectTree = store.state.trees['abcproject/main'];
+
+ expect(projectTree.tree.length).toBe(2);
+ expect(projectTree.tree[0].type).toBe('tree');
+ expect(projectTree.tree[0].tree[1].name).toBe('fileinfolder.js');
+ expect(projectTree.tree[1].type).toBe('blob');
+ expect(projectTree.tree[0].tree[0].tree[0].type).toBe('blob');
+ expect(projectTree.tree[0].tree[0].tree[0].name).toBe('fileinsubfolder.js');
});
});
describe('error', () => {
- it('dispatches error action', (done) => {
+ it('dispatches error action', async () => {
const dispatch = jest.fn();
store.state.projects = {
@@ -103,28 +97,26 @@ describe('Multi-file store tree actions', () => {
mock.onGet(/(.*)/).replyOnce(500);
- getFiles(
- {
- commit() {},
- dispatch,
- state: store.state,
- getters,
- },
- {
- projectId: 'abc/def',
- branchId: 'main-testing',
- },
- )
- .then(done.fail)
- .catch(() => {
- expect(dispatch).toHaveBeenCalledWith('setErrorMessage', {
- text: 'An error occurred while loading all the files.',
- action: expect.any(Function),
- actionText: 'Please try again',
- actionPayload: { projectId: 'abc/def', branchId: 'main-testing' },
- });
- done();
- });
+ await expect(
+ getFiles(
+ {
+ commit() {},
+ dispatch,
+ state: store.state,
+ getters,
+ },
+ {
+ projectId: 'abc/def',
+ branchId: 'main-testing',
+ },
+ ),
+ ).rejects.toEqual(new Error('Request failed with status code 500'));
+ expect(dispatch).toHaveBeenCalledWith('setErrorMessage', {
+ text: 'An error occurred while loading all the files.',
+ action: expect.any(Function),
+ actionText: 'Please try again',
+ actionPayload: { projectId: 'abc/def', branchId: 'main-testing' },
+ });
});
});
});
@@ -137,15 +129,9 @@ describe('Multi-file store tree actions', () => {
store.state.entries[tree.path] = tree;
});
- it('toggles the tree open', (done) => {
- store
- .dispatch('toggleTreeOpen', tree.path)
- .then(() => {
- expect(tree.opened).toBeTruthy();
-
- done();
- })
- .catch(done.fail);
+ it('toggles the tree open', async () => {
+ await store.dispatch('toggleTreeOpen', tree.path);
+ expect(tree.opened).toBeTruthy();
});
});
@@ -163,24 +149,23 @@ describe('Multi-file store tree actions', () => {
Object.assign(store.state.entries, createEntriesFromPaths(paths));
});
- it('opens the parents', (done) => {
- testAction(
+ it('opens the parents', () => {
+ return testAction(
showTreeEntry,
'grandparent/parent/child.txt',
store.state,
[{ type: types.SET_TREE_OPEN, payload: 'grandparent/parent' }],
[{ type: 'showTreeEntry', payload: 'grandparent/parent' }],
- done,
);
});
});
describe('setDirectoryData', () => {
- it('sets tree correctly if there are no opened files yet', (done) => {
+ it('sets tree correctly if there are no opened files yet', () => {
const treeFile = file({ name: 'README.md' });
store.state.trees['abcproject/main'] = {};
- testAction(
+ return testAction(
setDirectoryData,
{ projectId: 'abcproject', branchId: 'main', treeList: [treeFile] },
store.state,
@@ -201,7 +186,6 @@ describe('Multi-file store tree actions', () => {
},
],
[],
- done,
);
});
});
diff --git a/spec/frontend/ide/stores/actions_spec.js b/spec/frontend/ide/stores/actions_spec.js
index be43c618095..3889c4f11c3 100644
--- a/spec/frontend/ide/stores/actions_spec.js
+++ b/spec/frontend/ide/stores/actions_spec.js
@@ -43,15 +43,9 @@ describe('Multi-file store actions', () => {
});
describe('redirectToUrl', () => {
- it('calls visitUrl', (done) => {
- store
- .dispatch('redirectToUrl', 'test')
- .then(() => {
- expect(visitUrl).toHaveBeenCalledWith('test');
-
- done();
- })
- .catch(done.fail);
+ it('calls visitUrl', async () => {
+ await store.dispatch('redirectToUrl', 'test');
+ expect(visitUrl).toHaveBeenCalledWith('test');
});
});
@@ -89,15 +83,10 @@ describe('Multi-file store actions', () => {
expect(store.dispatch.mock.calls).toEqual(expect.arrayContaining(expectedCalls));
});
- it('removes all files from changedFiles state', (done) => {
- store
- .dispatch('discardAllChanges')
- .then(() => {
- expect(store.state.changedFiles.length).toBe(0);
- expect(store.state.openFiles.length).toBe(2);
- })
- .then(done)
- .catch(done.fail);
+ it('removes all files from changedFiles state', async () => {
+ await store.dispatch('discardAllChanges');
+ expect(store.state.changedFiles.length).toBe(0);
+ expect(store.state.openFiles.length).toBe(2);
});
});
@@ -121,24 +110,18 @@ describe('Multi-file store actions', () => {
});
describe('tree', () => {
- it('creates temp tree', (done) => {
- store
- .dispatch('createTempEntry', {
- name: 'test',
- type: 'tree',
- })
- .then(() => {
- const entry = store.state.entries.test;
-
- expect(entry).not.toBeNull();
- expect(entry.type).toBe('tree');
+ it('creates temp tree', async () => {
+ await store.dispatch('createTempEntry', {
+ name: 'test',
+ type: 'tree',
+ });
+ const entry = store.state.entries.test;
- done();
- })
- .catch(done.fail);
+ expect(entry).not.toBeNull();
+ expect(entry.type).toBe('tree');
});
- it('creates new folder inside another tree', (done) => {
+ it('creates new folder inside another tree', async () => {
const tree = {
type: 'tree',
name: 'testing',
@@ -148,22 +131,16 @@ describe('Multi-file store actions', () => {
store.state.entries[tree.path] = tree;
- store
- .dispatch('createTempEntry', {
- name: 'testing/test',
- type: 'tree',
- })
- .then(() => {
- expect(tree.tree[0].tempFile).toBeTruthy();
- expect(tree.tree[0].name).toBe('test');
- expect(tree.tree[0].type).toBe('tree');
-
- done();
- })
- .catch(done.fail);
+ await store.dispatch('createTempEntry', {
+ name: 'testing/test',
+ type: 'tree',
+ });
+ expect(tree.tree[0].tempFile).toBeTruthy();
+ expect(tree.tree[0].name).toBe('test');
+ expect(tree.tree[0].type).toBe('tree');
});
- it('does not create new tree if already exists', (done) => {
+ it('does not create new tree if already exists', async () => {
const tree = {
type: 'tree',
path: 'testing',
@@ -173,76 +150,52 @@ describe('Multi-file store actions', () => {
store.state.entries[tree.path] = tree;
- store
- .dispatch('createTempEntry', {
- name: 'testing',
- type: 'tree',
- })
- .then(() => {
- expect(store.state.entries[tree.path].tempFile).toEqual(false);
- expect(document.querySelector('.flash-alert')).not.toBeNull();
-
- done();
- })
- .catch(done.fail);
+ await store.dispatch('createTempEntry', {
+ name: 'testing',
+ type: 'tree',
+ });
+ expect(store.state.entries[tree.path].tempFile).toEqual(false);
+ expect(document.querySelector('.flash-alert')).not.toBeNull();
});
});
describe('blob', () => {
- it('creates temp file', (done) => {
+ it('creates temp file', async () => {
const name = 'test';
- store
- .dispatch('createTempEntry', {
- name,
- type: 'blob',
- mimeType: 'test/mime',
- })
- .then(() => {
- const f = store.state.entries[name];
-
- expect(f.tempFile).toBeTruthy();
- expect(f.mimeType).toBe('test/mime');
- expect(store.state.trees['abcproject/mybranch'].tree.length).toBe(1);
-
- done();
- })
- .catch(done.fail);
+ await store.dispatch('createTempEntry', {
+ name,
+ type: 'blob',
+ mimeType: 'test/mime',
+ });
+ const f = store.state.entries[name];
+
+ expect(f.tempFile).toBeTruthy();
+ expect(f.mimeType).toBe('test/mime');
+ expect(store.state.trees['abcproject/mybranch'].tree.length).toBe(1);
});
- it('adds tmp file to open files', (done) => {
+ it('adds tmp file to open files', async () => {
const name = 'test';
- store
- .dispatch('createTempEntry', {
- name,
- type: 'blob',
- })
- .then(() => {
- const f = store.state.entries[name];
-
- expect(store.state.openFiles.length).toBe(1);
- expect(store.state.openFiles[0].name).toBe(f.name);
+ await store.dispatch('createTempEntry', {
+ name,
+ type: 'blob',
+ });
+ const f = store.state.entries[name];
- done();
- })
- .catch(done.fail);
+ expect(store.state.openFiles.length).toBe(1);
+ expect(store.state.openFiles[0].name).toBe(f.name);
});
- it('adds tmp file to staged files', (done) => {
+ it('adds tmp file to staged files', async () => {
const name = 'test';
- store
- .dispatch('createTempEntry', {
- name,
- type: 'blob',
- })
- .then(() => {
- expect(store.state.stagedFiles).toEqual([expect.objectContaining({ name })]);
-
- done();
- })
- .catch(done.fail);
+ await store.dispatch('createTempEntry', {
+ name,
+ type: 'blob',
+ });
+ expect(store.state.stagedFiles).toEqual([expect.objectContaining({ name })]);
});
it('sets tmp file as active', () => {
@@ -251,24 +204,18 @@ describe('Multi-file store actions', () => {
expect(store.dispatch).toHaveBeenCalledWith('setFileActive', 'test');
});
- it('creates flash message if file already exists', (done) => {
+ it('creates flash message if file already exists', async () => {
const f = file('test', '1', 'blob');
store.state.trees['abcproject/mybranch'].tree = [f];
store.state.entries[f.path] = f;
- store
- .dispatch('createTempEntry', {
- name: 'test',
- type: 'blob',
- })
- .then(() => {
- expect(document.querySelector('.flash-alert')?.textContent.trim()).toEqual(
- `The name "${f.name}" is already taken in this directory.`,
- );
-
- done();
- })
- .catch(done.fail);
+ await store.dispatch('createTempEntry', {
+ name: 'test',
+ type: 'blob',
+ });
+ expect(document.querySelector('.flash-alert')?.textContent.trim()).toEqual(
+ `The name "${f.name}" is already taken in this directory.`,
+ );
});
});
});
@@ -372,45 +319,38 @@ describe('Multi-file store actions', () => {
});
describe('updateViewer', () => {
- it('updates viewer state', (done) => {
- store
- .dispatch('updateViewer', 'diff')
- .then(() => {
- expect(store.state.viewer).toBe('diff');
- })
- .then(done)
- .catch(done.fail);
+ it('updates viewer state', async () => {
+ await store.dispatch('updateViewer', 'diff');
+ expect(store.state.viewer).toBe('diff');
});
});
describe('updateActivityBarView', () => {
- it('commits UPDATE_ACTIVITY_BAR_VIEW', (done) => {
- testAction(
+ it('commits UPDATE_ACTIVITY_BAR_VIEW', () => {
+ return testAction(
updateActivityBarView,
'test',
{},
[{ type: 'UPDATE_ACTIVITY_BAR_VIEW', payload: 'test' }],
[],
- done,
);
});
});
describe('setEmptyStateSvgs', () => {
- it('commits setEmptyStateSvgs', (done) => {
- testAction(
+ it('commits setEmptyStateSvgs', () => {
+ return testAction(
setEmptyStateSvgs,
'svg',
{},
[{ type: 'SET_EMPTY_STATE_SVGS', payload: 'svg' }],
[],
- done,
);
});
});
describe('updateTempFlagForEntry', () => {
- it('commits UPDATE_TEMP_FLAG', (done) => {
+ it('commits UPDATE_TEMP_FLAG', () => {
const f = {
...file(),
path: 'test',
@@ -418,17 +358,16 @@ describe('Multi-file store actions', () => {
};
store.state.entries[f.path] = f;
- testAction(
+ return testAction(
updateTempFlagForEntry,
{ file: f, tempFile: false },
store.state,
[{ type: 'UPDATE_TEMP_FLAG', payload: { path: f.path, tempFile: false } }],
[],
- done,
);
});
- it('commits UPDATE_TEMP_FLAG and dispatches for parent', (done) => {
+ it('commits UPDATE_TEMP_FLAG and dispatches for parent', () => {
const parent = {
...file(),
path: 'testing',
@@ -441,17 +380,16 @@ describe('Multi-file store actions', () => {
store.state.entries[parent.path] = parent;
store.state.entries[f.path] = f;
- testAction(
+ return testAction(
updateTempFlagForEntry,
{ file: f, tempFile: false },
store.state,
[{ type: 'UPDATE_TEMP_FLAG', payload: { path: f.path, tempFile: false } }],
[{ type: 'updateTempFlagForEntry', payload: { file: parent, tempFile: false } }],
- done,
);
});
- it('does not dispatch for parent, if parent does not exist', (done) => {
+ it('does not dispatch for parent, if parent does not exist', () => {
const f = {
...file(),
path: 'test',
@@ -459,71 +397,66 @@ describe('Multi-file store actions', () => {
};
store.state.entries[f.path] = f;
- testAction(
+ return testAction(
updateTempFlagForEntry,
{ file: f, tempFile: false },
store.state,
[{ type: 'UPDATE_TEMP_FLAG', payload: { path: f.path, tempFile: false } }],
[],
- done,
);
});
});
describe('setCurrentBranchId', () => {
- it('commits setCurrentBranchId', (done) => {
- testAction(
+ it('commits setCurrentBranchId', () => {
+ return testAction(
setCurrentBranchId,
'branchId',
{},
[{ type: 'SET_CURRENT_BRANCH', payload: 'branchId' }],
[],
- done,
);
});
});
describe('toggleFileFinder', () => {
- it('commits TOGGLE_FILE_FINDER', (done) => {
- testAction(
+ it('commits TOGGLE_FILE_FINDER', () => {
+ return testAction(
toggleFileFinder,
true,
null,
[{ type: 'TOGGLE_FILE_FINDER', payload: true }],
[],
- done,
);
});
});
describe('setErrorMessage', () => {
- it('commis error messsage', (done) => {
- testAction(
+ it('commis error messsage', () => {
+ return testAction(
setErrorMessage,
'error',
null,
[{ type: types.SET_ERROR_MESSAGE, payload: 'error' }],
[],
- done,
);
});
});
describe('deleteEntry', () => {
- it('commits entry deletion', (done) => {
+ it('commits entry deletion', () => {
store.state.entries.path = 'testing';
- testAction(
+ return testAction(
deleteEntry,
'path',
store.state,
[{ type: types.DELETE_ENTRY, payload: 'path' }],
[{ type: 'stageChange', payload: 'path' }, createTriggerChangeAction()],
- done,
);
});
- it('does not delete a folder after it is emptied', (done) => {
+ it('does not delete a folder after it is emptied', () => {
const testFolder = {
type: 'tree',
tree: [],
@@ -540,7 +473,7 @@ describe('Multi-file store actions', () => {
'testFolder/entry-to-delete': testEntry,
};
- testAction(
+ return testAction(
deleteEntry,
'testFolder/entry-to-delete',
store.state,
@@ -549,7 +482,6 @@ describe('Multi-file store actions', () => {
{ type: 'stageChange', payload: 'testFolder/entry-to-delete' },
createTriggerChangeAction(),
],
- done,
);
});
@@ -569,8 +501,8 @@ describe('Multi-file store actions', () => {
});
describe('and previous does not exist', () => {
- it('reverts the rename before deleting', (done) => {
- testAction(
+ it('reverts the rename before deleting', () => {
+ return testAction(
deleteEntry,
testEntry.path,
store.state,
@@ -589,7 +521,6 @@ describe('Multi-file store actions', () => {
payload: testEntry.prevPath,
},
],
- done,
);
});
});
@@ -604,21 +535,20 @@ describe('Multi-file store actions', () => {
store.state.entries[oldEntry.path] = oldEntry;
});
- it('does not revert rename before deleting', (done) => {
- testAction(
+ it('does not revert rename before deleting', () => {
+ return testAction(
deleteEntry,
testEntry.path,
store.state,
[{ type: types.DELETE_ENTRY, payload: testEntry.path }],
[{ type: 'stageChange', payload: testEntry.path }, createTriggerChangeAction()],
- done,
);
});
- it('when previous is deleted, it reverts rename before deleting', (done) => {
+ it('when previous is deleted, it reverts rename before deleting', () => {
store.state.entries[testEntry.prevPath].deleted = true;
- testAction(
+ return testAction(
deleteEntry,
testEntry.path,
store.state,
@@ -637,7 +567,6 @@ describe('Multi-file store actions', () => {
payload: testEntry.prevPath,
},
],
- done,
);
});
});
@@ -650,7 +579,7 @@ describe('Multi-file store actions', () => {
jest.spyOn(eventHub, '$emit').mockImplementation();
});
- it('does not purge model cache for temporary entries that got renamed', (done) => {
+ it('does not purge model cache for temporary entries that got renamed', async () => {
Object.assign(store.state.entries, {
test: {
...file('test'),
@@ -660,19 +589,14 @@ describe('Multi-file store actions', () => {
},
});
- store
- .dispatch('renameEntry', {
- path: 'test',
- name: 'new',
- })
- .then(() => {
- expect(eventHub.$emit.mock.calls).not.toContain('editor.update.model.dispose.foo-bar');
- })
- .then(done)
- .catch(done.fail);
+ await store.dispatch('renameEntry', {
+ path: 'test',
+ name: 'new',
+ });
+ expect(eventHub.$emit.mock.calls).not.toContain('editor.update.model.dispose.foo-bar');
});
- it('purges model cache for renamed entry', (done) => {
+ it('purges model cache for renamed entry', async () => {
Object.assign(store.state.entries, {
test: {
...file('test'),
@@ -682,17 +606,12 @@ describe('Multi-file store actions', () => {
},
});
- store
- .dispatch('renameEntry', {
- path: 'test',
- name: 'new',
- })
- .then(() => {
- expect(eventHub.$emit).toHaveBeenCalled();
- expect(eventHub.$emit).toHaveBeenCalledWith(`editor.update.model.dispose.foo-key`);
- })
- .then(done)
- .catch(done.fail);
+ await store.dispatch('renameEntry', {
+ path: 'test',
+ name: 'new',
+ });
+ expect(eventHub.$emit).toHaveBeenCalled();
+ expect(eventHub.$emit).toHaveBeenCalledWith(`editor.update.model.dispose.foo-key`);
});
});
@@ -731,8 +650,8 @@ describe('Multi-file store actions', () => {
]);
});
- it('if not changed, completely unstages and discards entry if renamed to original', (done) => {
- testAction(
+ it('if not changed, completely unstages and discards entry if renamed to original', () => {
+ return testAction(
renameEntry,
{ path: 'renamed', name: 'orig' },
store.state,
@@ -751,24 +670,22 @@ describe('Multi-file store actions', () => {
},
],
[createTriggerRenameAction('renamed', 'orig')],
- done,
);
});
- it('if already in changed, does not add to change', (done) => {
+ it('if already in changed, does not add to change', () => {
store.state.changedFiles.push(renamedEntry);
- testAction(
+ return testAction(
renameEntry,
{ path: 'orig', name: 'renamed' },
store.state,
[expect.objectContaining({ type: types.RENAME_ENTRY })],
[createTriggerRenameAction('orig', 'renamed')],
- done,
);
});
- it('routes to the renamed file if the original file has been opened', (done) => {
+ it('routes to the renamed file if the original file has been opened', async () => {
store.state.currentProjectId = 'test/test';
store.state.currentBranchId = 'main';
@@ -776,17 +693,12 @@ describe('Multi-file store actions', () => {
opened: true,
});
- store
- .dispatch('renameEntry', {
- path: 'orig',
- name: 'renamed',
- })
- .then(() => {
- expect(router.push.mock.calls).toHaveLength(1);
- expect(router.push).toHaveBeenCalledWith(`/project/test/test/tree/main/-/renamed/`);
- })
- .then(done)
- .catch(done.fail);
+ await store.dispatch('renameEntry', {
+ path: 'orig',
+ name: 'renamed',
+ });
+ expect(router.push.mock.calls).toHaveLength(1);
+ expect(router.push).toHaveBeenCalledWith(`/project/test/test/tree/main/-/renamed/`);
});
});
@@ -809,25 +721,20 @@ describe('Multi-file store actions', () => {
});
});
- it('updates entries in a folder correctly, when folder is renamed', (done) => {
- store
- .dispatch('renameEntry', {
- path: 'folder',
- name: 'new-folder',
- })
- .then(() => {
- const keys = Object.keys(store.state.entries);
-
- expect(keys.length).toBe(3);
- expect(keys.indexOf('new-folder')).toBe(0);
- expect(keys.indexOf('new-folder/file-1')).toBe(1);
- expect(keys.indexOf('new-folder/file-2')).toBe(2);
- })
- .then(done)
- .catch(done.fail);
+ it('updates entries in a folder correctly, when folder is renamed', async () => {
+ await store.dispatch('renameEntry', {
+ path: 'folder',
+ name: 'new-folder',
+ });
+ const keys = Object.keys(store.state.entries);
+
+ expect(keys.length).toBe(3);
+ expect(keys.indexOf('new-folder')).toBe(0);
+ expect(keys.indexOf('new-folder/file-1')).toBe(1);
+ expect(keys.indexOf('new-folder/file-2')).toBe(2);
});
- it('discards renaming of an entry if the root folder is renamed back to a previous name', (done) => {
+ it('discards renaming of an entry if the root folder is renamed back to a previous name', async () => {
const rootFolder = file('old-folder', 'old-folder', 'tree');
const testEntry = file('test', 'test', 'blob', rootFolder);
@@ -841,53 +748,45 @@ describe('Multi-file store actions', () => {
},
});
- store
- .dispatch('renameEntry', {
- path: 'old-folder',
- name: 'new-folder',
- })
- .then(() => {
- const { entries } = store.state;
-
- expect(Object.keys(entries).length).toBe(2);
- expect(entries['old-folder']).toBeUndefined();
- expect(entries['old-folder/test']).toBeUndefined();
-
- expect(entries['new-folder']).toBeDefined();
- expect(entries['new-folder/test']).toEqual(
- expect.objectContaining({
- path: 'new-folder/test',
- name: 'test',
- prevPath: 'old-folder/test',
- prevName: 'test',
- }),
- );
- })
- .then(() =>
- store.dispatch('renameEntry', {
- path: 'new-folder',
- name: 'old-folder',
- }),
- )
- .then(() => {
- const { entries } = store.state;
-
- expect(Object.keys(entries).length).toBe(2);
- expect(entries['new-folder']).toBeUndefined();
- expect(entries['new-folder/test']).toBeUndefined();
-
- expect(entries['old-folder']).toBeDefined();
- expect(entries['old-folder/test']).toEqual(
- expect.objectContaining({
- path: 'old-folder/test',
- name: 'test',
- prevPath: undefined,
- prevName: undefined,
- }),
- );
- })
- .then(done)
- .catch(done.fail);
+ await store.dispatch('renameEntry', {
+ path: 'old-folder',
+ name: 'new-folder',
+ });
+ const { entries } = store.state;
+
+ expect(Object.keys(entries).length).toBe(2);
+ expect(entries['old-folder']).toBeUndefined();
+ expect(entries['old-folder/test']).toBeUndefined();
+
+ expect(entries['new-folder']).toBeDefined();
+ expect(entries['new-folder/test']).toEqual(
+ expect.objectContaining({
+ path: 'new-folder/test',
+ name: 'test',
+ prevPath: 'old-folder/test',
+ prevName: 'test',
+ }),
+ );
+
+ await store.dispatch('renameEntry', {
+ path: 'new-folder',
+ name: 'old-folder',
+ });
+ const { entries: newEntries } = store.state;
+
+ expect(Object.keys(newEntries).length).toBe(2);
+ expect(newEntries['new-folder']).toBeUndefined();
+ expect(newEntries['new-folder/test']).toBeUndefined();
+
+ expect(newEntries['old-folder']).toBeDefined();
+ expect(newEntries['old-folder/test']).toEqual(
+ expect.objectContaining({
+ path: 'old-folder/test',
+ name: 'test',
+ prevPath: undefined,
+ prevName: undefined,
+ }),
+ );
});
describe('with file in directory', () => {
@@ -919,24 +818,21 @@ describe('Multi-file store actions', () => {
});
});
- it('creates new directory', (done) => {
+ it('creates new directory', async () => {
expect(store.state.entries[newParentPath]).toBeUndefined();
- store
- .dispatch('renameEntry', { path: filePath, name: fileName, parentPath: newParentPath })
- .then(() => {
- expect(store.state.entries[newParentPath]).toEqual(
- expect.objectContaining({
- path: newParentPath,
- type: 'tree',
- tree: expect.arrayContaining([
- store.state.entries[`${newParentPath}/${fileName}`],
- ]),
- }),
- );
- })
- .then(done)
- .catch(done.fail);
+ await store.dispatch('renameEntry', {
+ path: filePath,
+ name: fileName,
+ parentPath: newParentPath,
+ });
+ expect(store.state.entries[newParentPath]).toEqual(
+ expect.objectContaining({
+ path: newParentPath,
+ type: 'tree',
+ tree: expect.arrayContaining([store.state.entries[`${newParentPath}/${fileName}`]]),
+ }),
+ );
});
describe('when new directory exists', () => {
@@ -949,40 +845,30 @@ describe('Multi-file store actions', () => {
rootDir.tree.push(newDir);
});
- it('inserts in new directory', (done) => {
+ it('inserts in new directory', async () => {
expect(newDir.tree).toEqual([]);
- store
- .dispatch('renameEntry', {
- path: filePath,
- name: fileName,
- parentPath: newParentPath,
- })
- .then(() => {
- expect(newDir.tree).toEqual([store.state.entries[`${newParentPath}/${fileName}`]]);
- })
- .then(done)
- .catch(done.fail);
+ await store.dispatch('renameEntry', {
+ path: filePath,
+ name: fileName,
+ parentPath: newParentPath,
+ });
+ expect(newDir.tree).toEqual([store.state.entries[`${newParentPath}/${fileName}`]]);
});
- it('when new directory is deleted, it undeletes it', (done) => {
- store.dispatch('deleteEntry', newParentPath);
+ it('when new directory is deleted, it undeletes it', async () => {
+ await store.dispatch('deleteEntry', newParentPath);
expect(store.state.entries[newParentPath].deleted).toBe(true);
expect(rootDir.tree.some((x) => x.path === newParentPath)).toBe(false);
- store
- .dispatch('renameEntry', {
- path: filePath,
- name: fileName,
- parentPath: newParentPath,
- })
- .then(() => {
- expect(store.state.entries[newParentPath].deleted).toBe(false);
- expect(rootDir.tree.some((x) => x.path === newParentPath)).toBe(true);
- })
- .then(done)
- .catch(done.fail);
+ await store.dispatch('renameEntry', {
+ path: filePath,
+ name: fileName,
+ parentPath: newParentPath,
+ });
+ expect(store.state.entries[newParentPath].deleted).toBe(false);
+ expect(rootDir.tree.some((x) => x.path === newParentPath)).toBe(true);
});
});
});
@@ -1023,30 +909,25 @@ describe('Multi-file store actions', () => {
document.querySelector('.flash-container').remove();
});
- it('passes the error further unchanged without dispatching any action when response is 404', (done) => {
+ it('passes the error further unchanged without dispatching any action when response is 404', async () => {
mock.onGet(/(.*)/).replyOnce(404);
- getBranchData(...callParams)
- .then(done.fail)
- .catch((e) => {
- expect(dispatch.mock.calls).toHaveLength(0);
- expect(e.response.status).toEqual(404);
- expect(document.querySelector('.flash-alert')).toBeNull();
- done();
- });
+ await expect(getBranchData(...callParams)).rejects.toEqual(
+ new Error('Request failed with status code 404'),
+ );
+ expect(dispatch.mock.calls).toHaveLength(0);
+ expect(document.querySelector('.flash-alert')).toBeNull();
});
- it('does not pass the error further and flashes an alert if error is not 404', (done) => {
+ it('does not pass the error further and flashes an alert if error is not 404', async () => {
mock.onGet(/(.*)/).replyOnce(418);
- getBranchData(...callParams)
- .then(done.fail)
- .catch((e) => {
- expect(dispatch.mock.calls).toHaveLength(0);
- expect(e.response).toBeUndefined();
- expect(document.querySelector('.flash-alert')).not.toBeNull();
- done();
- });
+ await expect(getBranchData(...callParams)).rejects.toEqual(
+ new Error('Branch not loaded - <strong>abc/def/main-testing</strong>'),
+ );
+
+ expect(dispatch.mock.calls).toHaveLength(0);
+ expect(document.querySelector('.flash-alert')).not.toBeNull();
});
});
});
diff --git a/spec/frontend/ide/stores/modules/branches/actions_spec.js b/spec/frontend/ide/stores/modules/branches/actions_spec.js
index 135dbc1f746..306330e3ba2 100644
--- a/spec/frontend/ide/stores/modules/branches/actions_spec.js
+++ b/spec/frontend/ide/stores/modules/branches/actions_spec.js
@@ -42,21 +42,20 @@ describe('IDE branches actions', () => {
});
describe('requestBranches', () => {
- it('should commit request', (done) => {
- testAction(
+ it('should commit request', () => {
+ return testAction(
requestBranches,
null,
mockedContext.state,
[{ type: types.REQUEST_BRANCHES }],
[],
- done,
);
});
});
describe('receiveBranchesError', () => {
- it('should commit error', (done) => {
- testAction(
+ it('should commit error', () => {
+ return testAction(
receiveBranchesError,
{ search: TEST_SEARCH },
mockedContext.state,
@@ -72,20 +71,18 @@ describe('IDE branches actions', () => {
},
},
],
- done,
);
});
});
describe('receiveBranchesSuccess', () => {
- it('should commit received data', (done) => {
- testAction(
+ it('should commit received data', () => {
+ return testAction(
receiveBranchesSuccess,
branches,
mockedContext.state,
[{ type: types.RECEIVE_BRANCHES_SUCCESS, payload: branches }],
[],
- done,
);
});
});
@@ -110,8 +107,8 @@ describe('IDE branches actions', () => {
});
});
- it('dispatches success with received data', (done) => {
- testAction(
+ it('dispatches success with received data', () => {
+ return testAction(
fetchBranches,
{ search: TEST_SEARCH },
mockedState,
@@ -121,7 +118,6 @@ describe('IDE branches actions', () => {
{ type: 'resetBranches' },
{ type: 'receiveBranchesSuccess', payload: branches },
],
- done,
);
});
});
@@ -131,8 +127,8 @@ describe('IDE branches actions', () => {
mock.onGet(/\/api\/v4\/projects\/\d+\/repository\/branches(.*)$/).replyOnce(500);
});
- it('dispatches error', (done) => {
- testAction(
+ it('dispatches error', () => {
+ return testAction(
fetchBranches,
{ search: TEST_SEARCH },
mockedState,
@@ -142,20 +138,18 @@ describe('IDE branches actions', () => {
{ type: 'resetBranches' },
{ type: 'receiveBranchesError', payload: { search: TEST_SEARCH } },
],
- done,
);
});
});
describe('resetBranches', () => {
- it('commits reset', (done) => {
- testAction(
+ it('commits reset', () => {
+ return testAction(
resetBranches,
null,
mockedContext.state,
[{ type: types.RESET_BRANCHES }],
[],
- done,
);
});
});
diff --git a/spec/frontend/ide/stores/modules/clientside/actions_spec.js b/spec/frontend/ide/stores/modules/clientside/actions_spec.js
index d2777623b0d..c2b9de192d9 100644
--- a/spec/frontend/ide/stores/modules/clientside/actions_spec.js
+++ b/spec/frontend/ide/stores/modules/clientside/actions_spec.js
@@ -26,15 +26,13 @@ describe('IDE store module clientside actions', () => {
});
describe('pingUsage', () => {
- it('posts to usage endpoint', (done) => {
+ it('posts to usage endpoint', async () => {
const usageSpy = jest.fn(() => [200]);
mock.onPost(TEST_USAGE_URL).reply(() => usageSpy());
- testAction(actions.pingUsage, PING_USAGE_PREVIEW_KEY, rootGetters, [], [], () => {
- expect(usageSpy).toHaveBeenCalled();
- done();
- });
+ await testAction(actions.pingUsage, PING_USAGE_PREVIEW_KEY, rootGetters, [], []);
+ expect(usageSpy).toHaveBeenCalled();
});
});
});
diff --git a/spec/frontend/ide/stores/modules/commit/actions_spec.js b/spec/frontend/ide/stores/modules/commit/actions_spec.js
index cb6bb7c1202..d65039e89cc 100644
--- a/spec/frontend/ide/stores/modules/commit/actions_spec.js
+++ b/spec/frontend/ide/stores/modules/commit/actions_spec.js
@@ -57,40 +57,25 @@ describe('IDE commit module actions', () => {
});
describe('updateCommitMessage', () => {
- it('updates store with new commit message', (done) => {
- store
- .dispatch('commit/updateCommitMessage', 'testing')
- .then(() => {
- expect(store.state.commit.commitMessage).toBe('testing');
- })
- .then(done)
- .catch(done.fail);
+ it('updates store with new commit message', async () => {
+ await store.dispatch('commit/updateCommitMessage', 'testing');
+ expect(store.state.commit.commitMessage).toBe('testing');
});
});
describe('discardDraft', () => {
- it('resets commit message to blank', (done) => {
+ it('resets commit message to blank', async () => {
store.state.commit.commitMessage = 'testing';
- store
- .dispatch('commit/discardDraft')
- .then(() => {
- expect(store.state.commit.commitMessage).not.toBe('testing');
- })
- .then(done)
- .catch(done.fail);
+ await store.dispatch('commit/discardDraft');
+ expect(store.state.commit.commitMessage).not.toBe('testing');
});
});
describe('updateCommitAction', () => {
- it('updates store with new commit action', (done) => {
- store
- .dispatch('commit/updateCommitAction', '1')
- .then(() => {
- expect(store.state.commit.commitAction).toBe('1');
- })
- .then(done)
- .catch(done.fail);
+ it('updates store with new commit action', async () => {
+ await store.dispatch('commit/updateCommitAction', '1');
+ expect(store.state.commit.commitAction).toBe('1');
});
});
@@ -139,34 +124,24 @@ describe('IDE commit module actions', () => {
});
});
- it('updates commit message with short_id', (done) => {
- store
- .dispatch('commit/setLastCommitMessage', { short_id: '123' })
- .then(() => {
- expect(store.state.lastCommitMsg).toContain(
- 'Your changes have been committed. Commit <a href="http://testing/-/commit/123" class="commit-sha">123</a>',
- );
- })
- .then(done)
- .catch(done.fail);
+ it('updates commit message with short_id', async () => {
+ await store.dispatch('commit/setLastCommitMessage', { short_id: '123' });
+ expect(store.state.lastCommitMsg).toContain(
+ 'Your changes have been committed. Commit <a href="http://testing/-/commit/123" class="commit-sha">123</a>',
+ );
});
- it('updates commit message with stats', (done) => {
- store
- .dispatch('commit/setLastCommitMessage', {
- short_id: '123',
- stats: {
- additions: '1',
- deletions: '2',
- },
- })
- .then(() => {
- expect(store.state.lastCommitMsg).toBe(
- 'Your changes have been committed. Commit <a href="http://testing/-/commit/123" class="commit-sha">123</a> with 1 additions, 2 deletions.',
- );
- })
- .then(done)
- .catch(done.fail);
+ it('updates commit message with stats', async () => {
+ await store.dispatch('commit/setLastCommitMessage', {
+ short_id: '123',
+ stats: {
+ additions: '1',
+ deletions: '2',
+ },
+ });
+ expect(store.state.lastCommitMsg).toBe(
+ 'Your changes have been committed. Commit <a href="http://testing/-/commit/123" class="commit-sha">123</a> with 1 additions, 2 deletions.',
+ );
});
});
@@ -221,74 +196,49 @@ describe('IDE commit module actions', () => {
});
});
- it('updates stores working reference', (done) => {
- store
- .dispatch('commit/updateFilesAfterCommit', {
- data,
- branch,
- })
- .then(() => {
- expect(store.state.projects.abcproject.branches.main.workingReference).toBe(data.id);
- })
- .then(done)
- .catch(done.fail);
+ it('updates stores working reference', async () => {
+ await store.dispatch('commit/updateFilesAfterCommit', {
+ data,
+ branch,
+ });
+ expect(store.state.projects.abcproject.branches.main.workingReference).toBe(data.id);
});
- it('resets all files changed status', (done) => {
- store
- .dispatch('commit/updateFilesAfterCommit', {
- data,
- branch,
- })
- .then(() => {
- store.state.openFiles.forEach((entry) => {
- expect(entry.changed).toBeFalsy();
- });
- })
- .then(done)
- .catch(done.fail);
+ it('resets all files changed status', async () => {
+ await store.dispatch('commit/updateFilesAfterCommit', {
+ data,
+ branch,
+ });
+ store.state.openFiles.forEach((entry) => {
+ expect(entry.changed).toBeFalsy();
+ });
});
- it('sets files commit data', (done) => {
- store
- .dispatch('commit/updateFilesAfterCommit', {
- data,
- branch,
- })
- .then(() => {
- expect(f.lastCommitSha).toBe(data.id);
- })
- .then(done)
- .catch(done.fail);
+ it('sets files commit data', async () => {
+ await store.dispatch('commit/updateFilesAfterCommit', {
+ data,
+ branch,
+ });
+ expect(f.lastCommitSha).toBe(data.id);
});
- it('updates raw content for changed file', (done) => {
- store
- .dispatch('commit/updateFilesAfterCommit', {
- data,
- branch,
- })
- .then(() => {
- expect(f.raw).toBe(f.content);
- })
- .then(done)
- .catch(done.fail);
+ it('updates raw content for changed file', async () => {
+ await store.dispatch('commit/updateFilesAfterCommit', {
+ data,
+ branch,
+ });
+ expect(f.raw).toBe(f.content);
});
- it('emits changed event for file', (done) => {
- store
- .dispatch('commit/updateFilesAfterCommit', {
- data,
- branch,
- })
- .then(() => {
- expect(eventHub.$emit).toHaveBeenCalledWith(`editor.update.model.content.${f.key}`, {
- content: f.content,
- changed: false,
- });
- })
- .then(done)
- .catch(done.fail);
+ it('emits changed event for file', async () => {
+ await store.dispatch('commit/updateFilesAfterCommit', {
+ data,
+ branch,
+ });
+ expect(eventHub.$emit).toHaveBeenCalledWith(`editor.update.model.content.${f.key}`, {
+ content: f.content,
+ changed: false,
+ });
});
});
@@ -349,138 +299,93 @@ describe('IDE commit module actions', () => {
jest.spyOn(service, 'commit').mockResolvedValue({ data: COMMIT_RESPONSE });
});
- it('calls service', (done) => {
- store
- .dispatch('commit/commitChanges')
- .then(() => {
- expect(service.commit).toHaveBeenCalledWith('abcproject', {
- branch: expect.anything(),
- commit_message: 'testing 123',
- actions: [
- {
- action: commitActionTypes.update,
- file_path: expect.anything(),
- content: '\n',
- encoding: expect.anything(),
- last_commit_id: undefined,
- previous_path: undefined,
- },
- ],
- start_sha: TEST_COMMIT_SHA,
- });
-
- done();
- })
- .catch(done.fail);
+ it('calls service', async () => {
+ await store.dispatch('commit/commitChanges');
+ expect(service.commit).toHaveBeenCalledWith('abcproject', {
+ branch: expect.anything(),
+ commit_message: 'testing 123',
+ actions: [
+ {
+ action: commitActionTypes.update,
+ file_path: expect.anything(),
+ content: '\n',
+ encoding: expect.anything(),
+ last_commit_id: undefined,
+ previous_path: undefined,
+ },
+ ],
+ start_sha: TEST_COMMIT_SHA,
+ });
});
- it('sends lastCommit ID when not creating new branch', (done) => {
+ it('sends lastCommit ID when not creating new branch', async () => {
store.state.commit.commitAction = '1';
- store
- .dispatch('commit/commitChanges')
- .then(() => {
- expect(service.commit).toHaveBeenCalledWith('abcproject', {
- branch: expect.anything(),
- commit_message: 'testing 123',
- actions: [
- {
- action: commitActionTypes.update,
- file_path: expect.anything(),
- content: '\n',
- encoding: expect.anything(),
- last_commit_id: TEST_COMMIT_SHA,
- previous_path: undefined,
- },
- ],
- start_sha: undefined,
- });
-
- done();
- })
- .catch(done.fail);
+ await store.dispatch('commit/commitChanges');
+ expect(service.commit).toHaveBeenCalledWith('abcproject', {
+ branch: expect.anything(),
+ commit_message: 'testing 123',
+ actions: [
+ {
+ action: commitActionTypes.update,
+ file_path: expect.anything(),
+ content: '\n',
+ encoding: expect.anything(),
+ last_commit_id: TEST_COMMIT_SHA,
+ previous_path: undefined,
+ },
+ ],
+ start_sha: undefined,
+ });
});
- it('sets last Commit Msg', (done) => {
- store
- .dispatch('commit/commitChanges')
- .then(() => {
- expect(store.state.lastCommitMsg).toBe(
- 'Your changes have been committed. Commit <a href="webUrl/-/commit/123" class="commit-sha">123</a> with 1 additions, 2 deletions.',
- );
-
- done();
- })
- .catch(done.fail);
+ it('sets last Commit Msg', async () => {
+ await store.dispatch('commit/commitChanges');
+ expect(store.state.lastCommitMsg).toBe(
+ 'Your changes have been committed. Commit <a href="webUrl/-/commit/123" class="commit-sha">123</a> with 1 additions, 2 deletions.',
+ );
});
- it('adds commit data to files', (done) => {
- store
- .dispatch('commit/commitChanges')
- .then(() => {
- expect(store.state.entries[store.state.openFiles[0].path].lastCommitSha).toBe(
- COMMIT_RESPONSE.id,
- );
-
- done();
- })
- .catch(done.fail);
+ it('adds commit data to files', async () => {
+ await store.dispatch('commit/commitChanges');
+ expect(store.state.entries[store.state.openFiles[0].path].lastCommitSha).toBe(
+ COMMIT_RESPONSE.id,
+ );
});
- it('resets stores commit actions', (done) => {
+ it('resets stores commit actions', async () => {
store.state.commit.commitAction = COMMIT_TO_NEW_BRANCH;
- store
- .dispatch('commit/commitChanges')
- .then(() => {
- expect(store.state.commit.commitAction).not.toBe(COMMIT_TO_NEW_BRANCH);
- })
- .then(done)
- .catch(done.fail);
+ await store.dispatch('commit/commitChanges');
+ expect(store.state.commit.commitAction).not.toBe(COMMIT_TO_NEW_BRANCH);
});
- it('removes all staged files', (done) => {
- store
- .dispatch('commit/commitChanges')
- .then(() => {
- expect(store.state.stagedFiles.length).toBe(0);
- })
- .then(done)
- .catch(done.fail);
+ it('removes all staged files', async () => {
+ await store.dispatch('commit/commitChanges');
+ expect(store.state.stagedFiles.length).toBe(0);
});
describe('merge request', () => {
- it('redirects to new merge request page', (done) => {
+ it('redirects to new merge request page', async () => {
jest.spyOn(eventHub, '$on').mockImplementation();
store.state.commit.commitAction = COMMIT_TO_NEW_BRANCH;
store.state.commit.shouldCreateMR = true;
- store
- .dispatch('commit/commitChanges')
- .then(() => {
- expect(visitUrl).toHaveBeenCalledWith(
- `webUrl/-/merge_requests/new?merge_request[source_branch]=${store.getters['commit/placeholderBranchName']}&merge_request[target_branch]=main&nav_source=webide`,
- );
-
- done();
- })
- .catch(done.fail);
+ await store.dispatch('commit/commitChanges');
+ expect(visitUrl).toHaveBeenCalledWith(
+ `webUrl/-/merge_requests/new?merge_request[source_branch]=${store.getters['commit/placeholderBranchName']}&merge_request[target_branch]=main&nav_source=webide`,
+ );
});
- it('does not redirect to new merge request page when shouldCreateMR is not checked', (done) => {
+ it('does not redirect to new merge request page when shouldCreateMR is not checked', async () => {
jest.spyOn(eventHub, '$on').mockImplementation();
store.state.commit.commitAction = COMMIT_TO_NEW_BRANCH;
store.state.commit.shouldCreateMR = false;
- store
- .dispatch('commit/commitChanges')
- .then(() => {
- expect(visitUrl).not.toHaveBeenCalled();
- done();
- })
- .catch(done.fail);
+ await store.dispatch('commit/commitChanges');
+ expect(visitUrl).not.toHaveBeenCalled();
});
it('does not redirect to merge request page if shouldCreateMR is checked, but branch is the default branch', async () => {
@@ -514,17 +419,11 @@ describe('IDE commit module actions', () => {
});
});
- it('shows failed message', (done) => {
- store
- .dispatch('commit/commitChanges')
- .then(() => {
- const alert = document.querySelector('.flash-container');
-
- expect(alert.textContent.trim()).toBe('failed message');
+ it('shows failed message', async () => {
+ await store.dispatch('commit/commitChanges');
+ const alert = document.querySelector('.flash-container');
- done();
- })
- .catch(done.fail);
+ expect(alert.textContent.trim()).toBe('failed message');
});
});
@@ -548,52 +447,37 @@ describe('IDE commit module actions', () => {
});
describe('first commit of a branch', () => {
- it('commits TOGGLE_EMPTY_STATE mutation on empty repo', (done) => {
+ it('commits TOGGLE_EMPTY_STATE mutation on empty repo', async () => {
jest.spyOn(service, 'commit').mockResolvedValue({ data: COMMIT_RESPONSE });
jest.spyOn(store, 'commit');
- store
- .dispatch('commit/commitChanges')
- .then(() => {
- expect(store.commit.mock.calls).toEqual(
- expect.arrayContaining([
- ['TOGGLE_EMPTY_STATE', expect.any(Object), expect.any(Object)],
- ]),
- );
- done();
- })
- .catch(done.fail);
+ await store.dispatch('commit/commitChanges');
+ expect(store.commit.mock.calls).toEqual(
+ expect.arrayContaining([['TOGGLE_EMPTY_STATE', expect.any(Object), expect.any(Object)]]),
+ );
});
- it('does not commmit TOGGLE_EMPTY_STATE mutation on existing project', (done) => {
+ it('does not commmit TOGGLE_EMPTY_STATE mutation on existing project', async () => {
COMMIT_RESPONSE.parent_ids.push('1234');
jest.spyOn(service, 'commit').mockResolvedValue({ data: COMMIT_RESPONSE });
jest.spyOn(store, 'commit');
- store
- .dispatch('commit/commitChanges')
- .then(() => {
- expect(store.commit.mock.calls).not.toEqual(
- expect.arrayContaining([
- ['TOGGLE_EMPTY_STATE', expect.any(Object), expect.any(Object)],
- ]),
- );
- done();
- })
- .catch(done.fail);
+ await store.dispatch('commit/commitChanges');
+ expect(store.commit.mock.calls).not.toEqual(
+ expect.arrayContaining([['TOGGLE_EMPTY_STATE', expect.any(Object), expect.any(Object)]]),
+ );
});
});
});
describe('toggleShouldCreateMR', () => {
- it('commits both toggle and interacting with MR checkbox actions', (done) => {
- testAction(
+ it('commits both toggle and interacting with MR checkbox actions', () => {
+ return testAction(
actions.toggleShouldCreateMR,
{},
store.state,
[{ type: mutationTypes.TOGGLE_SHOULD_CREATE_MR }],
[],
- done,
);
});
});
diff --git a/spec/frontend/ide/stores/modules/file_templates/actions_spec.js b/spec/frontend/ide/stores/modules/file_templates/actions_spec.js
index 9ff950b0875..1080a30d2d8 100644
--- a/spec/frontend/ide/stores/modules/file_templates/actions_spec.js
+++ b/spec/frontend/ide/stores/modules/file_templates/actions_spec.js
@@ -20,21 +20,20 @@ describe('IDE file templates actions', () => {
});
describe('requestTemplateTypes', () => {
- it('commits REQUEST_TEMPLATE_TYPES', (done) => {
- testAction(
+ it('commits REQUEST_TEMPLATE_TYPES', () => {
+ return testAction(
actions.requestTemplateTypes,
null,
state,
[{ type: types.REQUEST_TEMPLATE_TYPES }],
[],
- done,
);
});
});
describe('receiveTemplateTypesError', () => {
- it('commits RECEIVE_TEMPLATE_TYPES_ERROR and dispatches setErrorMessage', (done) => {
- testAction(
+ it('commits RECEIVE_TEMPLATE_TYPES_ERROR and dispatches setErrorMessage', () => {
+ return testAction(
actions.receiveTemplateTypesError,
null,
state,
@@ -49,20 +48,18 @@ describe('IDE file templates actions', () => {
},
},
],
- done,
);
});
});
describe('receiveTemplateTypesSuccess', () => {
- it('commits RECEIVE_TEMPLATE_TYPES_SUCCESS', (done) => {
- testAction(
+ it('commits RECEIVE_TEMPLATE_TYPES_SUCCESS', () => {
+ return testAction(
actions.receiveTemplateTypesSuccess,
'test',
state,
[{ type: types.RECEIVE_TEMPLATE_TYPES_SUCCESS, payload: 'test' }],
[],
- done,
);
});
});
@@ -81,23 +78,17 @@ describe('IDE file templates actions', () => {
});
});
- it('rejects if selectedTemplateType is empty', (done) => {
+ it('rejects if selectedTemplateType is empty', async () => {
const dispatch = jest.fn().mockName('dispatch');
- actions
- .fetchTemplateTypes({ dispatch, state })
- .then(done.fail)
- .catch(() => {
- expect(dispatch).not.toHaveBeenCalled();
-
- done();
- });
+ await expect(actions.fetchTemplateTypes({ dispatch, state })).rejects.toBeUndefined();
+ expect(dispatch).not.toHaveBeenCalled();
});
- it('dispatches actions', (done) => {
+ it('dispatches actions', () => {
state.selectedTemplateType = { key: 'licenses' };
- testAction(
+ return testAction(
actions.fetchTemplateTypes,
null,
state,
@@ -111,7 +102,6 @@ describe('IDE file templates actions', () => {
payload: pages[0].concat(pages[1]).concat(pages[2]),
},
],
- done,
);
});
});
@@ -121,16 +111,15 @@ describe('IDE file templates actions', () => {
mock.onGet(/api\/(.*)\/templates\/licenses/).replyOnce(500);
});
- it('dispatches actions', (done) => {
+ it('dispatches actions', () => {
state.selectedTemplateType = { key: 'licenses' };
- testAction(
+ return testAction(
actions.fetchTemplateTypes,
null,
state,
[],
[{ type: 'requestTemplateTypes' }, { type: 'receiveTemplateTypesError' }],
- done,
);
});
});
@@ -184,8 +173,8 @@ describe('IDE file templates actions', () => {
});
describe('receiveTemplateError', () => {
- it('dispatches setErrorMessage', (done) => {
- testAction(
+ it('dispatches setErrorMessage', () => {
+ return testAction(
actions.receiveTemplateError,
'test',
state,
@@ -201,7 +190,6 @@ describe('IDE file templates actions', () => {
},
},
],
- done,
);
});
});
@@ -217,46 +205,43 @@ describe('IDE file templates actions', () => {
.replyOnce(200, { content: 'testing content' });
});
- it('dispatches setFileTemplate if template already has content', (done) => {
+ it('dispatches setFileTemplate if template already has content', () => {
const template = { content: 'already has content' };
- testAction(
+ return testAction(
actions.fetchTemplate,
template,
state,
[],
[{ type: 'setFileTemplate', payload: template }],
- done,
);
});
- it('dispatches success', (done) => {
+ it('dispatches success', () => {
const template = { key: 'mit' };
state.selectedTemplateType = { key: 'licenses' };
- testAction(
+ return testAction(
actions.fetchTemplate,
template,
state,
[],
[{ type: 'setFileTemplate', payload: { content: 'MIT content' } }],
- done,
);
});
- it('dispatches success and uses name key for API call', (done) => {
+ it('dispatches success and uses name key for API call', () => {
const template = { name: 'testing' };
state.selectedTemplateType = { key: 'licenses' };
- testAction(
+ return testAction(
actions.fetchTemplate,
template,
state,
[],
[{ type: 'setFileTemplate', payload: { content: 'testing content' } }],
- done,
);
});
});
@@ -266,18 +251,17 @@ describe('IDE file templates actions', () => {
mock.onGet(/api\/(.*)\/templates\/licenses\/mit/).replyOnce(500);
});
- it('dispatches error', (done) => {
+ it('dispatches error', () => {
const template = { name: 'testing' };
state.selectedTemplateType = { key: 'licenses' };
- testAction(
+ return testAction(
actions.fetchTemplate,
template,
state,
[],
[{ type: 'receiveTemplateError', payload: template }],
- done,
);
});
});
diff --git a/spec/frontend/ide/stores/modules/merge_requests/actions_spec.js b/spec/frontend/ide/stores/modules/merge_requests/actions_spec.js
index e1f2b165dd9..344fe3a41c3 100644
--- a/spec/frontend/ide/stores/modules/merge_requests/actions_spec.js
+++ b/spec/frontend/ide/stores/modules/merge_requests/actions_spec.js
@@ -28,21 +28,20 @@ describe('IDE merge requests actions', () => {
});
describe('requestMergeRequests', () => {
- it('should commit request', (done) => {
- testAction(
+ it('should commit request', () => {
+ return testAction(
requestMergeRequests,
null,
mockedState,
[{ type: types.REQUEST_MERGE_REQUESTS }],
[],
- done,
);
});
});
describe('receiveMergeRequestsError', () => {
- it('should commit error', (done) => {
- testAction(
+ it('should commit error', () => {
+ return testAction(
receiveMergeRequestsError,
{ type: 'created', search: '' },
mockedState,
@@ -58,20 +57,18 @@ describe('IDE merge requests actions', () => {
},
},
],
- done,
);
});
});
describe('receiveMergeRequestsSuccess', () => {
- it('should commit received data', (done) => {
- testAction(
+ it('should commit received data', () => {
+ return testAction(
receiveMergeRequestsSuccess,
mergeRequests,
mockedState,
[{ type: types.RECEIVE_MERGE_REQUESTS_SUCCESS, payload: mergeRequests }],
[],
- done,
);
});
});
@@ -118,8 +115,8 @@ describe('IDE merge requests actions', () => {
});
});
- it('dispatches success with received data', (done) => {
- testAction(
+ it('dispatches success with received data', () => {
+ return testAction(
fetchMergeRequests,
{ type: 'created' },
mockedState,
@@ -129,7 +126,6 @@ describe('IDE merge requests actions', () => {
{ type: 'resetMergeRequests' },
{ type: 'receiveMergeRequestsSuccess', payload: mergeRequests },
],
- done,
);
});
});
@@ -156,8 +152,8 @@ describe('IDE merge requests actions', () => {
);
});
- it('dispatches success with received data', (done) => {
- testAction(
+ it('dispatches success with received data', () => {
+ return testAction(
fetchMergeRequests,
{ type: null },
{ ...mockedState, ...mockedRootState },
@@ -167,7 +163,6 @@ describe('IDE merge requests actions', () => {
{ type: 'resetMergeRequests' },
{ type: 'receiveMergeRequestsSuccess', payload: mergeRequests },
],
- done,
);
});
});
@@ -177,8 +172,8 @@ describe('IDE merge requests actions', () => {
mock.onGet(/\/api\/v4\/merge_requests(.*)$/).replyOnce(500);
});
- it('dispatches error', (done) => {
- testAction(
+ it('dispatches error', () => {
+ return testAction(
fetchMergeRequests,
{ type: 'created', search: '' },
mockedState,
@@ -188,21 +183,19 @@ describe('IDE merge requests actions', () => {
{ type: 'resetMergeRequests' },
{ type: 'receiveMergeRequestsError', payload: { type: 'created', search: '' } },
],
- done,
);
});
});
});
describe('resetMergeRequests', () => {
- it('commits reset', (done) => {
- testAction(
+ it('commits reset', () => {
+ return testAction(
resetMergeRequests,
null,
mockedState,
[{ type: types.RESET_MERGE_REQUESTS }],
[],
- done,
);
});
});
diff --git a/spec/frontend/ide/stores/modules/pane/actions_spec.js b/spec/frontend/ide/stores/modules/pane/actions_spec.js
index 42fe8b400b8..98c4f22dac8 100644
--- a/spec/frontend/ide/stores/modules/pane/actions_spec.js
+++ b/spec/frontend/ide/stores/modules/pane/actions_spec.js
@@ -7,19 +7,19 @@ describe('IDE pane module actions', () => {
const TEST_VIEW_KEEP_ALIVE = { name: 'test-keep-alive', keepAlive: true };
describe('toggleOpen', () => {
- it('dispatches open if closed', (done) => {
- testAction(actions.toggleOpen, TEST_VIEW, { isOpen: false }, [], [{ type: 'open' }], done);
+ it('dispatches open if closed', () => {
+ return testAction(actions.toggleOpen, TEST_VIEW, { isOpen: false }, [], [{ type: 'open' }]);
});
- it('dispatches close if opened', (done) => {
- testAction(actions.toggleOpen, TEST_VIEW, { isOpen: true }, [], [{ type: 'close' }], done);
+ it('dispatches close if opened', () => {
+ return testAction(actions.toggleOpen, TEST_VIEW, { isOpen: true }, [], [{ type: 'close' }]);
});
});
describe('open', () => {
describe('with a view specified', () => {
- it('commits SET_OPEN and SET_CURRENT_VIEW', (done) => {
- testAction(
+ it('commits SET_OPEN and SET_CURRENT_VIEW', () => {
+ return testAction(
actions.open,
TEST_VIEW,
{},
@@ -28,12 +28,11 @@ describe('IDE pane module actions', () => {
{ type: types.SET_CURRENT_VIEW, payload: TEST_VIEW.name },
],
[],
- done,
);
});
- it('commits KEEP_ALIVE_VIEW if keepAlive is true', (done) => {
- testAction(
+ it('commits KEEP_ALIVE_VIEW if keepAlive is true', () => {
+ return testAction(
actions.open,
TEST_VIEW_KEEP_ALIVE,
{},
@@ -43,28 +42,26 @@ describe('IDE pane module actions', () => {
{ type: types.KEEP_ALIVE_VIEW, payload: TEST_VIEW_KEEP_ALIVE.name },
],
[],
- done,
);
});
});
describe('without a view specified', () => {
- it('commits SET_OPEN', (done) => {
- testAction(
+ it('commits SET_OPEN', () => {
+ return testAction(
actions.open,
undefined,
{},
[{ type: types.SET_OPEN, payload: true }],
[],
- done,
);
});
});
});
describe('close', () => {
- it('commits SET_OPEN', (done) => {
- testAction(actions.close, null, {}, [{ type: types.SET_OPEN, payload: false }], [], done);
+ it('commits SET_OPEN', () => {
+ return testAction(actions.close, null, {}, [{ type: types.SET_OPEN, payload: false }], []);
});
});
});
diff --git a/spec/frontend/ide/stores/modules/pipelines/actions_spec.js b/spec/frontend/ide/stores/modules/pipelines/actions_spec.js
index 3ede37e2eed..b76b673c3a2 100644
--- a/spec/frontend/ide/stores/modules/pipelines/actions_spec.js
+++ b/spec/frontend/ide/stores/modules/pipelines/actions_spec.js
@@ -25,6 +25,7 @@ import {
import * as types from '~/ide/stores/modules/pipelines/mutation_types';
import state from '~/ide/stores/modules/pipelines/state';
import axios from '~/lib/utils/axios_utils';
+import waitForPromises from 'helpers/wait_for_promises';
import { pipelines, jobs } from '../../../mock_data';
describe('IDE pipelines actions', () => {
@@ -44,32 +45,30 @@ describe('IDE pipelines actions', () => {
});
describe('requestLatestPipeline', () => {
- it('commits request', (done) => {
- testAction(
+ it('commits request', () => {
+ return testAction(
requestLatestPipeline,
null,
mockedState,
[{ type: types.REQUEST_LATEST_PIPELINE }],
[],
- done,
);
});
});
describe('receiveLatestPipelineError', () => {
- it('commits error', (done) => {
- testAction(
+ it('commits error', () => {
+ return testAction(
receiveLatestPipelineError,
{ status: 404 },
mockedState,
[{ type: types.RECEIVE_LASTEST_PIPELINE_ERROR }],
[{ type: 'stopPipelinePolling' }],
- done,
);
});
- it('dispatches setErrorMessage is not 404', (done) => {
- testAction(
+ it('dispatches setErrorMessage is not 404', () => {
+ return testAction(
receiveLatestPipelineError,
{ status: 500 },
mockedState,
@@ -86,7 +85,6 @@ describe('IDE pipelines actions', () => {
},
{ type: 'stopPipelinePolling' },
],
- done,
);
});
});
@@ -123,7 +121,7 @@ describe('IDE pipelines actions', () => {
.reply(200, { data: { foo: 'bar' } }, { 'poll-interval': '10000' });
});
- it('dispatches request', (done) => {
+ it('dispatches request', async () => {
jest.spyOn(axios, 'get');
jest.spyOn(Visibility, 'hidden').mockReturnValue(false);
@@ -133,34 +131,21 @@ describe('IDE pipelines actions', () => {
currentProject: { path_with_namespace: 'abc/def' },
};
- fetchLatestPipeline({ dispatch, rootGetters });
+ await fetchLatestPipeline({ dispatch, rootGetters });
expect(dispatch).toHaveBeenCalledWith('requestLatestPipeline');
- jest.advanceTimersByTime(1000);
-
- new Promise((resolve) => requestAnimationFrame(resolve))
- .then(() => {
- expect(axios.get).toHaveBeenCalled();
- expect(axios.get).toHaveBeenCalledTimes(1);
- expect(dispatch).toHaveBeenCalledWith(
- 'receiveLatestPipelineSuccess',
- expect.anything(),
- );
-
- jest.advanceTimersByTime(10000);
- })
- .then(() => new Promise((resolve) => requestAnimationFrame(resolve)))
- .then(() => {
- expect(axios.get).toHaveBeenCalled();
- expect(axios.get).toHaveBeenCalledTimes(2);
- expect(dispatch).toHaveBeenCalledWith(
- 'receiveLatestPipelineSuccess',
- expect.anything(),
- );
- })
- .then(done)
- .catch(done.fail);
+ await waitForPromises();
+
+ expect(axios.get).toHaveBeenCalled();
+ expect(axios.get).toHaveBeenCalledTimes(1);
+ expect(dispatch).toHaveBeenCalledWith('receiveLatestPipelineSuccess', expect.anything());
+
+ jest.advanceTimersByTime(10000);
+
+ expect(axios.get).toHaveBeenCalled();
+ expect(axios.get).toHaveBeenCalledTimes(2);
+ expect(dispatch).toHaveBeenCalledWith('receiveLatestPipelineSuccess', expect.anything());
});
});
@@ -169,27 +154,22 @@ describe('IDE pipelines actions', () => {
mock.onGet('/abc/def/commit/abc123def456ghi789jkl/pipelines').reply(500);
});
- it('dispatches error', (done) => {
+ it('dispatches error', async () => {
const dispatch = jest.fn().mockName('dispatch');
const rootGetters = {
lastCommit: { id: 'abc123def456ghi789jkl' },
currentProject: { path_with_namespace: 'abc/def' },
};
- fetchLatestPipeline({ dispatch, rootGetters });
+ await fetchLatestPipeline({ dispatch, rootGetters });
- jest.advanceTimersByTime(1500);
+ await waitForPromises();
- new Promise((resolve) => requestAnimationFrame(resolve))
- .then(() => {
- expect(dispatch).toHaveBeenCalledWith('receiveLatestPipelineError', expect.anything());
- })
- .then(done)
- .catch(done.fail);
+ expect(dispatch).toHaveBeenCalledWith('receiveLatestPipelineError', expect.anything());
});
});
- it('sets latest pipeline to `null` and stops polling on empty project', (done) => {
+ it('sets latest pipeline to `null` and stops polling on empty project', () => {
mockedState = {
...mockedState,
rootGetters: {
@@ -197,26 +177,31 @@ describe('IDE pipelines actions', () => {
},
};
- testAction(
+ return testAction(
fetchLatestPipeline,
{},
mockedState,
[{ type: types.RECEIVE_LASTEST_PIPELINE_SUCCESS, payload: null }],
[{ type: 'stopPipelinePolling' }],
- done,
);
});
});
describe('requestJobs', () => {
- it('commits request', (done) => {
- testAction(requestJobs, 1, mockedState, [{ type: types.REQUEST_JOBS, payload: 1 }], [], done);
+ it('commits request', () => {
+ return testAction(
+ requestJobs,
+ 1,
+ mockedState,
+ [{ type: types.REQUEST_JOBS, payload: 1 }],
+ [],
+ );
});
});
describe('receiveJobsError', () => {
- it('commits error', (done) => {
- testAction(
+ it('commits error', () => {
+ return testAction(
receiveJobsError,
{ id: 1 },
mockedState,
@@ -232,20 +217,18 @@ describe('IDE pipelines actions', () => {
},
},
],
- done,
);
});
});
describe('receiveJobsSuccess', () => {
- it('commits data', (done) => {
- testAction(
+ it('commits data', () => {
+ return testAction(
receiveJobsSuccess,
{ id: 1, data: jobs },
mockedState,
[{ type: types.RECEIVE_JOBS_SUCCESS, payload: { id: 1, data: jobs } }],
[],
- done,
);
});
});
@@ -258,8 +241,8 @@ describe('IDE pipelines actions', () => {
mock.onGet(stage.dropdownPath).replyOnce(200, jobs);
});
- it('dispatches request', (done) => {
- testAction(
+ it('dispatches request', () => {
+ return testAction(
fetchJobs,
stage,
mockedState,
@@ -268,7 +251,6 @@ describe('IDE pipelines actions', () => {
{ type: 'requestJobs', payload: stage.id },
{ type: 'receiveJobsSuccess', payload: { id: stage.id, data: jobs } },
],
- done,
);
});
});
@@ -278,8 +260,8 @@ describe('IDE pipelines actions', () => {
mock.onGet(stage.dropdownPath).replyOnce(500);
});
- it('dispatches error', (done) => {
- testAction(
+ it('dispatches error', () => {
+ return testAction(
fetchJobs,
stage,
mockedState,
@@ -288,69 +270,64 @@ describe('IDE pipelines actions', () => {
{ type: 'requestJobs', payload: stage.id },
{ type: 'receiveJobsError', payload: stage },
],
- done,
);
});
});
});
describe('toggleStageCollapsed', () => {
- it('commits collapse', (done) => {
- testAction(
+ it('commits collapse', () => {
+ return testAction(
toggleStageCollapsed,
1,
mockedState,
[{ type: types.TOGGLE_STAGE_COLLAPSE, payload: 1 }],
[],
- done,
);
});
});
describe('setDetailJob', () => {
- it('commits job', (done) => {
- testAction(
+ it('commits job', () => {
+ return testAction(
setDetailJob,
'job',
mockedState,
[{ type: types.SET_DETAIL_JOB, payload: 'job' }],
[{ type: 'rightPane/open', payload: rightSidebarViews.jobsDetail }],
- done,
);
});
- it('dispatches rightPane/open as pipeline when job is null', (done) => {
- testAction(
+ it('dispatches rightPane/open as pipeline when job is null', () => {
+ return testAction(
setDetailJob,
null,
mockedState,
[{ type: types.SET_DETAIL_JOB, payload: null }],
[{ type: 'rightPane/open', payload: rightSidebarViews.pipelines }],
- done,
);
});
- it('dispatches rightPane/open as job', (done) => {
- testAction(
+ it('dispatches rightPane/open as job', () => {
+ return testAction(
setDetailJob,
'job',
mockedState,
[{ type: types.SET_DETAIL_JOB, payload: 'job' }],
[{ type: 'rightPane/open', payload: rightSidebarViews.jobsDetail }],
- done,
);
});
});
describe('requestJobLogs', () => {
- it('commits request', (done) => {
- testAction(requestJobLogs, null, mockedState, [{ type: types.REQUEST_JOB_LOGS }], [], done);
+ it('commits request', () => {
+ return testAction(requestJobLogs, null, mockedState, [{ type: types.REQUEST_JOB_LOGS }], []);
});
});
describe('receiveJobLogsError', () => {
- it('commits error', (done) => {
- testAction(
+ it('commits error', () => {
+ return testAction(
receiveJobLogsError,
null,
mockedState,
@@ -366,20 +343,18 @@ describe('IDE pipelines actions', () => {
},
},
],
- done,
);
});
});
describe('receiveJobLogsSuccess', () => {
- it('commits data', (done) => {
- testAction(
+ it('commits data', () => {
+ return testAction(
receiveJobLogsSuccess,
'data',
mockedState,
[{ type: types.RECEIVE_JOB_LOGS_SUCCESS, payload: 'data' }],
[],
- done,
);
});
});
@@ -395,8 +370,8 @@ describe('IDE pipelines actions', () => {
mock.onGet(`${TEST_HOST}/project/builds/trace`).replyOnce(200, { html: 'html' });
});
- it('dispatches request', (done) => {
- testAction(
+ it('dispatches request', () => {
+ return testAction(
fetchJobLogs,
null,
mockedState,
@@ -405,7 +380,6 @@ describe('IDE pipelines actions', () => {
{ type: 'requestJobLogs' },
{ type: 'receiveJobLogsSuccess', payload: { html: 'html' } },
],
- done,
);
});
@@ -426,22 +400,21 @@ describe('IDE pipelines actions', () => {
mock.onGet(`${TEST_HOST}/project/builds/trace`).replyOnce(500);
});
- it('dispatches error', (done) => {
- testAction(
+ it('dispatches error', () => {
+ return testAction(
fetchJobLogs,
null,
mockedState,
[],
[{ type: 'requestJobLogs' }, { type: 'receiveJobLogsError' }],
- done,
);
});
});
});
describe('resetLatestPipeline', () => {
- it('commits reset mutations', (done) => {
- testAction(
+ it('commits reset mutations', () => {
+ return testAction(
resetLatestPipeline,
null,
mockedState,
@@ -450,7 +423,6 @@ describe('IDE pipelines actions', () => {
{ type: types.SET_DETAIL_JOB, payload: null },
],
[],
- done,
);
});
});
diff --git a/spec/frontend/ide/stores/modules/terminal_sync/actions_spec.js b/spec/frontend/ide/stores/modules/terminal_sync/actions_spec.js
index 22b0615c6d0..448fd909f39 100644
--- a/spec/frontend/ide/stores/modules/terminal_sync/actions_spec.js
+++ b/spec/frontend/ide/stores/modules/terminal_sync/actions_spec.js
@@ -22,43 +22,37 @@ describe('ide/stores/modules/terminal_sync/actions', () => {
});
describe('upload', () => {
- it('uploads to mirror and sets success', (done) => {
+ it('uploads to mirror and sets success', async () => {
mirror.upload.mockReturnValue(Promise.resolve());
- testAction(
+ await testAction(
actions.upload,
null,
rootState,
[{ type: types.START_LOADING }, { type: types.SET_SUCCESS }],
[],
- () => {
- expect(mirror.upload).toHaveBeenCalledWith(rootState);
- done();
- },
);
+ expect(mirror.upload).toHaveBeenCalledWith(rootState);
});
- it('sets error when failed', (done) => {
+ it('sets error when failed', () => {
const err = { message: 'it failed!' };
mirror.upload.mockReturnValue(Promise.reject(err));
- testAction(
+ return testAction(
actions.upload,
null,
rootState,
[{ type: types.START_LOADING }, { type: types.SET_ERROR, payload: err }],
[],
- done,
);
});
});
describe('stop', () => {
- it('disconnects from mirror', (done) => {
- testAction(actions.stop, null, rootState, [{ type: types.STOP }], [], () => {
- expect(mirror.disconnect).toHaveBeenCalled();
- done();
- });
+ it('disconnects from mirror', async () => {
+ await testAction(actions.stop, null, rootState, [{ type: types.STOP }], []);
+ expect(mirror.disconnect).toHaveBeenCalled();
});
});
@@ -83,20 +77,17 @@ describe('ide/stores/modules/terminal_sync/actions', () => {
};
});
- it('connects to mirror and sets success', (done) => {
+ it('connects to mirror and sets success', async () => {
mirror.connect.mockReturnValue(Promise.resolve());
- testAction(
+ await testAction(
actions.start,
null,
rootState,
[{ type: types.START_LOADING }, { type: types.SET_SUCCESS }],
[],
- () => {
- expect(mirror.connect).toHaveBeenCalledWith(TEST_SESSION.proxyWebsocketPath);
- done();
- },
);
+ expect(mirror.connect).toHaveBeenCalledWith(TEST_SESSION.proxyWebsocketPath);
});
it('sets error if connection fails', () => {
diff --git a/spec/frontend/ide/stores/plugins/terminal_spec.js b/spec/frontend/ide/stores/plugins/terminal_spec.js
index 912de88cb39..193300540fd 100644
--- a/spec/frontend/ide/stores/plugins/terminal_spec.js
+++ b/spec/frontend/ide/stores/plugins/terminal_spec.js
@@ -6,10 +6,10 @@ import { SET_BRANCH_WORKING_REFERENCE } from '~/ide/stores/mutation_types';
import createTerminalPlugin from '~/ide/stores/plugins/terminal';
const TEST_DATASET = {
- eeWebTerminalSvgPath: `${TEST_HOST}/web/terminal/svg`,
- eeWebTerminalHelpPath: `${TEST_HOST}/web/terminal/help`,
- eeWebTerminalConfigHelpPath: `${TEST_HOST}/web/terminal/config/help`,
- eeWebTerminalRunnersHelpPath: `${TEST_HOST}/web/terminal/runners/help`,
+ webTerminalSvgPath: `${TEST_HOST}/web/terminal/svg`,
+ webTerminalHelpPath: `${TEST_HOST}/web/terminal/help`,
+ webTerminalConfigHelpPath: `${TEST_HOST}/web/terminal/config/help`,
+ webTerminalRunnersHelpPath: `${TEST_HOST}/web/terminal/runners/help`,
};
Vue.use(Vuex);
@@ -40,10 +40,10 @@ describe('ide/stores/extend', () => {
it('dispatches terminal/setPaths', () => {
expect(store.dispatch).toHaveBeenCalledWith('terminal/setPaths', {
- webTerminalSvgPath: TEST_DATASET.eeWebTerminalSvgPath,
- webTerminalHelpPath: TEST_DATASET.eeWebTerminalHelpPath,
- webTerminalConfigHelpPath: TEST_DATASET.eeWebTerminalConfigHelpPath,
- webTerminalRunnersHelpPath: TEST_DATASET.eeWebTerminalRunnersHelpPath,
+ webTerminalSvgPath: TEST_DATASET.webTerminalSvgPath,
+ webTerminalHelpPath: TEST_DATASET.webTerminalHelpPath,
+ webTerminalConfigHelpPath: TEST_DATASET.webTerminalConfigHelpPath,
+ webTerminalRunnersHelpPath: TEST_DATASET.webTerminalRunnersHelpPath,
});
});
diff --git a/spec/frontend/image_diff/init_discussion_tab_spec.js b/spec/frontend/image_diff/init_discussion_tab_spec.js
index 5bc0c738944..f6f05037c95 100644
--- a/spec/frontend/image_diff/init_discussion_tab_spec.js
+++ b/spec/frontend/image_diff/init_discussion_tab_spec.js
@@ -11,23 +11,21 @@ describe('initDiscussionTab', () => {
`);
});
- it('should pass canCreateNote as false to initImageDiff', (done) => {
+ it('should pass canCreateNote as false to initImageDiff', () => {
jest
.spyOn(initImageDiffHelper, 'initImageDiff')
.mockImplementation((diffFileEl, canCreateNote) => {
expect(canCreateNote).toEqual(false);
- done();
});
initDiscussionTab();
});
- it('should pass renderCommentBadge as true to initImageDiff', (done) => {
+ it('should pass renderCommentBadge as true to initImageDiff', () => {
jest
.spyOn(initImageDiffHelper, 'initImageDiff')
.mockImplementation((diffFileEl, canCreateNote, renderCommentBadge) => {
expect(renderCommentBadge).toEqual(true);
- done();
});
initDiscussionTab();
diff --git a/spec/frontend/image_diff/replaced_image_diff_spec.js b/spec/frontend/image_diff/replaced_image_diff_spec.js
index cc4a2530fc4..2b401fc46bf 100644
--- a/spec/frontend/image_diff/replaced_image_diff_spec.js
+++ b/spec/frontend/image_diff/replaced_image_diff_spec.js
@@ -176,34 +176,36 @@ describe('ReplacedImageDiff', () => {
expect(ImageDiff.prototype.bindEvents).toHaveBeenCalled();
});
- it('should register click eventlistener to 2-up view mode', (done) => {
- jest.spyOn(ReplacedImageDiff.prototype, 'changeView').mockImplementation((viewMode) => {
- expect(viewMode).toEqual(viewTypes.TWO_UP);
- done();
- });
+ it('should register click eventlistener to 2-up view mode', () => {
+ const changeViewSpy = jest
+ .spyOn(ReplacedImageDiff.prototype, 'changeView')
+ .mockImplementation(() => {});
replacedImageDiff.bindEvents();
replacedImageDiff.viewModesEls[viewTypes.TWO_UP].click();
+
+ expect(changeViewSpy).toHaveBeenCalledWith(viewTypes.TWO_UP, expect.any(Object));
});
- it('should register click eventlistener to swipe view mode', (done) => {
- jest.spyOn(ReplacedImageDiff.prototype, 'changeView').mockImplementation((viewMode) => {
- expect(viewMode).toEqual(viewTypes.SWIPE);
- done();
- });
+ it('should register click eventlistener to swipe view mode', () => {
+ const changeViewSpy = jest
+ .spyOn(ReplacedImageDiff.prototype, 'changeView')
+ .mockImplementation(() => {});
replacedImageDiff.bindEvents();
replacedImageDiff.viewModesEls[viewTypes.SWIPE].click();
+
+ expect(changeViewSpy).toHaveBeenCalledWith(viewTypes.SWIPE, expect.any(Object));
});
- it('should register click eventlistener to onion skin view mode', (done) => {
- jest.spyOn(ReplacedImageDiff.prototype, 'changeView').mockImplementation((viewMode) => {
- expect(viewMode).toEqual(viewTypes.SWIPE);
- done();
- });
+ it('should register click eventlistener to onion skin view mode', () => {
+ const changeViewSpy = jest
+ .spyOn(ReplacedImageDiff.prototype, 'changeView')
+ .mockImplementation(() => {});
replacedImageDiff.bindEvents();
replacedImageDiff.viewModesEls[viewTypes.SWIPE].click();
+ expect(changeViewSpy).toHaveBeenCalledWith(viewTypes.SWIPE, expect.any(Object));
});
});
@@ -325,32 +327,34 @@ describe('ReplacedImageDiff', () => {
setupImageFrameEls();
});
- it('should pass showCommentIndicator normalized indicator values', (done) => {
+ it('should pass showCommentIndicator normalized indicator values', () => {
jest.spyOn(imageDiffHelper, 'showCommentIndicator').mockImplementation(() => {});
- jest
+ const resizeCoordinatesToImageElementSpy = jest
.spyOn(imageDiffHelper, 'resizeCoordinatesToImageElement')
- .mockImplementation((imageEl, meta) => {
- expect(meta.x).toEqual(indicator.x);
- expect(meta.y).toEqual(indicator.y);
- expect(meta.width).toEqual(indicator.image.width);
- expect(meta.height).toEqual(indicator.image.height);
- done();
- });
+ .mockImplementation(() => {});
+
replacedImageDiff.renderNewView(indicator);
+
+ expect(resizeCoordinatesToImageElementSpy).toHaveBeenCalledWith(undefined, {
+ x: indicator.x,
+ y: indicator.y,
+ width: indicator.image.width,
+ height: indicator.image.height,
+ });
});
- it('should call showCommentIndicator', (done) => {
+ it('should call showCommentIndicator', () => {
const normalized = {
normalized: true,
};
jest.spyOn(imageDiffHelper, 'resizeCoordinatesToImageElement').mockReturnValue(normalized);
- jest
+ const showCommentIndicatorSpy = jest
.spyOn(imageDiffHelper, 'showCommentIndicator')
- .mockImplementation((imageFrameEl, normalizedIndicator) => {
- expect(normalizedIndicator).toEqual(normalized);
- done();
- });
+ .mockImplementation(() => {});
+
replacedImageDiff.renderNewView(indicator);
+
+ expect(showCommentIndicatorSpy).toHaveBeenCalledWith(undefined, normalized);
});
});
});
diff --git a/spec/frontend/import_entities/components/import_status_spec.js b/spec/frontend/import_entities/components/import_status_spec.js
new file mode 100644
index 00000000000..686a21e3923
--- /dev/null
+++ b/spec/frontend/import_entities/components/import_status_spec.js
@@ -0,0 +1,145 @@
+import { GlAccordionItem, GlBadge, GlIcon } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import ImportStatus from '~/import_entities/components/import_status.vue';
+import { STATUSES } from '~/import_entities/constants';
+
+describe('Import entities status component', () => {
+ let wrapper;
+
+ const createComponent = (propsData) => {
+ wrapper = shallowMount(ImportStatus, {
+ propsData,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('success status', () => {
+ const getStatusText = () => wrapper.findComponent(GlBadge).text();
+
+ it('displays finished status as complete when no stats are provided', () => {
+ createComponent({
+ status: STATUSES.FINISHED,
+ });
+ expect(getStatusText()).toBe('Complete');
+ });
+
+ it('displays finished status as complete when all stats items were processed', () => {
+ const statItems = { label: 100, note: 200 };
+
+ createComponent({
+ status: STATUSES.FINISHED,
+ stats: {
+ fetched: { ...statItems },
+ imported: { ...statItems },
+ },
+ });
+
+ expect(getStatusText()).toBe('Complete');
+ });
+
+ it('displays finished status as partial when all stats items were processed', () => {
+ const statItems = { label: 100, note: 200 };
+
+ createComponent({
+ status: STATUSES.FINISHED,
+ stats: {
+ fetched: { ...statItems },
+ imported: { ...statItems, label: 50 },
+ },
+ });
+
+ expect(getStatusText()).toBe('Partial import');
+ });
+ });
+
+ describe('details drawer', () => {
+ const findDetailsDrawer = () => wrapper.findComponent(GlAccordionItem);
+
+ it('renders details drawer to be present when stats are provided', () => {
+ createComponent({
+ status: 'created',
+ stats: { fetched: { label: 1 }, imported: { label: 0 } },
+ });
+
+ expect(findDetailsDrawer().exists()).toBe(true);
+ });
+
+ it('does not render details drawer when no stats are provided', () => {
+ createComponent({
+ status: 'created',
+ });
+
+ expect(findDetailsDrawer().exists()).toBe(false);
+ });
+
+ it('does not render details drawer when stats are empty', () => {
+ createComponent({
+ status: 'created',
+ stats: { fetched: {}, imported: {} },
+ });
+
+ expect(findDetailsDrawer().exists()).toBe(false);
+ });
+
+ it('does not render details drawer when no known stats are provided', () => {
+ createComponent({
+ status: 'created',
+ stats: {
+ fetched: {
+ UNKNOWN_STAT: 100,
+ },
+ imported: {
+ UNKNOWN_STAT: 0,
+ },
+ },
+ });
+
+ expect(findDetailsDrawer().exists()).toBe(false);
+ });
+ });
+
+ describe('stats display', () => {
+ const getStatusIcon = () =>
+ wrapper.findComponent(GlAccordionItem).findComponent(GlIcon).props().name;
+
+ const createComponentWithStats = ({ fetched, imported }) => {
+ createComponent({
+ status: 'created',
+ stats: {
+ fetched: { label: fetched },
+ imported: { label: imported },
+ },
+ });
+ };
+
+ it('displays scheduled status when imported is 0', () => {
+ createComponentWithStats({
+ fetched: 100,
+ imported: 0,
+ });
+
+ expect(getStatusIcon()).toBe('status-scheduled');
+ });
+
+ it('displays running status when imported is not equal to fetched', () => {
+ createComponentWithStats({
+ fetched: 100,
+ imported: 10,
+ });
+
+ expect(getStatusIcon()).toBe('status-running');
+ });
+
+ it('displays success status when imported is equal to fetched', () => {
+ createComponentWithStats({
+ fetched: 100,
+ imported: 100,
+ });
+
+ expect(getStatusIcon()).toBe('status-success');
+ });
+ });
+});
diff --git a/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js b/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js
index 16adf88700f..88fcedd31b2 100644
--- a/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js
+++ b/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js
@@ -31,7 +31,7 @@ describe('ImportProjectsTable', () => {
const findImportAllButton = () =>
wrapper
.findAll(GlButton)
- .filter((w) => w.props().variant === 'success')
+ .filter((w) => w.props().variant === 'confirm')
.at(0);
const findImportAllModal = () => wrapper.find({ ref: 'importAllModal' });
diff --git a/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js b/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js
index c8afa9ea57d..41a005199e1 100644
--- a/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js
+++ b/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js
@@ -98,6 +98,8 @@ describe('ProviderRepoTableRow', () => {
});
describe('when rendering imported project', () => {
+ const FAKE_STATS = {};
+
const repo = {
importSource: {
id: 'remote-1',
@@ -109,6 +111,7 @@ describe('ProviderRepoTableRow', () => {
fullPath: 'fullPath',
importSource: 'importSource',
importStatus: STATUSES.FINISHED,
+ stats: FAKE_STATS,
},
};
@@ -134,6 +137,10 @@ describe('ProviderRepoTableRow', () => {
it('does not render import button', () => {
expect(findImportButton().exists()).toBe(false);
});
+
+ it('passes stats to import status component', () => {
+ expect(wrapper.find(ImportStatus).props().stats).toBe(FAKE_STATS);
+ });
});
describe('when rendering incompatible project', () => {
diff --git a/spec/frontend/import_entities/import_projects/store/mutations_spec.js b/spec/frontend/import_entities/import_projects/store/mutations_spec.js
index e062d889325..77fae951300 100644
--- a/spec/frontend/import_entities/import_projects/store/mutations_spec.js
+++ b/spec/frontend/import_entities/import_projects/store/mutations_spec.js
@@ -232,6 +232,35 @@ describe('import_projects store mutations', () => {
updatedProjects[0].importStatus,
);
});
+
+ it('updates import stats of project', () => {
+ const repoId = 1;
+ state = {
+ repositories: [
+ { importedProject: { id: repoId, stats: {} }, importStatus: STATUSES.STARTED },
+ ],
+ };
+ const newStats = {
+ fetched: {
+ label: 10,
+ },
+ imported: {
+ label: 1,
+ },
+ };
+
+ const updatedProjects = [
+ {
+ id: repoId,
+ importStatus: STATUSES.FINISHED,
+ stats: newStats,
+ },
+ ];
+
+ mutations[types.RECEIVE_JOBS_SUCCESS](state, updatedProjects);
+
+ expect(state.repositories[0].importedProject.stats).toStrictEqual(newStats);
+ });
});
describe(`${types.REQUEST_NAMESPACES}`, () => {
diff --git a/spec/frontend/incidents/components/incidents_list_spec.js b/spec/frontend/incidents/components/incidents_list_spec.js
index 9ed0294e876..a556f3c17f3 100644
--- a/spec/frontend/incidents/components/incidents_list_spec.js
+++ b/spec/frontend/incidents/components/incidents_list_spec.js
@@ -7,6 +7,7 @@ import {
I18N,
TH_CREATED_AT_TEST_ID,
TH_SEVERITY_TEST_ID,
+ TH_ESCALATION_STATUS_TEST_ID,
TH_PUBLISHED_TEST_ID,
TH_INCIDENT_SLA_TEST_ID,
trackIncidentCreateNewOptions,
@@ -170,6 +171,7 @@ describe('Incidents List', () => {
expect(link.text()).toBe(title);
expect(link.attributes('href')).toContain(`issues/incident/${iid}`);
+ expect(link.find('.gl-text-truncate').exists()).toBe(true);
});
describe('Assignees', () => {
@@ -200,15 +202,14 @@ describe('Incidents List', () => {
describe('Escalation status', () => {
it('renders escalation status per row', () => {
- expect(findEscalationStatus().length).toBe(mockIncidents.length);
-
- const actualStatuses = findEscalationStatus().wrappers.map((status) => status.text());
- expect(actualStatuses).toEqual([
- 'Triggered',
- 'Acknowledged',
- 'Resolved',
- I18N.noEscalationStatus,
- ]);
+ const statuses = findEscalationStatus().wrappers;
+ const expectedStatuses = ['Triggered', 'Acknowledged', 'Resolved', I18N.noEscalationStatus];
+
+ expect(statuses.length).toBe(mockIncidents.length);
+ statuses.forEach((status, index) => {
+ expect(status.text()).toEqual(expectedStatuses[index]);
+ expect(status.classes('gl-text-truncate')).toBe(true);
+ });
});
describe('when feature is disabled', () => {
@@ -294,11 +295,12 @@ describe('Incidents List', () => {
const noneSort = 'none';
it.each`
- description | selector | initialSort | firstSort | nextSort
- ${'creation date'} | ${TH_CREATED_AT_TEST_ID} | ${descSort} | ${ascSort} | ${descSort}
- ${'severity'} | ${TH_SEVERITY_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort}
- ${'publish date'} | ${TH_PUBLISHED_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort}
- ${'due date'} | ${TH_INCIDENT_SLA_TEST_ID} | ${noneSort} | ${ascSort} | ${descSort}
+ description | selector | initialSort | firstSort | nextSort
+ ${'creation date'} | ${TH_CREATED_AT_TEST_ID} | ${descSort} | ${ascSort} | ${descSort}
+ ${'severity'} | ${TH_SEVERITY_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort}
+ ${'status'} | ${TH_ESCALATION_STATUS_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort}
+ ${'publish date'} | ${TH_PUBLISHED_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort}
+ ${'due date'} | ${TH_INCIDENT_SLA_TEST_ID} | ${noneSort} | ${ascSort} | ${descSort}
`(
'updates sort with new direction when sorting by $description',
async ({ selector, initialSort, firstSort, nextSort }) => {
diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js
index c4569070d09..ca481e009cf 100644
--- a/spec/frontend/integrations/edit/components/integration_form_spec.js
+++ b/spec/frontend/integrations/edit/components/integration_form_spec.js
@@ -9,8 +9,6 @@ import ActiveCheckbox from '~/integrations/edit/components/active_checkbox.vue';
import ConfirmationModal from '~/integrations/edit/components/confirmation_modal.vue';
import DynamicField from '~/integrations/edit/components/dynamic_field.vue';
import IntegrationForm from '~/integrations/edit/components/integration_form.vue';
-import JiraIssuesFields from '~/integrations/edit/components/jira_issues_fields.vue';
-import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_fields.vue';
import OverrideDropdown from '~/integrations/edit/components/override_dropdown.vue';
import ResetConfirmationModal from '~/integrations/edit/components/reset_confirmation_modal.vue';
import TriggerFields from '~/integrations/edit/components/trigger_fields.vue';
@@ -55,7 +53,6 @@ describe('IntegrationForm', () => {
OverrideDropdown,
ActiveCheckbox,
ConfirmationModal,
- JiraTriggerFields,
TriggerFields,
},
mocks: {
@@ -74,8 +71,6 @@ describe('IntegrationForm', () => {
const findProjectSaveButton = () => wrapper.findByTestId('save-button');
const findInstanceOrGroupSaveButton = () => wrapper.findByTestId('save-button-instance-group');
const findTestButton = () => wrapper.findByTestId('test-button');
- const findJiraTriggerFields = () => wrapper.findComponent(JiraTriggerFields);
- const findJiraIssuesFields = () => wrapper.findComponent(JiraIssuesFields);
const findTriggerFields = () => wrapper.findComponent(TriggerFields);
const findGlForm = () => wrapper.findComponent(GlForm);
const findRedirectToField = () => wrapper.findByTestId('redirect-to-field');
@@ -198,49 +193,6 @@ describe('IntegrationForm', () => {
});
});
- describe('type is "slack"', () => {
- beforeEach(() => {
- createComponent({
- customStateProps: { type: 'slack' },
- });
- });
-
- it('does not render JiraTriggerFields', () => {
- expect(findJiraTriggerFields().exists()).toBe(false);
- });
-
- it('does not render JiraIssuesFields', () => {
- expect(findJiraIssuesFields().exists()).toBe(false);
- });
- });
-
- describe('type is "jira"', () => {
- beforeEach(() => {
- jest.spyOn(document, 'querySelector').mockReturnValue(document.createElement('form'));
-
- createComponent({
- customStateProps: { type: 'jira', testPath: '/test' },
- mountFn: mountExtended,
- });
- });
-
- it('renders JiraTriggerFields', () => {
- expect(findJiraTriggerFields().exists()).toBe(true);
- });
-
- it('renders JiraIssuesFields', () => {
- expect(findJiraIssuesFields().exists()).toBe(true);
- });
-
- describe('when JiraIssueFields emits `request-jira-issue-types` event', () => {
- it('dispatches `requestJiraIssueTypes` action', () => {
- findJiraIssuesFields().vm.$emit('request-jira-issue-types');
-
- expect(dispatch).toHaveBeenCalledWith('requestJiraIssueTypes', expect.any(FormData));
- });
- });
- });
-
describe('triggerEvents is present', () => {
it('renders TriggerFields', () => {
const events = [{ title: 'push' }];
@@ -272,9 +224,6 @@ describe('IntegrationForm', () => {
];
createComponent({
- provide: {
- glFeatures: { integrationFormSections: true },
- },
customStateProps: {
sections: [mockSectionConnection],
fields: [...sectionFields, ...nonSectionFields],
@@ -363,9 +312,6 @@ describe('IntegrationForm', () => {
describe('when integration has sections', () => {
beforeEach(() => {
createComponent({
- provide: {
- glFeatures: { integrationFormSections: true },
- },
customStateProps: {
sections: [mockSectionConnection],
},
@@ -396,9 +342,6 @@ describe('IntegrationForm', () => {
];
createComponent({
- provide: {
- glFeatures: { integrationFormSections: true },
- },
customStateProps: {
sections: [mockSectionConnection],
fields: [...sectionFields, ...nonSectionFields],
@@ -417,9 +360,6 @@ describe('IntegrationForm', () => {
({ formActive, novalidate }) => {
beforeEach(() => {
createComponent({
- provide: {
- glFeatures: { integrationFormSections: true },
- },
customStateProps: {
sections: [mockSectionConnection],
showActive: true,
@@ -441,9 +381,6 @@ describe('IntegrationForm', () => {
jest.spyOn(document, 'querySelector').mockReturnValue(document.createElement('form'));
createComponent({
- provide: {
- glFeatures: { integrationFormSections: true },
- },
customStateProps: {
sections: [mockSectionConnection],
testPath: '/test',
diff --git a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js
index 33fd08a5959..94e370a485f 100644
--- a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js
+++ b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js
@@ -10,7 +10,6 @@ describe('JiraIssuesFields', () => {
let wrapper;
const defaultProps = {
- editProjectPath: '/edit',
showJiraIssuesIntegration: true,
showJiraVulnerabilitiesIntegration: true,
upgradePlanPath: 'https://gitlab.com',
@@ -46,7 +45,6 @@ describe('JiraIssuesFields', () => {
const findPremiumUpgradeCTA = () => wrapper.findByTestId('premium-upgrade-cta');
const findUltimateUpgradeCTA = () => wrapper.findByTestId('ultimate-upgrade-cta');
const findJiraForVulnerabilities = () => wrapper.findByTestId('jira-for-vulnerabilities');
- const findConflictWarning = () => wrapper.findByTestId('conflict-warning-text');
const setEnableCheckbox = async (isEnabled = true) =>
findEnableCheckbox().vm.$emit('input', isEnabled);
@@ -75,10 +73,9 @@ describe('JiraIssuesFields', () => {
});
if (showJiraIssuesIntegration) {
- it('renders checkbox and input field', () => {
+ it('renders enable checkbox', () => {
expect(findEnableCheckbox().exists()).toBe(true);
expect(findEnableCheckboxDisabled()).toBeUndefined();
- expect(findProjectKey().exists()).toBe(true);
});
it('does not render the Premium CTA', () => {
@@ -98,9 +95,8 @@ describe('JiraIssuesFields', () => {
});
}
} else {
- it('does not render checkbox and input field', () => {
+ it('does not render enable checkbox', () => {
expect(findEnableCheckbox().exists()).toBe(false);
- expect(findProjectKey().exists()).toBe(false);
});
it('renders the Premium CTA', () => {
@@ -122,12 +118,8 @@ describe('JiraIssuesFields', () => {
createComponent({ props: { initialProjectKey: '' } });
});
- it('renders disabled project_key input', () => {
- const projectKey = findProjectKey();
-
- expect(projectKey.exists()).toBe(true);
- expect(projectKey.attributes('disabled')).toBe('disabled');
- expect(projectKey.attributes('required')).toBeUndefined();
+ it('does not render project_key input', () => {
+ expect(findProjectKey().exists()).toBe(false);
});
// As per https://vuejs.org/v2/guide/forms.html#Checkbox-1,
@@ -137,45 +129,23 @@ describe('JiraIssuesFields', () => {
});
describe('when isInheriting = true', () => {
- it('disables checkbox and sets input as readonly', () => {
+ it('disables checkbox', () => {
createComponent({ isInheriting: true });
expect(findEnableCheckboxDisabled()).toBe('disabled');
- expect(findProjectKey().attributes('readonly')).toBe('readonly');
});
});
describe('on enable issues', () => {
- it('enables project_key input as required', async () => {
+ it('renders project_key input as required', async () => {
await setEnableCheckbox(true);
- expect(findProjectKey().attributes('disabled')).toBeUndefined();
+ expect(findProjectKey().exists()).toBe(true);
expect(findProjectKey().attributes('required')).toBe('required');
});
});
});
- it('contains link to editProjectPath', () => {
- createComponent();
-
- expect(wrapper.find(`a[href="${defaultProps.editProjectPath}"]`).exists()).toBe(true);
- });
-
- describe('GitLab issues warning', () => {
- it.each`
- gitlabIssuesEnabled | scenario
- ${true} | ${'displays conflict warning'}
- ${false} | ${'does not display conflict warning'}
- `(
- '$scenario when `gitlabIssuesEnabled` is `$gitlabIssuesEnabled`',
- ({ gitlabIssuesEnabled }) => {
- createComponent({ props: { gitlabIssuesEnabled } });
-
- expect(findConflictWarning().exists()).toBe(gitlabIssuesEnabled);
- },
- );
- });
-
describe('Vulnerabilities creation', () => {
beforeEach(() => {
createComponent();
diff --git a/spec/frontend/invite_members/components/group_select_spec.js b/spec/frontend/invite_members/components/group_select_spec.js
index 192f3fdd381..e1563a7bb3a 100644
--- a/spec/frontend/invite_members/components/group_select_spec.js
+++ b/spec/frontend/invite_members/components/group_select_spec.js
@@ -4,7 +4,6 @@ import waitForPromises from 'helpers/wait_for_promises';
import * as groupsApi from '~/api/groups_api';
import GroupSelect from '~/invite_members/components/group_select.vue';
-const accessLevels = { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 };
const group1 = { id: 1, full_name: 'Group One', avatar_url: 'test' };
const group2 = { id: 2, full_name: 'Group Two', avatar_url: 'test' };
const allGroups = [group1, group2];
@@ -13,7 +12,6 @@ const createComponent = (props = {}) => {
return mount(GroupSelect, {
propsData: {
invalidGroups: [],
- accessLevels,
...props,
},
});
@@ -66,9 +64,8 @@ describe('GroupSelect', () => {
resolveApiRequest({ data: allGroups });
expect(groupsApi.getGroups).toHaveBeenCalledWith(group1.name, {
- active: true,
exclude_internal: true,
- min_access_level: accessLevels.Guest,
+ active: true,
});
});
diff --git a/spec/frontend/invite_members/components/invite_groups_modal_spec.js b/spec/frontend/invite_members/components/invite_groups_modal_spec.js
index 8085f48f6e2..f9cb4a149f2 100644
--- a/spec/frontend/invite_members/components/invite_groups_modal_spec.js
+++ b/spec/frontend/invite_members/components/invite_groups_modal_spec.js
@@ -42,18 +42,19 @@ describe('InviteGroupsModal', () => {
wrapper = null;
});
+ const findModal = () => wrapper.findComponent(GlModal);
const findGroupSelect = () => wrapper.findComponent(GroupSelect);
const findIntroText = () => wrapper.findByTestId('modal-base-intro-text').text();
- const findCancelButton = () => wrapper.findByTestId('cancel-button');
- const findInviteButton = () => wrapper.findByTestId('invite-button');
const findMembersFormGroup = () => wrapper.findByTestId('members-form-group');
const membersFormGroupInvalidFeedback = () =>
findMembersFormGroup().attributes('invalid-feedback');
- const clickInviteButton = () => findInviteButton().vm.$emit('click');
- const clickCancelButton = () => findCancelButton().vm.$emit('click');
- const triggerGroupSelect = (val) => findGroupSelect().vm.$emit('input', val);
const findBase = () => wrapper.findComponent(InviteModalBase);
- const hideModal = () => wrapper.findComponent(GlModal).vm.$emit('hide');
+ const triggerGroupSelect = (val) => findGroupSelect().vm.$emit('input', val);
+ const emitEventFromModal = (eventName) => () =>
+ findModal().vm.$emit(eventName, { preventDefault: jest.fn() });
+ const hideModal = emitEventFromModal('hidden');
+ const clickInviteButton = emitEventFromModal('primary');
+ const clickCancelButton = emitEventFromModal('cancel');
describe('displaying the correct introText and form group description', () => {
describe('when inviting to a project', () => {
diff --git a/spec/frontend/invite_members/components/invite_members_modal_spec.js b/spec/frontend/invite_members/components/invite_members_modal_spec.js
index dd16bb48cb8..84317da39e6 100644
--- a/spec/frontend/invite_members/components/invite_members_modal_spec.js
+++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js
@@ -23,7 +23,7 @@ import ContentTransition from '~/vue_shared/components/content_transition.vue';
import axios from '~/lib/utils/axios_utils';
import httpStatus from '~/lib/utils/http_status';
import { getParameterValues } from '~/lib/utils/url_utility';
-import { apiPaths, membersApiResponse, invitationsApiResponse } from '../mock_data/api_responses';
+import { GROUPS_INVITATIONS_PATH, invitationsApiResponse } from '../mock_data/api_responses';
import {
propsData,
inviteSource,
@@ -85,12 +85,13 @@ describe('InviteMembersModal', () => {
mock.restore();
});
+ const findModal = () => wrapper.findComponent(GlModal);
const findBase = () => wrapper.findComponent(InviteModalBase);
const findIntroText = () => wrapper.findByTestId('modal-base-intro-text').text();
- const findCancelButton = () => wrapper.findByTestId('cancel-button');
- const findInviteButton = () => wrapper.findByTestId('invite-button');
- const clickInviteButton = () => findInviteButton().vm.$emit('click');
- const clickCancelButton = () => findCancelButton().vm.$emit('click');
+ const emitEventFromModal = (eventName) => () =>
+ findModal().vm.$emit(eventName, { preventDefault: jest.fn() });
+ const clickInviteButton = emitEventFromModal('primary');
+ const clickCancelButton = emitEventFromModal('cancel');
const findMembersFormGroup = () => wrapper.findByTestId('members-form-group');
const membersFormGroupInvalidFeedback = () =>
findMembersFormGroup().attributes('invalid-feedback');
@@ -276,7 +277,7 @@ describe('InviteMembersModal', () => {
});
it('renders the modal with the correct title', () => {
- expect(wrapper.findComponent(GlModal).props('title')).toBe(MEMBERS_MODAL_CELEBRATE_TITLE);
+ expect(findModal().props('title')).toBe(MEMBERS_MODAL_CELEBRATE_TITLE);
});
it('includes the correct celebration text and emoji', () => {
@@ -300,11 +301,8 @@ describe('InviteMembersModal', () => {
});
describe('submitting the invite form', () => {
- const mockMembersApi = (code, data) => {
- mock.onPost(apiPaths.GROUPS_MEMBERS).reply(code, data);
- };
const mockInvitationsApi = (code, data) => {
- mock.onPost(apiPaths.GROUPS_INVITATIONS).reply(code, data);
+ mock.onPost(GROUPS_INVITATIONS_PATH).reply(code, data);
};
const expectedEmailRestrictedError =
@@ -328,7 +326,7 @@ describe('InviteMembersModal', () => {
await triggerMembersTokenSelect([user1, user2]);
wrapper.vm.$toast = { show: jest.fn() };
- jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData });
+ jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({ data: postData });
});
describe('when triggered from regular mounting', () => {
@@ -336,12 +334,8 @@ describe('InviteMembersModal', () => {
clickInviteButton();
});
- it('sets isLoading on the Invite button when it is clicked', () => {
- expect(findInviteButton().props('loading')).toBe(true);
- });
-
- it('calls Api addGroupMembersByUserId with the correct params', () => {
- expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(propsData.id, postData);
+ it('calls Api inviteGroupMembers with the correct params', () => {
+ expect(Api.inviteGroupMembers).toHaveBeenCalledWith(propsData.id, postData);
});
it('displays the successful toastMessage', () => {
@@ -371,21 +365,9 @@ describe('InviteMembersModal', () => {
await triggerMembersTokenSelect([user1]);
});
- it('displays "Member already exists" api message for http status conflict', async () => {
- mockMembersApi(httpStatus.CONFLICT, membersApiResponse.MEMBER_ALREADY_EXISTS);
-
- clickInviteButton();
-
- await waitForPromises();
-
- expect(membersFormGroupInvalidFeedback()).toBe('Member already exists');
- expect(findMembersSelect().props('validationState')).toBe(false);
- expect(findInviteButton().props('loading')).toBe(false);
- });
-
describe('clearing the invalid state and message', () => {
beforeEach(async () => {
- mockMembersApi(httpStatus.CONFLICT, membersApiResponse.MEMBER_ALREADY_EXISTS);
+ mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.EMAIL_TAKEN);
clickInviteButton();
@@ -393,7 +375,9 @@ describe('InviteMembersModal', () => {
});
it('clears the error when the list of members to invite is cleared', async () => {
- expect(membersFormGroupInvalidFeedback()).toBe('Member already exists');
+ expect(membersFormGroupInvalidFeedback()).toBe(
+ Object.values(invitationsApiResponse.EMAIL_TAKEN.message)[0],
+ );
expect(findMembersSelect().props('validationState')).toBe(false);
findMembersSelect().vm.$emit('clear');
@@ -414,7 +398,7 @@ describe('InviteMembersModal', () => {
});
it('clears the error when the modal is hidden', async () => {
- wrapper.findComponent(GlModal).vm.$emit('hide');
+ findModal().vm.$emit('hidden');
await nextTick();
@@ -424,15 +408,17 @@ describe('InviteMembersModal', () => {
});
it('clears the invalid state and message once the list of members to invite is cleared', async () => {
- mockMembersApi(httpStatus.CONFLICT, membersApiResponse.MEMBER_ALREADY_EXISTS);
+ mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.EMAIL_TAKEN);
clickInviteButton();
await waitForPromises();
- expect(membersFormGroupInvalidFeedback()).toBe('Member already exists');
+ expect(membersFormGroupInvalidFeedback()).toBe(
+ Object.values(invitationsApiResponse.EMAIL_TAKEN.message)[0],
+ );
expect(findMembersSelect().props('validationState')).toBe(false);
- expect(findInviteButton().props('loading')).toBe(false);
+ expect(findModal().props('actionPrimary').attributes.loading).toBe(false);
findMembersSelect().vm.$emit('clear');
@@ -440,11 +426,14 @@ describe('InviteMembersModal', () => {
expect(membersFormGroupInvalidFeedback()).toBe('');
expect(findMembersSelect().props('validationState')).toBe(null);
- expect(findInviteButton().props('loading')).toBe(false);
+ expect(findModal().props('actionPrimary').attributes.loading).toBe(false);
});
it('displays the generic error for http server error', async () => {
- mockMembersApi(httpStatus.INTERNAL_SERVER_ERROR, 'Request failed with status code 500');
+ mockInvitationsApi(
+ httpStatus.INTERNAL_SERVER_ERROR,
+ 'Request failed with status code 500',
+ );
clickInviteButton();
@@ -454,7 +443,7 @@ describe('InviteMembersModal', () => {
});
it('displays the restricted user api message for response with bad request', async () => {
- mockMembersApi(httpStatus.BAD_REQUEST, membersApiResponse.SINGLE_USER_RESTRICTED);
+ mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.EMAIL_RESTRICTED);
clickInviteButton();
@@ -464,7 +453,7 @@ describe('InviteMembersModal', () => {
});
it('displays the first part of the error when multiple existing users are restricted by email', async () => {
- mockMembersApi(httpStatus.CREATED, membersApiResponse.MULTIPLE_USERS_RESTRICTED);
+ mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.MULTIPLE_RESTRICTED);
clickInviteButton();
@@ -475,19 +464,6 @@ describe('InviteMembersModal', () => {
);
expect(findMembersSelect().props('validationState')).toBe(false);
});
-
- it('displays an access_level error message received for the existing user', async () => {
- mockMembersApi(httpStatus.BAD_REQUEST, membersApiResponse.SINGLE_USER_ACCESS_LEVEL);
-
- clickInviteButton();
-
- await waitForPromises();
-
- expect(membersFormGroupInvalidFeedback()).toBe(
- 'should be greater than or equal to Owner inherited membership from group Gitlab Org',
- );
- expect(findMembersSelect().props('validationState')).toBe(false);
- });
});
});
@@ -508,7 +484,7 @@ describe('InviteMembersModal', () => {
await triggerMembersTokenSelect([user3]);
wrapper.vm.$toast = { show: jest.fn() };
- jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData });
+ jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({ data: postData });
});
describe('when triggered from regular mounting', () => {
@@ -516,8 +492,8 @@ describe('InviteMembersModal', () => {
clickInviteButton();
});
- it('calls Api inviteGroupMembersByEmail with the correct params', () => {
- expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(propsData.id, postData);
+ it('calls Api inviteGroupMembers with the correct params', () => {
+ expect(Api.inviteGroupMembers).toHaveBeenCalledWith(propsData.id, postData);
});
it('displays the successful toastMessage', () => {
@@ -542,7 +518,7 @@ describe('InviteMembersModal', () => {
expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError);
expect(findMembersSelect().props('validationState')).toBe(false);
- expect(findInviteButton().props('loading')).toBe(false);
+ expect(findModal().props('actionPrimary').attributes.loading).toBe(false);
});
it('displays the restricted email error when restricted email is invited', async () => {
@@ -554,23 +530,11 @@ describe('InviteMembersModal', () => {
expect(membersFormGroupInvalidFeedback()).toContain(expectedEmailRestrictedError);
expect(findMembersSelect().props('validationState')).toBe(false);
- expect(findInviteButton().props('loading')).toBe(false);
- });
-
- it('displays the successful toast message when email has already been invited', async () => {
- mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.EMAIL_TAKEN);
- wrapper.vm.$toast = { show: jest.fn() };
-
- clickInviteButton();
-
- await waitForPromises();
-
- expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added');
- expect(findMembersSelect().props('validationState')).toBe(null);
+ expect(findModal().props('actionPrimary').attributes.loading).toBe(false);
});
it('displays the first error message when multiple emails return a restricted error message', async () => {
- mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.MULTIPLE_EMAIL_RESTRICTED);
+ mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.MULTIPLE_RESTRICTED);
clickInviteButton();
@@ -617,19 +581,17 @@ describe('InviteMembersModal', () => {
format: 'json',
tasks_to_be_done: [],
tasks_project_id: '',
+ user_id: '1',
+ email: 'email@example.com',
};
- const emailPostData = { ...postData, email: 'email@example.com' };
- const idPostData = { ...postData, user_id: '1' };
-
describe('when invites are sent successfully', () => {
beforeEach(async () => {
createComponent();
await triggerMembersTokenSelect([user1, user3]);
wrapper.vm.$toast = { show: jest.fn() };
- jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData });
- jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData });
+ jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({ data: postData });
});
describe('when triggered from regular mounting', () => {
@@ -637,12 +599,8 @@ describe('InviteMembersModal', () => {
clickInviteButton();
});
- it('calls Api inviteGroupMembersByEmail with the correct params', () => {
- expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(propsData.id, emailPostData);
- });
-
- it('calls Api addGroupMembersByUserId with the correct params', () => {
- expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(propsData.id, idPostData);
+ it('calls Api inviteGroupMembers with the correct params', () => {
+ expect(Api.inviteGroupMembers).toHaveBeenCalledWith(propsData.id, postData);
});
it('displays the successful toastMessage', () => {
@@ -655,12 +613,8 @@ describe('InviteMembersModal', () => {
clickInviteButton();
- expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(propsData.id, {
- ...emailPostData,
- invite_source: '_invite_source_',
- });
- expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(propsData.id, {
- ...idPostData,
+ expect(Api.inviteGroupMembers).toHaveBeenCalledWith(propsData.id, {
+ ...postData,
invite_source: '_invite_source_',
});
});
@@ -673,7 +627,6 @@ describe('InviteMembersModal', () => {
await triggerMembersTokenSelect([user1, user3]);
mockInvitationsApi(httpStatus.BAD_REQUEST, invitationsApiResponse.EMAIL_INVALID);
- mockMembersApi(httpStatus.OK, '200 OK');
clickInviteButton();
});
@@ -692,7 +645,7 @@ describe('InviteMembersModal', () => {
await triggerMembersTokenSelect([user3]);
wrapper.vm.$toast = { show: jest.fn() };
- jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({});
+ jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({});
});
it('tracks the view for learn_gitlab source', () => {
diff --git a/spec/frontend/invite_members/components/invite_modal_base_spec.js b/spec/frontend/invite_members/components/invite_modal_base_spec.js
index 9e17112fb15..8355ae67f20 100644
--- a/spec/frontend/invite_members/components/invite_modal_base_spec.js
+++ b/spec/frontend/invite_members/components/invite_modal_base_spec.js
@@ -49,8 +49,6 @@ describe('InviteModalBase', () => {
const findDatepicker = () => wrapper.findComponent(GlDatepicker);
const findLink = () => wrapper.findComponent(GlLink);
const findIntroText = () => wrapper.findByTestId('modal-base-intro-text').text();
- const findCancelButton = () => wrapper.findByTestId('cancel-button');
- const findInviteButton = () => wrapper.findByTestId('invite-button');
const findMembersFormGroup = () => wrapper.findByTestId('members-form-group');
describe('rendering the modal', () => {
@@ -67,15 +65,21 @@ describe('InviteModalBase', () => {
});
it('renders the Cancel button text correctly', () => {
- expect(findCancelButton().text()).toBe(CANCEL_BUTTON_TEXT);
- });
-
- it('renders the Invite button text correctly', () => {
- expect(findInviteButton().text()).toBe(INVITE_BUTTON_TEXT);
+ expect(wrapper.findComponent(GlModal).props('actionCancel')).toMatchObject({
+ text: CANCEL_BUTTON_TEXT,
+ });
});
- it('renders the Invite button modal without isLoading', () => {
- expect(findInviteButton().props('loading')).toBe(false);
+ it('renders the Invite button correctly', () => {
+ expect(wrapper.findComponent(GlModal).props('actionPrimary')).toMatchObject({
+ text: INVITE_BUTTON_TEXT,
+ attributes: {
+ variant: 'confirm',
+ disabled: false,
+ loading: false,
+ 'data-qa-selector': 'invite_button',
+ },
+ });
});
describe('rendering the access levels dropdown', () => {
@@ -114,7 +118,7 @@ describe('InviteModalBase', () => {
isLoading: true,
});
- expect(findInviteButton().props('loading')).toBe(true);
+ expect(wrapper.findComponent(GlModal).props('actionPrimary').attributes.loading).toBe(true);
});
it('with invalidFeedbackMessage, set members form group validation state', () => {
diff --git a/spec/frontend/invite_members/components/members_token_select_spec.js b/spec/frontend/invite_members/components/members_token_select_spec.js
index 196a716d08c..bf5564e4d63 100644
--- a/spec/frontend/invite_members/components/members_token_select_spec.js
+++ b/spec/frontend/invite_members/components/members_token_select_spec.js
@@ -95,7 +95,7 @@ describe('MembersTokenSelect', () => {
expect(UserApi.getUsers).toHaveBeenCalledWith(searchParam, {
active: true,
- exclude_internal: true,
+ without_project_bots: true,
});
expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false);
});
@@ -172,7 +172,7 @@ describe('MembersTokenSelect', () => {
expect(UserApi.getUsers).toHaveBeenCalledWith(searchParam, {
active: true,
- exclude_internal: true,
+ without_project_bots: true,
saml_provider_id: samlProviderId,
});
});
diff --git a/spec/frontend/invite_members/components/user_limit_notification_spec.js b/spec/frontend/invite_members/components/user_limit_notification_spec.js
new file mode 100644
index 00000000000..c779cf2ee3f
--- /dev/null
+++ b/spec/frontend/invite_members/components/user_limit_notification_spec.js
@@ -0,0 +1,71 @@
+import { GlAlert, GlSprintf } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import UserLimitNotification from '~/invite_members/components/user_limit_notification.vue';
+
+describe('UserLimitNotification', () => {
+ let wrapper;
+
+ const findAlert = () => wrapper.findComponent(GlAlert);
+
+ const createComponent = (providers = {}) => {
+ wrapper = shallowMountExtended(UserLimitNotification, {
+ provide: {
+ name: 'my group',
+ newTrialRegistrationPath: 'newTrialRegistrationPath',
+ purchasePath: 'purchasePath',
+ freeUsersLimit: 5,
+ membersCount: 1,
+ ...providers,
+ },
+ stubs: { GlSprintf },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('when limit is not reached', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders empty block', () => {
+ expect(findAlert().exists()).toBe(false);
+ });
+ });
+
+ describe('when close to limit', () => {
+ beforeEach(() => {
+ createComponent({ membersCount: 3 });
+ });
+
+ it("renders user's limit notification", () => {
+ const alert = findAlert();
+
+ expect(alert.attributes('title')).toEqual(
+ 'You only have space for 2 more members in my group',
+ );
+
+ expect(alert.text()).toEqual(
+ 'To get more members an owner of this namespace can start a trial or upgrade to a paid tier.',
+ );
+ });
+ });
+
+ describe('when limit is reached', () => {
+ beforeEach(() => {
+ createComponent({ membersCount: 5 });
+ });
+
+ it("renders user's limit notification", () => {
+ const alert = findAlert();
+
+ expect(alert.attributes('title')).toEqual("You've reached your 5 members limit for my group");
+
+ expect(alert.text()).toEqual(
+ 'New members will be unable to participate. You can manage your members by removing ones you no longer need. To get more members an owner of this namespace can start a trial or upgrade to a paid tier.',
+ );
+ });
+ });
+});
diff --git a/spec/frontend/invite_members/mock_data/api_responses.js b/spec/frontend/invite_members/mock_data/api_responses.js
index a3e426376d8..4ad3b6aeb66 100644
--- a/spec/frontend/invite_members/mock_data/api_responses.js
+++ b/spec/frontend/invite_members/mock_data/api_responses.js
@@ -1,12 +1,12 @@
-const INVITATIONS_API_EMAIL_INVALID = {
+const EMAIL_INVALID = {
message: { error: 'email contains an invalid email address' },
};
-const INVITATIONS_API_ERROR_EMAIL_INVALID = {
+const ERROR_EMAIL_INVALID = {
error: 'email contains an invalid email address',
};
-const INVITATIONS_API_EMAIL_RESTRICTED = {
+const EMAIL_RESTRICTED = {
message: {
'email@example.com':
"The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups.",
@@ -14,65 +14,31 @@ const INVITATIONS_API_EMAIL_RESTRICTED = {
status: 'error',
};
-const INVITATIONS_API_MULTIPLE_EMAIL_RESTRICTED = {
+const MULTIPLE_RESTRICTED = {
message: {
'email@example.com':
"The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups.",
'email4@example.com':
"The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check the Domain denylist.",
- },
- status: 'error',
-};
-
-const INVITATIONS_API_EMAIL_TAKEN = {
- message: {
- 'email@example.org': 'Invite email has already been taken',
- },
- status: 'error',
-};
-
-const MEMBERS_API_MEMBER_ALREADY_EXISTS = {
- message: 'Member already exists',
-};
-
-const MEMBERS_API_SINGLE_USER_RESTRICTED = {
- message: {
- user: [
+ root:
"The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups.",
- ],
},
+ status: 'error',
};
-const MEMBERS_API_SINGLE_USER_ACCESS_LEVEL = {
+const EMAIL_TAKEN = {
message: {
- access_level: [
- 'should be greater than or equal to Owner inherited membership from group Gitlab Org',
- ],
+ 'email@example.org': "The member's email address has already been taken",
},
-};
-
-const MEMBERS_API_MULTIPLE_USERS_RESTRICTED = {
- message:
- "root: The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups. and user18: The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check the Domain denylist. and john_doe31: The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Email restrictions for sign-ups.",
status: 'error',
};
-export const apiPaths = {
- GROUPS_MEMBERS: '/api/v4/groups/1/members',
- GROUPS_INVITATIONS: '/api/v4/groups/1/invitations',
-};
-
-export const membersApiResponse = {
- MEMBER_ALREADY_EXISTS: MEMBERS_API_MEMBER_ALREADY_EXISTS,
- SINGLE_USER_ACCESS_LEVEL: MEMBERS_API_SINGLE_USER_ACCESS_LEVEL,
- SINGLE_USER_RESTRICTED: MEMBERS_API_SINGLE_USER_RESTRICTED,
- MULTIPLE_USERS_RESTRICTED: MEMBERS_API_MULTIPLE_USERS_RESTRICTED,
-};
+export const GROUPS_INVITATIONS_PATH = '/api/v4/groups/1/invitations';
export const invitationsApiResponse = {
- EMAIL_INVALID: INVITATIONS_API_EMAIL_INVALID,
- ERROR_EMAIL_INVALID: INVITATIONS_API_ERROR_EMAIL_INVALID,
- EMAIL_RESTRICTED: INVITATIONS_API_EMAIL_RESTRICTED,
- MULTIPLE_EMAIL_RESTRICTED: INVITATIONS_API_MULTIPLE_EMAIL_RESTRICTED,
- EMAIL_TAKEN: INVITATIONS_API_EMAIL_TAKEN,
+ EMAIL_INVALID,
+ ERROR_EMAIL_INVALID,
+ EMAIL_RESTRICTED,
+ MULTIPLE_RESTRICTED,
+ EMAIL_TAKEN,
};
diff --git a/spec/frontend/invite_members/mock_data/group_modal.js b/spec/frontend/invite_members/mock_data/group_modal.js
index c05c4edb7d0..c8588683885 100644
--- a/spec/frontend/invite_members/mock_data/group_modal.js
+++ b/spec/frontend/invite_members/mock_data/group_modal.js
@@ -1,5 +1,6 @@
export const propsData = {
id: '1',
+ rootId: '1',
name: 'test name',
isProject: false,
invalidGroups: [],
diff --git a/spec/frontend/invite_members/mock_data/member_modal.js b/spec/frontend/invite_members/mock_data/member_modal.js
index 590502909b2..1b0cc57fb5b 100644
--- a/spec/frontend/invite_members/mock_data/member_modal.js
+++ b/spec/frontend/invite_members/mock_data/member_modal.js
@@ -1,5 +1,6 @@
export const propsData = {
id: '1',
+ rootId: '1',
name: 'test name',
isProject: false,
accessLevels: { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 },
diff --git a/spec/frontend/invite_members/utils/response_message_parser_spec.js b/spec/frontend/invite_members/utils/response_message_parser_spec.js
index e2cc87c8547..8b2064df374 100644
--- a/spec/frontend/invite_members/utils/response_message_parser_spec.js
+++ b/spec/frontend/invite_members/utils/response_message_parser_spec.js
@@ -2,23 +2,19 @@ import {
responseMessageFromSuccess,
responseMessageFromError,
} from '~/invite_members/utils/response_message_parser';
-import { membersApiResponse, invitationsApiResponse } from '../mock_data/api_responses';
+import { invitationsApiResponse } from '../mock_data/api_responses';
describe('Response message parser', () => {
const expectedMessage = 'expected display and message.';
describe('parse message from successful response', () => {
const exampleKeyedMsg = { 'email@example.com': expectedMessage };
- const exampleFirstPartMultiple = 'username1: expected display and message.';
- const exampleUserMsgMultiple =
- ' and username2: id not found and restricted email. and username3: email is restricted.';
it.each([
- [[{ data: { message: expectedMessage } }]],
- [[{ data: { message: exampleFirstPartMultiple + exampleUserMsgMultiple } }]],
- [[{ data: { error: expectedMessage } }]],
- [[{ data: { message: [expectedMessage] } }]],
- [[{ data: { message: exampleKeyedMsg } }]],
+ [{ data: { message: expectedMessage } }],
+ [{ data: { error: expectedMessage } }],
+ [{ data: { message: [expectedMessage] } }],
+ [{ data: { message: exampleKeyedMsg } }],
])(`returns "${expectedMessage}" from success response: %j`, (successResponse) => {
expect(responseMessageFromSuccess(successResponse)).toBe(expectedMessage);
});
@@ -27,8 +23,6 @@ describe('Response message parser', () => {
describe('message from error response', () => {
it.each([
[{ response: { data: { error: expectedMessage } } }],
- [{ response: { data: { message: { user: [expectedMessage] } } } }],
- [{ response: { data: { message: { access_level: [expectedMessage] } } } }],
[{ response: { data: { message: { error: expectedMessage } } } }],
[{ response: { data: { message: expectedMessage } } }],
])(`returns "${expectedMessage}" from error response: %j`, (errorResponse) => {
@@ -41,18 +35,10 @@ describe('Response message parser', () => {
"The member's email address is not allowed for this project. Go to the Admin area > Sign-up restrictions, and check Allowed domains for sign-ups.";
it.each([
- [[{ data: membersApiResponse.MULTIPLE_USERS_RESTRICTED }]],
- [[{ data: invitationsApiResponse.MULTIPLE_EMAIL_RESTRICTED }]],
- [[{ data: invitationsApiResponse.EMAIL_RESTRICTED }]],
+ [{ data: invitationsApiResponse.MULTIPLE_RESTRICTED }],
+ [{ data: invitationsApiResponse.EMAIL_RESTRICTED }],
])(`returns "${expectedMessage}" from success response: %j`, (restrictedResponse) => {
expect(responseMessageFromSuccess(restrictedResponse)).toBe(expected);
});
-
- it.each([[{ response: { data: membersApiResponse.SINGLE_USER_RESTRICTED } }]])(
- `returns "${expectedMessage}" from error response: %j`,
- (singleRestrictedResponse) => {
- expect(responseMessageFromError(singleRestrictedResponse)).toBe(expected);
- },
- );
});
});
diff --git a/spec/frontend/issuable/issuable_form_spec.js b/spec/frontend/issuable/issuable_form_spec.js
index 321c61ead1e..99ed18cf5bd 100644
--- a/spec/frontend/issuable/issuable_form_spec.js
+++ b/spec/frontend/issuable/issuable_form_spec.js
@@ -1,20 +1,46 @@
import $ from 'jquery';
import IssuableForm from '~/issuable/issuable_form';
-
-function createIssuable() {
- const instance = new IssuableForm($(document.createElement('form')));
-
- instance.titleField = $(document.createElement('input'));
-
- return instance;
-}
+import setWindowLocation from 'helpers/set_window_location_helper';
describe('IssuableForm', () => {
let instance;
+ const createIssuable = (form) => {
+ instance = new IssuableForm(form);
+ };
+
beforeEach(() => {
- instance = createIssuable();
+ setFixtures(`
+ <form>
+ <input name="[title]" />
+ </form>
+ `);
+ createIssuable($('form'));
+ });
+
+ describe('initAutosave', () => {
+ it('creates autosave with the searchTerm included', () => {
+ setWindowLocation('https://gitlab.test/foo?bar=true');
+ const autosave = instance.initAutosave();
+
+ expect(autosave.key.includes('bar=true')).toBe(true);
+ });
+
+ it("creates autosave fields without the searchTerm if it's an issue new form", () => {
+ setFixtures(`
+ <form data-new-issue-path="/issues/new">
+ <input name="[title]" />
+ </form>
+ `);
+ createIssuable($('form'));
+
+ setWindowLocation('https://gitlab.test/issues/new?bar=true');
+
+ const autosave = instance.initAutosave();
+
+ expect(autosave.key.includes('bar=true')).toBe(false);
+ });
});
describe('removeWip', () => {
diff --git a/spec/frontend/issues/create_merge_request_dropdown_spec.js b/spec/frontend/issues/create_merge_request_dropdown_spec.js
index c2cfb16fdf7..20b26f5abba 100644
--- a/spec/frontend/issues/create_merge_request_dropdown_spec.js
+++ b/spec/frontend/issues/create_merge_request_dropdown_spec.js
@@ -15,7 +15,7 @@ describe('CreateMergeRequestDropdown', () => {
<div id="dummy-wrapper-element">
<div class="available"></div>
<div class="unavailable">
- <div class="gl-spinner"></div>
+ <div class="js-create-mr-spinner"></div>
<div class="text"></div>
</div>
<div class="js-ref"></div>
@@ -38,21 +38,16 @@ describe('CreateMergeRequestDropdown', () => {
});
describe('getRef', () => {
- it('escapes branch names correctly', (done) => {
+ it('escapes branch names correctly', async () => {
const endpoint = `${dropdown.refsPath}contains%23hash`;
jest.spyOn(axios, 'get');
axiosMock.onGet(endpoint).replyOnce({});
- dropdown
- .getRef('contains#hash')
- .then(() => {
- expect(axios.get).toHaveBeenCalledWith(
- endpoint,
- expect.objectContaining({ cancelToken: expect.anything() }),
- );
- })
- .then(done)
- .catch(done.fail);
+ await dropdown.getRef('contains#hash');
+ expect(axios.get).toHaveBeenCalledWith(
+ endpoint,
+ expect.objectContaining({ cancelToken: expect.anything() }),
+ );
});
});
diff --git a/spec/frontend/issues/list/components/issue_card_time_info_spec.js b/spec/frontend/issues/list/components/issue_card_time_info_spec.js
index e9c48b60da4..c3f13ca6f9a 100644
--- a/spec/frontend/issues/list/components/issue_card_time_info_spec.js
+++ b/spec/frontend/issues/list/components/issue_card_time_info_spec.js
@@ -1,10 +1,11 @@
import { GlIcon, GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { useFakeDate } from 'helpers/fake_date';
+import { IssuableStatus } from '~/issues/constants';
import IssueCardTimeInfo from '~/issues/list/components/issue_card_time_info.vue';
describe('CE IssueCardTimeInfo component', () => {
- useFakeDate(2020, 11, 11);
+ useFakeDate(2020, 11, 11); // 2020 Dec 11
let wrapper;
@@ -24,7 +25,7 @@ describe('CE IssueCardTimeInfo component', () => {
const findDueDate = () => wrapper.find('[data-testid="issuable-due-date"]');
const mountComponent = ({
- closedAt = null,
+ state = IssuableStatus.Open,
dueDate = issue.dueDate,
milestoneDueDate = issue.milestone.dueDate,
milestoneStartDate = issue.milestone.startDate,
@@ -38,7 +39,7 @@ describe('CE IssueCardTimeInfo component', () => {
dueDate: milestoneDueDate,
startDate: milestoneStartDate,
},
- closedAt,
+ state,
dueDate,
},
},
@@ -91,7 +92,7 @@ describe('CE IssueCardTimeInfo component', () => {
describe('when in the past', () => {
describe('when issue is open', () => {
it('renders in red', () => {
- wrapper = mountComponent({ dueDate: new Date('2020-10-10') });
+ wrapper = mountComponent({ dueDate: '2020-10-10' });
expect(findDueDate().classes()).toContain('gl-text-red-500');
});
@@ -100,8 +101,8 @@ describe('CE IssueCardTimeInfo component', () => {
describe('when issue is closed', () => {
it('does not render in red', () => {
wrapper = mountComponent({
- dueDate: new Date('2020-10-10'),
- closedAt: '2020-09-05T13:06:25Z',
+ dueDate: '2020-10-10',
+ state: IssuableStatus.Closed,
});
expect(findDueDate().classes()).not.toContain('gl-text-red-500');
diff --git a/spec/frontend/issues/list/components/issues_list_app_spec.js b/spec/frontend/issues/list/components/issues_list_app_spec.js
index 33c7ccac180..5a9bd1ff8e4 100644
--- a/spec/frontend/issues/list/components/issues_list_app_spec.js
+++ b/spec/frontend/issues/list/components/issues_list_app_spec.js
@@ -452,13 +452,26 @@ describe('CE IssuesListApp component', () => {
});
describe('IssuableByEmail component', () => {
- describe.each([true, false])(`when issue creation by email is enabled=%s`, (enabled) => {
- it(`${enabled ? 'renders' : 'does not render'}`, () => {
- wrapper = mountComponent({ provide: { initialEmail: enabled } });
-
- expect(findIssuableByEmail().exists()).toBe(enabled);
- });
- });
+ describe.each`
+ initialEmail | hasAnyIssues | isSignedIn | exists
+ ${false} | ${false} | ${false} | ${false}
+ ${false} | ${true} | ${false} | ${false}
+ ${false} | ${false} | ${true} | ${false}
+ ${false} | ${true} | ${true} | ${false}
+ ${true} | ${false} | ${false} | ${false}
+ ${true} | ${true} | ${false} | ${false}
+ ${true} | ${false} | ${true} | ${true}
+ ${true} | ${true} | ${true} | ${true}
+ `(
+ `when issue creation by email is enabled=$initialEmail`,
+ ({ initialEmail, hasAnyIssues, isSignedIn, exists }) => {
+ it(`${initialEmail ? 'renders' : 'does not render'}`, () => {
+ wrapper = mountComponent({ provide: { initialEmail, hasAnyIssues, isSignedIn } });
+
+ expect(findIssuableByEmail().exists()).toBe(exists);
+ });
+ },
+ );
});
describe('empty states', () => {
diff --git a/spec/frontend/issues/list/mock_data.js b/spec/frontend/issues/list/mock_data.js
index c883b20682e..b1a135ceb18 100644
--- a/spec/frontend/issues/list/mock_data.js
+++ b/spec/frontend/issues/list/mock_data.js
@@ -21,7 +21,6 @@ export const getIssuesQueryResponse = {
__typename: 'Issue',
id: 'gid://gitlab/Issue/123456',
iid: '789',
- closedAt: null,
confidential: false,
createdAt: '2021-05-22T04:08:01Z',
downvotes: 2,
@@ -30,6 +29,7 @@ export const getIssuesQueryResponse = {
humanTimeEstimate: null,
mergeRequestsCount: false,
moved: false,
+ state: 'opened',
title: 'Issue title',
updatedAt: '2021-05-22T04:08:01Z',
upvotes: 3,
diff --git a/spec/frontend/issues/related_merge_requests/store/actions_spec.js b/spec/frontend/issues/related_merge_requests/store/actions_spec.js
index 5f232fee09b..4327fac15d4 100644
--- a/spec/frontend/issues/related_merge_requests/store/actions_spec.js
+++ b/spec/frontend/issues/related_merge_requests/store/actions_spec.js
@@ -23,90 +23,82 @@ describe('RelatedMergeRequest store actions', () => {
});
describe('setInitialState', () => {
- it('commits types.SET_INITIAL_STATE with given props', (done) => {
+ it('commits types.SET_INITIAL_STATE with given props', () => {
const props = { a: 1, b: 2 };
- testAction(
+ return testAction(
actions.setInitialState,
props,
{},
[{ type: types.SET_INITIAL_STATE, payload: props }],
[],
- done,
);
});
});
describe('requestData', () => {
- it('commits types.REQUEST_DATA', (done) => {
- testAction(actions.requestData, null, {}, [{ type: types.REQUEST_DATA }], [], done);
+ it('commits types.REQUEST_DATA', () => {
+ return testAction(actions.requestData, null, {}, [{ type: types.REQUEST_DATA }], []);
});
});
describe('receiveDataSuccess', () => {
- it('commits types.RECEIVE_DATA_SUCCESS with data', (done) => {
+ it('commits types.RECEIVE_DATA_SUCCESS with data', () => {
const data = { a: 1, b: 2 };
- testAction(
+ return testAction(
actions.receiveDataSuccess,
data,
{},
[{ type: types.RECEIVE_DATA_SUCCESS, payload: data }],
[],
- done,
);
});
});
describe('receiveDataError', () => {
- it('commits types.RECEIVE_DATA_ERROR', (done) => {
- testAction(
+ it('commits types.RECEIVE_DATA_ERROR', () => {
+ return testAction(
actions.receiveDataError,
null,
{},
[{ type: types.RECEIVE_DATA_ERROR }],
[],
- done,
);
});
});
describe('fetchMergeRequests', () => {
describe('for a successful request', () => {
- it('should dispatch success action', (done) => {
+ it('should dispatch success action', () => {
const data = { a: 1 };
mock.onGet(`${state.apiEndpoint}?per_page=100`).replyOnce(200, data, { 'x-total': 2 });
- testAction(
+ return testAction(
actions.fetchMergeRequests,
null,
state,
[],
[{ type: 'requestData' }, { type: 'receiveDataSuccess', payload: { data, total: 2 } }],
- done,
);
});
});
describe('for a failing request', () => {
- it('should dispatch error action', (done) => {
+ it('should dispatch error action', async () => {
mock.onGet(`${state.apiEndpoint}?per_page=100`).replyOnce(400);
- testAction(
+ await testAction(
actions.fetchMergeRequests,
null,
state,
[],
[{ type: 'requestData' }, { type: 'receiveDataError' }],
- () => {
- expect(createFlash).toHaveBeenCalledTimes(1);
- expect(createFlash).toHaveBeenCalledWith({
- message: expect.stringMatching('Something went wrong'),
- });
-
- done();
- },
);
+ expect(createFlash).toHaveBeenCalledTimes(1);
+ expect(createFlash).toHaveBeenCalledWith({
+ message: expect.stringMatching('Something went wrong'),
+ });
});
});
});
diff --git a/spec/frontend/issues/show/components/app_spec.js b/spec/frontend/issues/show/components/app_spec.js
index ac2717a5028..5ab64d8e9ca 100644
--- a/spec/frontend/issues/show/components/app_spec.js
+++ b/spec/frontend/issues/show/components/app_spec.js
@@ -2,11 +2,14 @@ import { GlIntersectionObserver } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
-import { mountExtended } from 'helpers/vue_test_utils_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import '~/behaviors/markdown/render_gfm';
import { IssuableStatus, IssuableStatusText } from '~/issues/constants';
import IssuableApp from '~/issues/show/components/app.vue';
import DescriptionComponent from '~/issues/show/components/description.vue';
+import EditedComponent from '~/issues/show/components/edited.vue';
+import FormComponent from '~/issues/show/components/form.vue';
+import TitleComponent from '~/issues/show/components/title.vue';
import IncidentTabs from '~/issues/show/components/incidents/incident_tabs.vue';
import PinnedLinks from '~/issues/show/components/pinned_links.vue';
import { POLLING_DELAY } from '~/issues/show/constants';
@@ -21,10 +24,6 @@ import {
zoomMeetingUrl,
} from '../mock_data/mock_data';
-function formatText(text) {
- return text.trim().replace(/\s\s+/g, ' ');
-}
-
jest.mock('~/lib/utils/url_utility');
jest.mock('~/issues/show/event_hub');
@@ -39,10 +38,15 @@ describe('Issuable output', () => {
const findLockedBadge = () => wrapper.findByTestId('locked');
const findConfidentialBadge = () => wrapper.findByTestId('confidential');
const findHiddenBadge = () => wrapper.findByTestId('hidden');
- const findAlert = () => wrapper.find('.alert');
+
+ const findTitle = () => wrapper.findComponent(TitleComponent);
+ const findDescription = () => wrapper.findComponent(DescriptionComponent);
+ const findEdited = () => wrapper.findComponent(EditedComponent);
+ const findForm = () => wrapper.findComponent(FormComponent);
+ const findPinnedLinks = () => wrapper.findComponent(PinnedLinks);
const mountComponent = (props = {}, options = {}, data = {}) => {
- wrapper = mountExtended(IssuableApp, {
+ wrapper = shallowMountExtended(IssuableApp, {
directives: {
GlTooltip: createMockDirective(),
},
@@ -104,23 +108,15 @@ describe('Issuable output', () => {
});
it('should render a title/description/edited and update title/description/edited on update', () => {
- let editedText;
return axios
.waitForAll()
.then(() => {
- editedText = wrapper.find('.edited-text');
- })
- .then(() => {
- expect(document.querySelector('title').innerText).toContain('this is a title (#1)');
- expect(wrapper.find('.title').text()).toContain('this is a title');
- expect(wrapper.find('.md').text()).toContain('this is a description!');
- expect(wrapper.find('.js-task-list-field').element.value).toContain(
- 'this is a description',
- );
+ expect(findTitle().props('titleText')).toContain('this is a title');
+ expect(findDescription().props('descriptionText')).toContain('this is a description');
- expect(formatText(editedText.text())).toMatch(/Edited[\s\S]+?by Some User/);
- expect(editedText.find('.author-link').attributes('href')).toMatch(/\/some_user$/);
- expect(editedText.find('time').text()).toBeTruthy();
+ expect(findEdited().exists()).toBe(true);
+ expect(findEdited().props('updatedByPath')).toMatch(/\/some_user$/);
+ expect(findEdited().props('updatedAt')).toBeTruthy();
expect(wrapper.vm.state.lock_version).toBe(initialRequest.lock_version);
})
.then(() => {
@@ -128,20 +124,13 @@ describe('Issuable output', () => {
return axios.waitForAll();
})
.then(() => {
- expect(document.querySelector('title').innerText).toContain('2 (#1)');
- expect(wrapper.find('.title').text()).toContain('2');
- expect(wrapper.find('.md').text()).toContain('42');
- expect(wrapper.find('.js-task-list-field').element.value).toContain('42');
- expect(wrapper.find('.edited-text').text()).toBeTruthy();
- expect(formatText(wrapper.find('.edited-text').text())).toMatch(
- /Edited[\s\S]+?by Other User/,
- );
+ expect(findTitle().props('titleText')).toContain('2');
+ expect(findDescription().props('descriptionText')).toContain('42');
- expect(editedText.find('.author-link').attributes('href')).toMatch(/\/other_user$/);
- expect(editedText.find('time').text()).toBeTruthy();
- // As the lock_version value does not differ from the server,
- // we should not see an alert
- expect(findAlert().exists()).toBe(false);
+ expect(findEdited().exists()).toBe(true);
+ expect(findEdited().props('updatedByName')).toBe('Other User');
+ expect(findEdited().props('updatedByPath')).toMatch(/\/other_user$/);
+ expect(findEdited().props('updatedAt')).toBeTruthy();
});
});
@@ -149,7 +138,7 @@ describe('Issuable output', () => {
wrapper.vm.showForm = true;
await nextTick();
- expect(wrapper.find('.markdown-selector').exists()).toBe(true);
+ expect(findForm().exists()).toBe(true);
});
it('does not show actions if permissions are incorrect', async () => {
@@ -157,7 +146,7 @@ describe('Issuable output', () => {
wrapper.setProps({ canUpdate: false });
await nextTick();
- expect(wrapper.find('.markdown-selector').exists()).toBe(false);
+ expect(findForm().exists()).toBe(false);
});
it('does not update formState if form is already open', async () => {
@@ -177,8 +166,7 @@ describe('Issuable output', () => {
${'zoomMeetingUrl'} | ${zoomMeetingUrl}
${'publishedIncidentUrl'} | ${publishedIncidentUrl}
`('sets the $prop correctly on underlying pinned links', ({ prop, value }) => {
- expect(wrapper.vm[prop]).toBe(value);
- expect(wrapper.find(`[data-testid="${prop}"]`).attributes('href')).toBe(value);
+ expect(findPinnedLinks().props(prop)).toBe(value);
});
});
@@ -327,7 +315,6 @@ describe('Issuable output', () => {
expect(wrapper.vm.formState.lockedWarningVisible).toBe(true);
expect(wrapper.vm.formState.lock_version).toBe(1);
- expect(findAlert().exists()).toBe(true);
});
});
@@ -374,15 +361,22 @@ describe('Issuable output', () => {
});
describe('show inline edit button', () => {
- it('should not render by default', () => {
- expect(wrapper.find('.btn-edit').exists()).toBe(true);
+ it('should render by default', () => {
+ expect(findTitle().props('showInlineEditButton')).toBe(true);
});
it('should render if showInlineEditButton', async () => {
wrapper.setProps({ showInlineEditButton: true });
await nextTick();
- expect(wrapper.find('.btn-edit').exists()).toBe(true);
+ expect(findTitle().props('showInlineEditButton')).toBe(true);
+ });
+
+ it('should not render if showInlineEditButton is false', async () => {
+ wrapper.setProps({ showInlineEditButton: false });
+
+ await nextTick();
+ expect(findTitle().props('showInlineEditButton')).toBe(false);
});
});
@@ -533,13 +527,11 @@ describe('Issuable output', () => {
describe('Composable description component', () => {
const findIncidentTabs = () => wrapper.findComponent(IncidentTabs);
- const findDescriptionComponent = () => wrapper.findComponent(DescriptionComponent);
- const findPinnedLinks = () => wrapper.findComponent(PinnedLinks);
const borderClass = 'gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid gl-mb-6';
describe('when using description component', () => {
it('renders the description component', () => {
- expect(findDescriptionComponent().exists()).toBe(true);
+ expect(findDescription().exists()).toBe(true);
});
it('does not render incident tabs', () => {
@@ -572,8 +564,8 @@ describe('Issuable output', () => {
);
});
- it('renders the description component', () => {
- expect(findDescriptionComponent().exists()).toBe(true);
+ it('does not the description component', () => {
+ expect(findDescription().exists()).toBe(false);
});
it('renders incident tabs', () => {
diff --git a/spec/frontend/issues/show/components/description_spec.js b/spec/frontend/issues/show/components/description_spec.js
index 08f8996de6f..0b3daadae1d 100644
--- a/spec/frontend/issues/show/components/description_spec.js
+++ b/spec/frontend/issues/show/components/description_spec.js
@@ -1,26 +1,35 @@
import $ from 'jquery';
import { nextTick } from 'vue';
import '~/behaviors/markdown/render_gfm';
-import { GlPopover, GlModal } from '@gitlab/ui';
+import { GlTooltip, GlModal } from '@gitlab/ui';
+import setWindowLocation from 'helpers/set_window_location_helper';
import { stubComponent } from 'helpers/stub_component';
import { TEST_HOST } from 'helpers/test_constants';
import { mockTracking } from 'helpers/tracking_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import createFlash from '~/flash';
import Description from '~/issues/show/components/description.vue';
+import { updateHistory } from '~/lib/utils/url_utility';
import TaskList from '~/task_list';
import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
import CreateWorkItem from '~/work_items/pages/create_work_item.vue';
import {
descriptionProps as initialProps,
descriptionHtmlWithCheckboxes,
+ descriptionHtmlWithTask,
} from '../mock_data/mock_data';
jest.mock('~/flash');
+jest.mock('~/lib/utils/url_utility', () => ({
+ ...jest.requireActual('~/lib/utils/url_utility'),
+ updateHistory: jest.fn(),
+}));
jest.mock('~/task_list');
const showModal = jest.fn();
const hideModal = jest.fn();
+const $toast = {
+ show: jest.fn(),
+};
describe('Description component', () => {
let wrapper;
@@ -28,10 +37,9 @@ describe('Description component', () => {
const findGfmContent = () => wrapper.find('[data-testid="gfm-content"]');
const findTextarea = () => wrapper.find('[data-testid="textarea"]');
const findTaskActionButtons = () => wrapper.findAll('.js-add-task');
- const findConvertToTaskButton = () => wrapper.find('[data-testid="convert-to-task"]');
- const findTaskSvg = () => wrapper.find('[data-testid="issue-open-m-icon"]');
+ const findConvertToTaskButton = () => wrapper.find('.js-add-task');
- const findPopovers = () => wrapper.findAllComponents(GlPopover);
+ const findTooltips = () => wrapper.findAllComponents(GlTooltip);
const findModal = () => wrapper.findComponent(GlModal);
const findCreateWorkItem = () => wrapper.findComponent(CreateWorkItem);
const findWorkItemDetailModal = () => wrapper.findComponent(WorkItemDetailModal);
@@ -39,10 +47,14 @@ describe('Description component', () => {
function createComponent({ props = {}, provide = {} } = {}) {
wrapper = shallowMountExtended(Description, {
propsData: {
+ issueId: 1,
...initialProps,
...props,
},
provide,
+ mocks: {
+ $toast,
+ },
stubs: {
GlModal: stubComponent(GlModal, {
methods: {
@@ -50,12 +62,13 @@ describe('Description component', () => {
hide: hideModal,
},
}),
- GlPopover,
},
});
}
beforeEach(() => {
+ setWindowLocation(TEST_HOST);
+
if (!document.querySelector('.issuable-meta')) {
const metaData = document.createElement('div');
metaData.classList.add('issuable-meta');
@@ -253,9 +266,9 @@ describe('Description component', () => {
expect(findTaskActionButtons()).toHaveLength(3);
});
- it('renders a list of popovers corresponding to checkboxes in description HTML', () => {
- expect(findPopovers()).toHaveLength(3);
- expect(findPopovers().at(0).props('target')).toBe(
+ it('renders a list of tooltips corresponding to checkboxes in description HTML', () => {
+ expect(findTooltips()).toHaveLength(3);
+ expect(findTooltips().at(0).props('target')).toBe(
findTaskActionButtons().at(0).attributes('id'),
);
});
@@ -264,92 +277,113 @@ describe('Description component', () => {
expect(findModal().props('visible')).toBe(false);
});
- it('opens a modal when a button on popover is clicked and displays correct title', async () => {
- findConvertToTaskButton().vm.$emit('click');
- expect(showModal).toHaveBeenCalled();
- await nextTick();
+ it('opens a modal when a button is clicked and displays correct title', async () => {
+ await findConvertToTaskButton().trigger('click');
expect(findCreateWorkItem().props('initialTitle').trim()).toBe('todo 1');
});
- it('closes the modal on `closeCreateTaskModal` event', () => {
- findConvertToTaskButton().vm.$emit('click');
+ it('closes the modal on `closeCreateTaskModal` event', async () => {
+ await findConvertToTaskButton().trigger('click');
findCreateWorkItem().vm.$emit('closeModal');
expect(hideModal).toHaveBeenCalled();
});
- it('updates description HTML on `onCreate` event', async () => {
- const newTitle = 'New title';
- findConvertToTaskButton().vm.$emit('click');
- findCreateWorkItem().vm.$emit('onCreate', { title: newTitle });
+ it('emits `updateDescription` on `onCreate` event', () => {
+ const newDescription = `<p>New description</p>`;
+ findCreateWorkItem().vm.$emit('onCreate', newDescription);
expect(hideModal).toHaveBeenCalled();
- await nextTick();
+ expect(wrapper.emitted('updateDescription')).toEqual([[newDescription]]);
+ });
+
+ it('shows toast after delete success', async () => {
+ findWorkItemDetailModal().vm.$emit('workItemDeleted');
- expect(findTaskSvg().exists()).toBe(true);
- expect(wrapper.text()).toContain(newTitle);
+ expect($toast.show).toHaveBeenCalledWith('Work item deleted');
});
});
describe('work items detail', () => {
- const id = '1';
- const title = 'my first task';
- const type = 'task';
+ const findTaskLink = () => wrapper.find('a.gfm-issue');
- const createThenClickOnTask = () => {
- findConvertToTaskButton().vm.$emit('click');
- findCreateWorkItem().vm.$emit('onCreate', { id, title, type });
- return wrapper.findByRole('button', { name: title }).trigger('click');
- };
-
- beforeEach(() => {
- createComponent({
- props: {
- descriptionHtml: descriptionHtmlWithCheckboxes,
- },
- provide: {
- glFeatures: { workItems: true },
- },
+ describe('when opening and closing', () => {
+ beforeEach(() => {
+ createComponent({
+ props: {
+ descriptionHtml: descriptionHtmlWithTask,
+ },
+ provide: {
+ glFeatures: { workItems: true },
+ },
+ });
+ return nextTick();
});
- return nextTick();
- });
- it('opens when task button is clicked', async () => {
- expect(findWorkItemDetailModal().props('visible')).toBe(false);
+ it('opens when task button is clicked', async () => {
+ expect(findWorkItemDetailModal().props('visible')).toBe(false);
- await createThenClickOnTask();
+ await findTaskLink().trigger('click');
- expect(findWorkItemDetailModal().props('visible')).toBe(true);
- });
+ expect(findWorkItemDetailModal().props('visible')).toBe(true);
+ expect(updateHistory).toHaveBeenCalledWith({
+ url: `${TEST_HOST}/?work_item_id=2`,
+ replace: true,
+ });
+ });
- it('closes from an open state', async () => {
- await createThenClickOnTask();
+ it('closes from an open state', async () => {
+ await findTaskLink().trigger('click');
- expect(findWorkItemDetailModal().props('visible')).toBe(true);
+ expect(findWorkItemDetailModal().props('visible')).toBe(true);
- findWorkItemDetailModal().vm.$emit('close');
- await nextTick();
+ findWorkItemDetailModal().vm.$emit('close');
+ await nextTick();
- expect(findWorkItemDetailModal().props('visible')).toBe(false);
- });
+ expect(findWorkItemDetailModal().props('visible')).toBe(false);
+ expect(updateHistory).toHaveBeenLastCalledWith({
+ url: `${TEST_HOST}/`,
+ replace: true,
+ });
+ });
- it('shows error on error', async () => {
- const message = 'I am error';
+ it('tracks when opened', async () => {
+ const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
- await createThenClickOnTask();
- findWorkItemDetailModal().vm.$emit('error', message);
+ await findTaskLink().trigger('click');
- expect(createFlash).toHaveBeenCalledWith({ message });
+ expect(trackingSpy).toHaveBeenCalledWith(
+ 'workItems:show',
+ 'viewed_work_item_from_modal',
+ {
+ category: 'workItems:show',
+ label: 'work_item_view',
+ property: 'type_task',
+ },
+ );
+ });
});
- it('tracks when opened', async () => {
- const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
-
- await createThenClickOnTask();
-
- expect(trackingSpy).toHaveBeenCalledWith('workItems:show', 'viewed_work_item_from_modal', {
- category: 'workItems:show',
- label: 'work_item_view',
- property: 'type_task',
- });
+ describe('when url query `work_item_id` exists', () => {
+ it.each`
+ behavior | workItemId | visible
+ ${'opens'} | ${'123'} | ${true}
+ ${'does not open'} | ${'123e'} | ${false}
+ ${'does not open'} | ${'12e3'} | ${false}
+ ${'does not open'} | ${'1e23'} | ${false}
+ ${'does not open'} | ${'x'} | ${false}
+ ${'does not open'} | ${'undefined'} | ${false}
+ `(
+ '$behavior when url contains `work_item_id=$workItemId`',
+ async ({ workItemId, visible }) => {
+ setWindowLocation(`?work_item_id=${workItemId}`);
+
+ createComponent({
+ props: { descriptionHtml: descriptionHtmlWithTask },
+ provide: { glFeatures: { workItems: true } },
+ });
+
+ expect(findWorkItemDetailModal().props('visible')).toBe(visible);
+ },
+ );
});
});
});
diff --git a/spec/frontend/issues/show/components/fields/description_spec.js b/spec/frontend/issues/show/components/fields/description_spec.js
index dd511c3945c..0dcd70ac19b 100644
--- a/spec/frontend/issues/show/components/fields/description_spec.js
+++ b/spec/frontend/issues/show/components/fields/description_spec.js
@@ -14,9 +14,8 @@ describe('Description field component', () => {
propsData: {
markdownPreviewPath: '/',
markdownDocsPath: '/',
- formState: {
- description,
- },
+ quickActionsDocsPath: '/',
+ value: description,
},
stubs: {
MarkdownField,
diff --git a/spec/frontend/issues/show/components/fields/description_template_spec.js b/spec/frontend/issues/show/components/fields/description_template_spec.js
index abe2805e5b2..79a3bfa9840 100644
--- a/spec/frontend/issues/show/components/fields/description_template_spec.js
+++ b/spec/frontend/issues/show/components/fields/description_template_spec.js
@@ -1,74 +1,65 @@
-import Vue from 'vue';
+import { shallowMount } from '@vue/test-utils';
import descriptionTemplate from '~/issues/show/components/fields/description_template.vue';
describe('Issue description template component with templates as hash', () => {
- let vm;
- let formState;
+ let wrapper;
+ const defaultOptions = {
+ propsData: {
+ value: 'test',
+ issuableTemplates: {
+ test: [{ name: 'test', id: 'test', project_path: '/', namespace_path: '/' }],
+ },
+ projectId: 1,
+ projectPath: '/',
+ namespacePath: '/',
+ projectNamespace: '/',
+ },
+ };
- beforeEach(() => {
- const Component = Vue.extend(descriptionTemplate);
- formState = {
- description: 'test',
- };
+ const findIssuableSelector = () => wrapper.find('.js-issuable-selector');
- vm = new Component({
- propsData: {
- formState,
- issuableTemplates: {
- test: [{ name: 'test', id: 'test', project_path: '/', namespace_path: '/' }],
- },
- projectId: 1,
- projectPath: '/',
- namespacePath: '/',
- projectNamespace: '/',
- },
- }).$mount();
+ const createComponent = (options = defaultOptions) => {
+ wrapper = shallowMount(descriptionTemplate, options);
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
});
it('renders templates as JSON hash in data attribute', () => {
- expect(vm.$el.querySelector('.js-issuable-selector').getAttribute('data-data')).toBe(
+ createComponent();
+ expect(findIssuableSelector().attributes('data-data')).toBe(
'{"test":[{"name":"test","id":"test","project_path":"/","namespace_path":"/"}]}',
);
});
- it('updates formState when changing template', () => {
- vm.issuableTemplate.editor.setValue('test new template');
+ it('emits input event', () => {
+ createComponent();
+ wrapper.vm.issuableTemplate.editor.setValue('test new template');
- expect(formState.description).toBe('test new template');
+ expect(wrapper.emitted('input')).toEqual([['test new template']]);
});
- it('returns formState description with editor getValue', () => {
- formState.description = 'testing new template';
-
- expect(vm.issuableTemplate.editor.getValue()).toBe('testing new template');
+ it('returns value with editor getValue', () => {
+ createComponent();
+ expect(wrapper.vm.issuableTemplate.editor.getValue()).toBe('test');
});
-});
-
-describe('Issue description template component with templates as array', () => {
- let vm;
- let formState;
- beforeEach(() => {
- const Component = Vue.extend(descriptionTemplate);
- formState = {
- description: 'test',
- };
-
- vm = new Component({
- propsData: {
- formState,
- issuableTemplates: [{ name: 'test', id: 'test', project_path: '/', namespace_path: '/' }],
- projectId: 1,
- projectPath: '/',
- namespacePath: '/',
- projectNamespace: '/',
- },
- }).$mount();
- });
-
- it('renders templates as JSON array in data attribute', () => {
- expect(vm.$el.querySelector('.js-issuable-selector').getAttribute('data-data')).toBe(
- '[{"name":"test","id":"test","project_path":"/","namespace_path":"/"}]',
- );
+ describe('Issue description template component with templates as array', () => {
+ it('renders templates as JSON array in data attribute', () => {
+ createComponent({
+ propsData: {
+ value: 'test',
+ issuableTemplates: [{ name: 'test', id: 'test', project_path: '/', namespace_path: '/' }],
+ projectId: 1,
+ projectPath: '/',
+ namespacePath: '/',
+ projectNamespace: '/',
+ },
+ });
+ expect(findIssuableSelector().attributes('data-data')).toBe(
+ '[{"name":"test","id":"test","project_path":"/","namespace_path":"/"}]',
+ );
+ });
});
});
diff --git a/spec/frontend/issues/show/components/fields/title_spec.js b/spec/frontend/issues/show/components/fields/title_spec.js
index efd0b6fbd30..de04405d89b 100644
--- a/spec/frontend/issues/show/components/fields/title_spec.js
+++ b/spec/frontend/issues/show/components/fields/title_spec.js
@@ -12,9 +12,7 @@ describe('Title field component', () => {
wrapper = shallowMount(TitleField, {
propsData: {
- formState: {
- title: 'test',
- },
+ value: 'test',
},
});
});
diff --git a/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js b/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js
index 20c6cda33d4..35acca60de7 100644
--- a/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js
+++ b/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js
@@ -34,8 +34,9 @@ describe('Incident Tabs component', () => {
provide: {
fullPath: '',
iid: '',
+ projectId: '',
uploadMetricsFeatureAvailable: true,
- glFeatures: { incidentTimelineEventTab: true, incidentTimelineEvents: true },
+ glFeatures: { incidentTimeline: true, incidentTimelineEvents: true },
},
data() {
return { alert: mockAlert, ...data };
@@ -57,7 +58,6 @@ describe('Incident Tabs component', () => {
const findTabs = () => wrapper.findAll(GlTab);
const findSummaryTab = () => findTabs().at(0);
- const findMetricsTab = () => wrapper.find('[data-testid="metrics-tab"]');
const findAlertDetailsTab = () => wrapper.find('[data-testid="alert-details-tab"]');
const findAlertDetailsComponent = () => wrapper.find(AlertDetailsTable);
const findDescriptionComponent = () => wrapper.find(DescriptionComponent);
@@ -111,20 +111,6 @@ describe('Incident Tabs component', () => {
});
});
- describe('upload metrics feature available', () => {
- it('shows the metric tab when metrics are available', () => {
- mountComponent({}, { provide: { uploadMetricsFeatureAvailable: true } });
-
- expect(findMetricsTab().exists()).toBe(true);
- });
-
- it('hides the tab when metrics are not available', () => {
- mountComponent({}, { provide: { uploadMetricsFeatureAvailable: false } });
-
- expect(findMetricsTab().exists()).toBe(false);
- });
- });
-
describe('Snowplow tracking', () => {
beforeEach(() => {
jest.spyOn(Tracking, 'event');
diff --git a/spec/frontend/issues/show/mock_data/mock_data.js b/spec/frontend/issues/show/mock_data/mock_data.js
index 89653ff82b2..7b0b8ca686a 100644
--- a/spec/frontend/issues/show/mock_data/mock_data.js
+++ b/spec/frontend/issues/show/mock_data/mock_data.js
@@ -72,3 +72,18 @@ export const descriptionHtmlWithCheckboxes = `
</li>
</ul>
`;
+
+export const descriptionHtmlWithTask = `
+ <ul data-sourcepos="1:1-3:7" class="task-list" dir="auto">
+ <li data-sourcepos="1:1-1:10" class="task-list-item">
+ <input type="checkbox" class="task-list-item-checkbox" disabled>
+ <a href="/gitlab-org/gitlab-test/-/issues/48" data-original="#48+" data-link="false" data-link-reference="false" data-project="1" data-issue="2" data-reference-format="+" data-reference-type="task" data-container="body" data-placement="top" title="1" class="gfm gfm-issue has-tooltip">1 (#48)</a>
+ </li>
+ <li data-sourcepos="2:1-2:7" class="task-list-item">
+ <input type="checkbox" class="task-list-item-checkbox" disabled> 2
+ </li>
+ <li data-sourcepos="3:1-3:7" class="task-list-item">
+ <input type="checkbox" class="task-list-item-checkbox" disabled> 3
+ </li>
+ </ul>
+`;
diff --git a/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item_spec.js b/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item_spec.js
index b0d5859cd31..3d7bf7acb41 100644
--- a/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/components/add_namespace_modal/groups_list_item_spec.js
@@ -72,7 +72,7 @@ describe('GroupsListItem', () => {
expect(addSubscriptionSpy).toHaveBeenCalledWith(mockSubscriptionPath, mockGroup1.full_path);
expect(persistAlert).toHaveBeenCalledWith({
- linkUrl: '/help/integration/jira_development_panel.html#usage',
+ linkUrl: '/help/integration/jira_development_panel.html#use-the-integration',
message:
'You should now see GitLab.com activity inside your Jira Cloud issues. %{linkStart}Learn more%{linkEnd}',
title: 'Namespace successfully linked',
diff --git a/spec/frontend/jira_connect/subscriptions/components/app_spec.js b/spec/frontend/jira_connect/subscriptions/components/app_spec.js
index 6b3ca7ffd65..ce02144f22f 100644
--- a/spec/frontend/jira_connect/subscriptions/components/app_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/components/app_spec.js
@@ -6,10 +6,12 @@ import JiraConnectApp from '~/jira_connect/subscriptions/components/app.vue';
import SignInPage from '~/jira_connect/subscriptions/pages/sign_in.vue';
import SubscriptionsPage from '~/jira_connect/subscriptions/pages/subscriptions.vue';
import UserLink from '~/jira_connect/subscriptions/components/user_link.vue';
+import BrowserSupportAlert from '~/jira_connect/subscriptions/components/browser_support_alert.vue';
import createStore from '~/jira_connect/subscriptions/store';
import { SET_ALERT } from '~/jira_connect/subscriptions/store/mutation_types';
import { I18N_DEFAULT_SIGN_IN_ERROR_MESSAGE } from '~/jira_connect/subscriptions/constants';
import { __ } from '~/locale';
+import AccessorUtilities from '~/lib/utils/accessor';
import { mockSubscription } from '../mock_data';
jest.mock('~/jira_connect/subscriptions/utils', () => ({
@@ -26,6 +28,7 @@ describe('JiraConnectApp', () => {
const findSignInPage = () => wrapper.findComponent(SignInPage);
const findSubscriptionsPage = () => wrapper.findComponent(SubscriptionsPage);
const findUserLink = () => wrapper.findComponent(UserLink);
+ const findBrowserSupportAlert = () => wrapper.findComponent(BrowserSupportAlert);
const createComponent = ({ provide, mountFn = shallowMountExtended } = {}) => {
store = createStore();
@@ -207,4 +210,29 @@ describe('JiraConnectApp', () => {
});
});
});
+
+ describe.each`
+ jiraConnectOauthEnabled | canUseCrypto | shouldShowAlert
+ ${false} | ${false} | ${false}
+ ${false} | ${true} | ${false}
+ ${true} | ${false} | ${true}
+ ${true} | ${true} | ${false}
+ `(
+ 'when `jiraConnectOauth` feature flag is $jiraConnectOauthEnabled and `AccessorUtilities.canUseCrypto` returns $canUseCrypto',
+ ({ jiraConnectOauthEnabled, canUseCrypto, shouldShowAlert }) => {
+ beforeEach(() => {
+ jest.spyOn(AccessorUtilities, 'canUseCrypto').mockReturnValue(canUseCrypto);
+
+ createComponent({ provide: { glFeatures: { jiraConnectOauth: jiraConnectOauthEnabled } } });
+ });
+
+ it(`does ${shouldShowAlert ? '' : 'not'} render BrowserSupportAlert component`, () => {
+ expect(findBrowserSupportAlert().exists()).toBe(shouldShowAlert);
+ });
+
+ it(`does ${!shouldShowAlert ? '' : 'not'} render the main Jira Connect app template`, () => {
+ expect(wrapper.findByTestId('jira-connect-app').exists()).toBe(!shouldShowAlert);
+ });
+ },
+ );
});
diff --git a/spec/frontend/jira_connect/subscriptions/components/browser_support_alert_spec.js b/spec/frontend/jira_connect/subscriptions/components/browser_support_alert_spec.js
new file mode 100644
index 00000000000..aa93a6be3c8
--- /dev/null
+++ b/spec/frontend/jira_connect/subscriptions/components/browser_support_alert_spec.js
@@ -0,0 +1,37 @@
+import { GlAlert, GlLink } from '@gitlab/ui';
+import { shallowMount, mount } from '@vue/test-utils';
+import BrowserSupportAlert from '~/jira_connect/subscriptions/components/browser_support_alert.vue';
+
+describe('BrowserSupportAlert', () => {
+ let wrapper;
+
+ const createComponent = ({ mountFn = shallowMount } = {}) => {
+ wrapper = mountFn(BrowserSupportAlert);
+ };
+
+ const findAlert = () => wrapper.findComponent(GlAlert);
+ const findLink = () => wrapper.findComponent(GlLink);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('displays a non-dismissible alert', () => {
+ createComponent();
+
+ expect(findAlert().exists()).toBe(true);
+ expect(findAlert().props()).toMatchObject({
+ dismissible: false,
+ title: BrowserSupportAlert.i18n.title,
+ variant: 'danger',
+ });
+ });
+
+ it('renders help link with target="_blank" and rel="noopener noreferrer"', () => {
+ createComponent({ mountFn: mount });
+ expect(findLink().attributes()).toMatchObject({
+ target: '_blank',
+ rel: 'noopener',
+ });
+ });
+});
diff --git a/spec/frontend/jira_connect/subscriptions/components/compatibility_alert_spec.js b/spec/frontend/jira_connect/subscriptions/components/compatibility_alert_spec.js
index f8ee8c2c664..5f38a0acb9d 100644
--- a/spec/frontend/jira_connect/subscriptions/components/compatibility_alert_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/components/compatibility_alert_spec.js
@@ -29,7 +29,7 @@ describe('CompatibilityAlert', () => {
createComponent({ mountFn: mount });
expect(findLink().attributes()).toMatchObject({
target: '_blank',
- rel: 'noopener noreferrer',
+ rel: 'noopener',
});
});
diff --git a/spec/frontend/jira_connect/subscriptions/pages/sign_in_spec.js b/spec/frontend/jira_connect/subscriptions/pages/sign_in_spec.js
index 175896c4ab0..97d1b077164 100644
--- a/spec/frontend/jira_connect/subscriptions/pages/sign_in_spec.js
+++ b/spec/frontend/jira_connect/subscriptions/pages/sign_in_spec.js
@@ -5,7 +5,7 @@ import SignInLegacyButton from '~/jira_connect/subscriptions/components/sign_in_
import SignInOauthButton from '~/jira_connect/subscriptions/components/sign_in_oauth_button.vue';
import SubscriptionsList from '~/jira_connect/subscriptions/components/subscriptions_list.vue';
import createStore from '~/jira_connect/subscriptions/store';
-import { I18N_DEFAULT_SIGN_IN_BUTTON_TEXT } from '../../../../../app/assets/javascripts/jira_connect/subscriptions/constants';
+import { I18N_DEFAULT_SIGN_IN_BUTTON_TEXT } from '~/jira_connect/subscriptions/constants';
jest.mock('~/jira_connect/subscriptions/utils');
diff --git a/spec/frontend/jira_import/components/jira_import_form_spec.js b/spec/frontend/jira_import/components/jira_import_form_spec.js
index 7a550d85204..41d3cd46d01 100644
--- a/spec/frontend/jira_import/components/jira_import_form_spec.js
+++ b/spec/frontend/jira_import/components/jira_import_form_spec.js
@@ -6,7 +6,7 @@ import {
GlFormSelect,
GlLabel,
GlSearchBoxByType,
- GlTable,
+ GlTableLite,
} from '@gitlab/ui';
import { getByRole } from '@testing-library/dom';
import { mount, shallowMount } from '@vue/test-utils';
@@ -34,19 +34,19 @@ describe('JiraImportForm', () => {
const currentUsername = 'mrgitlab';
- const getAlert = () => wrapper.find(GlAlert);
+ const getAlert = () => wrapper.findComponent(GlAlert);
- const getSelectDropdown = () => wrapper.find(GlFormSelect);
+ const getSelectDropdown = () => wrapper.findComponent(GlFormSelect);
- const getContinueButton = () => wrapper.find(GlButton);
+ const getContinueButton = () => wrapper.findComponent(GlButton);
- const getCancelButton = () => wrapper.findAll(GlButton).at(1);
+ const getCancelButton = () => wrapper.findAllComponents(GlButton).at(1);
- const getLabel = () => wrapper.find(GlLabel);
+ const getLabel = () => wrapper.findComponent(GlLabel);
- const getTable = () => wrapper.find(GlTable);
+ const getTable = () => wrapper.findComponent(GlTableLite);
- const getUserDropdown = () => getTable().find(GlDropdown);
+ const getUserDropdown = () => getTable().findComponent(GlDropdown);
const getHeader = (name) => getByRole(wrapper.element, 'columnheader', { name });
@@ -107,14 +107,13 @@ describe('JiraImportForm', () => {
mutateSpy.mockRestore();
querySpy.mockRestore();
wrapper.destroy();
- wrapper = null;
});
describe('select dropdown project selection', () => {
it('is shown', () => {
wrapper = mountComponent();
- expect(wrapper.find(GlFormSelect).exists()).toBe(true);
+ expect(getSelectDropdown().exists()).toBe(true);
});
it('contains a list of Jira projects to select from', () => {
@@ -273,7 +272,7 @@ describe('JiraImportForm', () => {
wrapper = mountComponent({ mountFunction: mount });
- wrapper.find(GlSearchBoxByType).vm.$emit('input', 'fred');
+ wrapper.findComponent(GlSearchBoxByType).vm.$emit('input', 'fred');
});
it('makes a GraphQL call', () => {
diff --git a/spec/frontend/jobs/components/filtered_search/jobs_filtered_search_spec.js b/spec/frontend/jobs/components/filtered_search/jobs_filtered_search_spec.js
new file mode 100644
index 00000000000..322cfa3ba1f
--- /dev/null
+++ b/spec/frontend/jobs/components/filtered_search/jobs_filtered_search_spec.js
@@ -0,0 +1,49 @@
+import { GlFilteredSearch } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
+import JobsFilteredSearch from '~/jobs/components/filtered_search/jobs_filtered_search.vue';
+import { mockFailedSearchToken } from '../../mock_data';
+
+describe('Jobs filtered search', () => {
+ let wrapper;
+
+ const findFilteredSearch = () => wrapper.findComponent(GlFilteredSearch);
+ const getSearchToken = (type) =>
+ findFilteredSearch()
+ .props('availableTokens')
+ .find((token) => token.type === type);
+
+ const findStatusToken = () => getSearchToken('status');
+
+ const createComponent = () => {
+ wrapper = shallowMount(JobsFilteredSearch);
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('displays filtered search', () => {
+ expect(findFilteredSearch().exists()).toBe(true);
+ });
+
+ it('displays status token', () => {
+ expect(findStatusToken()).toMatchObject({
+ type: 'status',
+ icon: 'status',
+ title: 'Status',
+ unique: true,
+ operators: OPERATOR_IS_ONLY,
+ });
+ });
+
+ it('emits filter token to parent component', () => {
+ findFilteredSearch().vm.$emit('submit', mockFailedSearchToken);
+
+ expect(wrapper.emitted('filterJobsBySearch')).toEqual([[mockFailedSearchToken]]);
+ });
+});
diff --git a/spec/frontend/jobs/components/filtered_search/tokens/job_status_token_spec.js b/spec/frontend/jobs/components/filtered_search/tokens/job_status_token_spec.js
new file mode 100644
index 00000000000..ce8e482cc16
--- /dev/null
+++ b/spec/frontend/jobs/components/filtered_search/tokens/job_status_token_spec.js
@@ -0,0 +1,57 @@
+import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlIcon } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import { stubComponent } from 'helpers/stub_component';
+import JobStatusToken from '~/jobs/components/filtered_search/tokens/job_status_token.vue';
+
+describe('Job Status Token', () => {
+ let wrapper;
+
+ const findFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken);
+ const findAllFilteredSearchSuggestions = () =>
+ wrapper.findAllComponents(GlFilteredSearchSuggestion);
+ const findAllGlIcons = () => wrapper.findAllComponents(GlIcon);
+
+ const defaultProps = {
+ config: {
+ type: 'status',
+ icon: 'status',
+ title: 'Status',
+ unique: true,
+ },
+ value: {
+ data: '',
+ },
+ };
+
+ const createComponent = () => {
+ wrapper = shallowMount(JobStatusToken, {
+ propsData: {
+ ...defaultProps,
+ },
+ stubs: {
+ GlFilteredSearchToken: stubComponent(GlFilteredSearchToken, {
+ template: `<div><slot name="suggestions"></slot></div>`,
+ }),
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('passes config correctly', () => {
+ expect(findFilteredSearchToken().props('config')).toEqual(defaultProps.config);
+ });
+
+ it('renders all job statuses available', () => {
+ const expectedLength = 11;
+
+ expect(findAllFilteredSearchSuggestions()).toHaveLength(expectedLength);
+ expect(findAllGlIcons()).toHaveLength(expectedLength);
+ });
+});
diff --git a/spec/frontend/jobs/components/job_app_spec.js b/spec/frontend/jobs/components/job_app_spec.js
index 06ebcd7f134..9abe66b4696 100644
--- a/spec/frontend/jobs/components/job_app_spec.js
+++ b/spec/frontend/jobs/components/job_app_spec.js
@@ -375,8 +375,8 @@ describe('Job App', () => {
});
describe('sidebar', () => {
- it('has no blank blocks', (done) => {
- setupAndMount({
+ it('has no blank blocks', async () => {
+ await setupAndMount({
jobData: {
duration: null,
finished_at: null,
@@ -387,17 +387,14 @@ describe('Job App', () => {
tags: [],
cancel_path: null,
},
- })
- .then(() => {
- const blocks = wrapper.findAll('.blocks-container > *').wrappers;
- expect(blocks.length).toBeGreaterThan(0);
-
- blocks.forEach((block) => {
- expect(block.text().trim()).not.toBe('');
- });
- })
- .then(done)
- .catch(done.fail);
+ });
+
+ const blocks = wrapper.findAll('.blocks-container > *').wrappers;
+ expect(blocks.length).toBeGreaterThan(0);
+
+ blocks.forEach((block) => {
+ expect(block.text().trim()).not.toBe('');
+ });
});
});
});
diff --git a/spec/frontend/jobs/components/table/graphql/cache_config_spec.js b/spec/frontend/jobs/components/table/graphql/cache_config_spec.js
index ac79186cb46..88c97285b85 100644
--- a/spec/frontend/jobs/components/table/graphql/cache_config_spec.js
+++ b/spec/frontend/jobs/components/table/graphql/cache_config_spec.js
@@ -33,6 +33,26 @@ describe('jobs/components/table/graphql/cache_config', () => {
);
});
+ it('should not add to existing cache if the incoming elements are the same', () => {
+ // simulate that this is the last page
+ const finalExistingCache = {
+ ...CIJobConnectionExistingCache,
+ pageInfo: {
+ hasNextPage: false,
+ },
+ };
+
+ const res = cacheConfig.typePolicies.CiJobConnection.merge(
+ CIJobConnectionExistingCache,
+ finalExistingCache,
+ {
+ args: firstLoadArgs,
+ },
+ );
+
+ expect(res.nodes).toHaveLength(CIJobConnectionExistingCache.nodes.length);
+ });
+
it('should contain the pageInfo key as part of the result', () => {
const res = cacheConfig.typePolicies.CiJobConnection.merge({}, CIJobConnectionIncomingCache, {
args: firstLoadArgs,
diff --git a/spec/frontend/jobs/components/table/job_table_app_spec.js b/spec/frontend/jobs/components/table/job_table_app_spec.js
index 4d51624dfff..986fba21fb9 100644
--- a/spec/frontend/jobs/components/table/job_table_app_spec.js
+++ b/spec/frontend/jobs/components/table/job_table_app_spec.js
@@ -1,30 +1,48 @@
-import { GlSkeletonLoader, GlAlert, GlEmptyState, GlIntersectionObserver } from '@gitlab/ui';
+import {
+ GlSkeletonLoader,
+ GlAlert,
+ GlEmptyState,
+ GlIntersectionObserver,
+ GlLoadingIcon,
+} from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import { s__ } from '~/locale';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
+import createFlash from '~/flash';
import getJobsQuery from '~/jobs/components/table/graphql/queries/get_jobs.query.graphql';
import JobsTable from '~/jobs/components/table/jobs_table.vue';
import JobsTableApp from '~/jobs/components/table/jobs_table_app.vue';
import JobsTableTabs from '~/jobs/components/table/jobs_table_tabs.vue';
-import { mockJobsQueryResponse, mockJobsQueryEmptyResponse } from '../../mock_data';
+import JobsFilteredSearch from '~/jobs/components/filtered_search/jobs_filtered_search.vue';
+import {
+ mockJobsQueryResponse,
+ mockJobsQueryEmptyResponse,
+ mockFailedSearchToken,
+} from '../../mock_data';
const projectPath = 'gitlab-org/gitlab';
Vue.use(VueApollo);
+jest.mock('~/flash');
+
describe('Job table app', () => {
let wrapper;
+ let jobsTableVueSearch = true;
const successHandler = jest.fn().mockResolvedValue(mockJobsQueryResponse);
const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error'));
const emptyHandler = jest.fn().mockResolvedValue(mockJobsQueryEmptyResponse);
const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
+ const findLoadingSpinner = () => wrapper.findComponent(GlLoadingIcon);
const findTable = () => wrapper.findComponent(JobsTable);
const findTabs = () => wrapper.findComponent(JobsTableTabs);
const findAlert = () => wrapper.findComponent(GlAlert);
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
+ const findFilteredSearch = () => wrapper.findComponent(JobsFilteredSearch);
const triggerInfiniteScroll = () =>
wrapper.findComponent(GlIntersectionObserver).vm.$emit('appear');
@@ -48,6 +66,7 @@ describe('Job table app', () => {
},
provide: {
fullPath: projectPath,
+ glFeatures: { jobsTableVueSearch },
},
apolloProvider: createMockApolloProvider(handler),
});
@@ -58,11 +77,21 @@ describe('Job table app', () => {
});
describe('loading state', () => {
- it('should display skeleton loader when loading', () => {
+ beforeEach(() => {
createComponent();
+ });
+ it('should display skeleton loader when loading', () => {
expect(findSkeletonLoader().exists()).toBe(true);
expect(findTable().exists()).toBe(false);
+ expect(findLoadingSpinner().exists()).toBe(false);
+ });
+
+ it('when switching tabs only the skeleton loader should show', () => {
+ findTabs().vm.$emit('fetchJobsByStatus', null);
+
+ expect(findSkeletonLoader().exists()).toBe(true);
+ expect(findLoadingSpinner().exists()).toBe(false);
});
});
@@ -76,6 +105,7 @@ describe('Job table app', () => {
it('should display the jobs table with data', () => {
expect(findTable().exists()).toBe(true);
expect(findSkeletonLoader().exists()).toBe(false);
+ expect(findLoadingSpinner().exists()).toBe(false);
});
it('should refetch jobs query on fetchJobsByStatus event', async () => {
@@ -98,8 +128,12 @@ describe('Job table app', () => {
});
it('handles infinite scrolling by calling fetch more', async () => {
+ expect(findLoadingSpinner().exists()).toBe(true);
+
await waitForPromises();
+ expect(findLoadingSpinner().exists()).toBe(false);
+
expect(successHandler).toHaveBeenCalledWith({
after: 'eyJpZCI6IjIzMTcifQ',
fullPath: 'gitlab-org/gitlab',
@@ -137,4 +171,69 @@ describe('Job table app', () => {
expect(findTable().exists()).toBe(true);
});
});
+
+ describe('filtered search', () => {
+ it('should display filtered search', () => {
+ createComponent();
+
+ expect(findFilteredSearch().exists()).toBe(true);
+ });
+
+ // this test should be updated once BE supports tab and filtered search filtering
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/356210
+ it.each`
+ scope | shouldDisplay
+ ${null} | ${true}
+ ${['FAILED', 'SUCCESS', 'CANCELED']} | ${false}
+ `(
+ 'with tab scope $scope the filtered search displays $shouldDisplay',
+ async ({ scope, shouldDisplay }) => {
+ createComponent();
+
+ await waitForPromises();
+
+ await findTabs().vm.$emit('fetchJobsByStatus', scope);
+
+ expect(findFilteredSearch().exists()).toBe(shouldDisplay);
+ },
+ );
+
+ it('refetches jobs query when filtering', async () => {
+ createComponent();
+
+ jest.spyOn(wrapper.vm.$apollo.queries.jobs, 'refetch').mockImplementation(jest.fn());
+
+ expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0);
+
+ await findFilteredSearch().vm.$emit('filterJobsBySearch', [mockFailedSearchToken]);
+
+ expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows raw text warning when user inputs raw text', async () => {
+ const expectedWarning = {
+ message: s__(
+ 'Jobs|Raw text search is not currently supported for the jobs filtered search feature. Please use the available search tokens.',
+ ),
+ type: 'warning',
+ };
+
+ createComponent();
+
+ jest.spyOn(wrapper.vm.$apollo.queries.jobs, 'refetch').mockImplementation(jest.fn());
+
+ await findFilteredSearch().vm.$emit('filterJobsBySearch', ['raw text']);
+
+ expect(createFlash).toHaveBeenCalledWith(expectedWarning);
+ expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0);
+ });
+
+ it('should not display filtered search', () => {
+ jobsTableVueSearch = false;
+
+ createComponent();
+
+ expect(findFilteredSearch().exists()).toBe(false);
+ });
+ });
});
diff --git a/spec/frontend/jobs/components/table/jobs_table_tabs_spec.js b/spec/frontend/jobs/components/table/jobs_table_tabs_spec.js
index ac9b45be932..23632001060 100644
--- a/spec/frontend/jobs/components/table/jobs_table_tabs_spec.js
+++ b/spec/frontend/jobs/components/table/jobs_table_tabs_spec.js
@@ -1,3 +1,4 @@
+import { GlTab } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { trimText } from 'helpers/text_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
@@ -7,16 +8,31 @@ describe('Jobs Table Tabs', () => {
let wrapper;
const defaultProps = {
- jobCounts: { all: 848, pending: 0, running: 0, finished: 704 },
+ allJobsCount: 286,
+ loading: false,
};
- const findTab = (testId) => wrapper.findByTestId(testId);
+ const statuses = {
+ success: 'SUCCESS',
+ failed: 'FAILED',
+ canceled: 'CANCELED',
+ };
+
+ const findAllTab = () => wrapper.findByTestId('jobs-all-tab');
+ const findFinishedTab = () => wrapper.findByTestId('jobs-finished-tab');
+
+ const triggerTabChange = (index) => wrapper.findAllComponents(GlTab).at(index).vm.$emit('click');
- const createComponent = () => {
+ const createComponent = (props = defaultProps) => {
wrapper = extendedWrapper(
mount(JobsTableTabs, {
provide: {
- ...defaultProps,
+ jobStatuses: {
+ ...statuses,
+ },
+ },
+ propsData: {
+ ...props,
},
}),
);
@@ -30,13 +46,21 @@ describe('Jobs Table Tabs', () => {
wrapper.destroy();
});
+ it('displays All tab with count', () => {
+ expect(trimText(findAllTab().text())).toBe(`All ${defaultProps.allJobsCount}`);
+ });
+
+ it('displays Finished tab with no count', () => {
+ expect(findFinishedTab().text()).toBe('Finished');
+ });
+
it.each`
- tabId | text | count
- ${'jobs-all-tab'} | ${'All'} | ${defaultProps.jobCounts.all}
- ${'jobs-pending-tab'} | ${'Pending'} | ${defaultProps.jobCounts.pending}
- ${'jobs-running-tab'} | ${'Running'} | ${defaultProps.jobCounts.running}
- ${'jobs-finished-tab'} | ${'Finished'} | ${defaultProps.jobCounts.finished}
- `('displays the right tab text and badge count', ({ tabId, text, count }) => {
- expect(trimText(findTab(tabId).text())).toBe(`${text} ${count}`);
+ tabIndex | expectedScope
+ ${0} | ${null}
+ ${1} | ${[statuses.success, statuses.failed, statuses.canceled]}
+ `('emits fetchJobsByStatus with $expectedScope on tab change', ({ tabIndex, expectedScope }) => {
+ triggerTabChange(tabIndex);
+
+ expect(wrapper.emitted()).toEqual({ fetchJobsByStatus: [[expectedScope]] });
});
});
diff --git a/spec/frontend/jobs/components/trigger_block_spec.js b/spec/frontend/jobs/components/trigger_block_spec.js
index e0eb873dc2f..78596612d23 100644
--- a/spec/frontend/jobs/components/trigger_block_spec.js
+++ b/spec/frontend/jobs/components/trigger_block_spec.js
@@ -1,12 +1,12 @@
-import { GlButton, GlTable } from '@gitlab/ui';
+import { GlButton, GlTableLite } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import TriggerBlock from '~/jobs/components/trigger_block.vue';
describe('Trigger block', () => {
let wrapper;
- const findRevealButton = () => wrapper.find(GlButton);
- const findVariableTable = () => wrapper.find(GlTable);
+ const findRevealButton = () => wrapper.findComponent(GlButton);
+ const findVariableTable = () => wrapper.findComponent(GlTableLite);
const findShortToken = () => wrapper.find('[data-testid="trigger-short-token"]');
const findVariableValue = (index) =>
wrapper.findAll('[data-testid="trigger-build-value"]').at(index);
@@ -22,7 +22,6 @@ describe('Trigger block', () => {
afterEach(() => {
wrapper.destroy();
- wrapper = null;
});
describe('with short token and no variables', () => {
diff --git a/spec/frontend/jobs/mock_data.js b/spec/frontend/jobs/mock_data.js
index 73b9df1853d..27b6c04eded 100644
--- a/spec/frontend/jobs/mock_data.js
+++ b/spec/frontend/jobs/mock_data.js
@@ -1481,6 +1481,7 @@ export const mockJobsQueryResponse = {
project: {
id: '1',
jobs: {
+ count: 1,
pageInfo: {
endCursor: 'eyJpZCI6IjIzMTcifQ',
hasNextPage: true,
@@ -1911,10 +1912,19 @@ export const CIJobConnectionIncomingCacheRunningStatus = {
};
export const CIJobConnectionExistingCache = {
+ pageInfo: {
+ __typename: 'PageInfo',
+ endCursor: 'eyJpZCI6IjIwNTEifQ',
+ hasNextPage: true,
+ hasPreviousPage: false,
+ startCursor: 'eyJpZCI6IjIxNzMifQ',
+ },
nodes: [
- { __ref: 'CiJob:gid://gitlab/Ci::Build/2057' },
- { __ref: 'CiJob:gid://gitlab/Ci::Build/2056' },
- { __ref: 'CiJob:gid://gitlab/Ci::Build/2051' },
+ { __ref: 'CiJob:gid://gitlab/Ci::Build/2100' },
+ { __ref: 'CiJob:gid://gitlab/Ci::Build/2101' },
+ { __ref: 'CiJob:gid://gitlab/Ci::Build/2102' },
],
statuses: 'PENDING',
};
+
+export const mockFailedSearchToken = { type: 'status', value: { data: 'FAILED', operator: '=' } };
diff --git a/spec/frontend/jobs/store/actions_spec.js b/spec/frontend/jobs/store/actions_spec.js
index 16448d6a3ca..b9f97a3c3ae 100644
--- a/spec/frontend/jobs/store/actions_spec.js
+++ b/spec/frontend/jobs/store/actions_spec.js
@@ -39,62 +39,60 @@ describe('Job State actions', () => {
});
describe('setJobEndpoint', () => {
- it('should commit SET_JOB_ENDPOINT mutation', (done) => {
- testAction(
+ it('should commit SET_JOB_ENDPOINT mutation', () => {
+ return testAction(
setJobEndpoint,
'job/872324.json',
mockedState,
[{ type: types.SET_JOB_ENDPOINT, payload: 'job/872324.json' }],
[],
- done,
);
});
});
describe('setJobLogOptions', () => {
- it('should commit SET_JOB_LOG_OPTIONS mutation', (done) => {
- testAction(
+ it('should commit SET_JOB_LOG_OPTIONS mutation', () => {
+ return testAction(
setJobLogOptions,
{ pagePath: 'job/872324/trace.json' },
mockedState,
[{ type: types.SET_JOB_LOG_OPTIONS, payload: { pagePath: 'job/872324/trace.json' } }],
[],
- done,
);
});
});
describe('hideSidebar', () => {
- it('should commit HIDE_SIDEBAR mutation', (done) => {
- testAction(hideSidebar, null, mockedState, [{ type: types.HIDE_SIDEBAR }], [], done);
+ it('should commit HIDE_SIDEBAR mutation', () => {
+ return testAction(hideSidebar, null, mockedState, [{ type: types.HIDE_SIDEBAR }], []);
});
});
describe('showSidebar', () => {
- it('should commit HIDE_SIDEBAR mutation', (done) => {
- testAction(showSidebar, null, mockedState, [{ type: types.SHOW_SIDEBAR }], [], done);
+ it('should commit SHOW_SIDEBAR mutation', () => {
+ return testAction(showSidebar, null, mockedState, [{ type: types.SHOW_SIDEBAR }], []);
});
});
describe('toggleSidebar', () => {
describe('when isSidebarOpen is true', () => {
- it('should dispatch hideSidebar', (done) => {
- testAction(toggleSidebar, null, mockedState, [], [{ type: 'hideSidebar' }], done);
+ it('should dispatch hideSidebar', () => {
+ return testAction(toggleSidebar, null, mockedState, [], [{ type: 'hideSidebar' }]);
});
});
describe('when isSidebarOpen is false', () => {
- it('should dispatch showSidebar', (done) => {
+ it('should dispatch showSidebar', () => {
mockedState.isSidebarOpen = false;
- testAction(toggleSidebar, null, mockedState, [], [{ type: 'showSidebar' }], done);
+ return testAction(toggleSidebar, null, mockedState, [], [{ type: 'showSidebar' }]);
});
});
});
describe('requestJob', () => {
- it('should commit REQUEST_JOB mutation', (done) => {
- testAction(requestJob, null, mockedState, [{ type: types.REQUEST_JOB }], [], done);
+ it('should commit REQUEST_JOB mutation', () => {
+ return testAction(requestJob, null, mockedState, [{ type: types.REQUEST_JOB }], []);
});
});
@@ -113,10 +111,10 @@ describe('Job State actions', () => {
});
describe('success', () => {
- it('dispatches requestJob and receiveJobSuccess ', (done) => {
+ it('dispatches requestJob and receiveJobSuccess ', () => {
mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, { id: 121212, name: 'karma' });
- testAction(
+ return testAction(
fetchJob,
null,
mockedState,
@@ -130,7 +128,6 @@ describe('Job State actions', () => {
type: 'receiveJobSuccess',
},
],
- done,
);
});
});
@@ -140,8 +137,8 @@ describe('Job State actions', () => {
mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500);
});
- it('dispatches requestJob and receiveJobError ', (done) => {
- testAction(
+ it('dispatches requestJob and receiveJobError ', () => {
+ return testAction(
fetchJob,
null,
mockedState,
@@ -154,46 +151,50 @@ describe('Job State actions', () => {
type: 'receiveJobError',
},
],
- done,
);
});
});
});
describe('receiveJobSuccess', () => {
- it('should commit RECEIVE_JOB_SUCCESS mutation', (done) => {
- testAction(
+ it('should commit RECEIVE_JOB_SUCCESS mutation', () => {
+ return testAction(
receiveJobSuccess,
{ id: 121232132 },
mockedState,
[{ type: types.RECEIVE_JOB_SUCCESS, payload: { id: 121232132 } }],
[],
- done,
);
});
});
describe('receiveJobError', () => {
- it('should commit RECEIVE_JOB_ERROR mutation', (done) => {
- testAction(receiveJobError, null, mockedState, [{ type: types.RECEIVE_JOB_ERROR }], [], done);
+ it('should commit RECEIVE_JOB_ERROR mutation', () => {
+ return testAction(
+ receiveJobError,
+ null,
+ mockedState,
+ [{ type: types.RECEIVE_JOB_ERROR }],
+ [],
+ );
});
});
describe('scrollTop', () => {
- it('should dispatch toggleScrollButtons action', (done) => {
- testAction(scrollTop, null, mockedState, [], [{ type: 'toggleScrollButtons' }], done);
+ it('should dispatch toggleScrollButtons action', () => {
+ return testAction(scrollTop, null, mockedState, [], [{ type: 'toggleScrollButtons' }]);
});
});
describe('scrollBottom', () => {
- it('should dispatch toggleScrollButtons action', (done) => {
- testAction(scrollBottom, null, mockedState, [], [{ type: 'toggleScrollButtons' }], done);
+ it('should dispatch toggleScrollButtons action', () => {
+ return testAction(scrollBottom, null, mockedState, [], [{ type: 'toggleScrollButtons' }]);
});
});
describe('requestJobLog', () => {
- it('should commit REQUEST_JOB_LOG mutation', (done) => {
- testAction(requestJobLog, null, mockedState, [{ type: types.REQUEST_JOB_LOG }], [], done);
+ it('should commit REQUEST_JOB_LOG mutation', () => {
+ return testAction(requestJobLog, null, mockedState, [{ type: types.REQUEST_JOB_LOG }], []);
});
});
@@ -212,13 +213,13 @@ describe('Job State actions', () => {
});
describe('success', () => {
- it('dispatches requestJobLog, receiveJobLogSuccess and stopPollingJobLog when job is complete', (done) => {
+ it('dispatches requestJobLog, receiveJobLogSuccess and stopPollingJobLog when job is complete', () => {
mock.onGet(`${TEST_HOST}/endpoint/trace.json`).replyOnce(200, {
html: 'I, [2018-08-17T22:57:45.707325 #1841] INFO -- :',
complete: true,
});
- testAction(
+ return testAction(
fetchJobLog,
null,
mockedState,
@@ -239,7 +240,6 @@ describe('Job State actions', () => {
type: 'stopPollingJobLog',
},
],
- done,
);
});
@@ -255,8 +255,8 @@ describe('Job State actions', () => {
mock.onGet(`${TEST_HOST}/endpoint/trace.json`).replyOnce(200, jobLogPayload);
});
- it('dispatches startPollingJobLog', (done) => {
- testAction(
+ it('dispatches startPollingJobLog', () => {
+ return testAction(
fetchJobLog,
null,
mockedState,
@@ -266,14 +266,13 @@ describe('Job State actions', () => {
{ type: 'receiveJobLogSuccess', payload: jobLogPayload },
{ type: 'startPollingJobLog' },
],
- done,
);
});
- it('does not dispatch startPollingJobLog when timeout is non-empty', (done) => {
+ it('does not dispatch startPollingJobLog when timeout is non-empty', () => {
mockedState.jobLogTimeout = 1;
- testAction(
+ return testAction(
fetchJobLog,
null,
mockedState,
@@ -282,7 +281,6 @@ describe('Job State actions', () => {
{ type: 'toggleScrollisInBottom', payload: true },
{ type: 'receiveJobLogSuccess', payload: jobLogPayload },
],
- done,
);
});
});
@@ -293,8 +291,8 @@ describe('Job State actions', () => {
mock.onGet(`${TEST_HOST}/endpoint/trace.json`).reply(500);
});
- it('dispatches requestJobLog and receiveJobLogError ', (done) => {
- testAction(
+ it('dispatches requestJobLog and receiveJobLogError ', () => {
+ return testAction(
fetchJobLog,
null,
mockedState,
@@ -304,7 +302,6 @@ describe('Job State actions', () => {
type: 'receiveJobLogError',
},
],
- done,
);
});
});
@@ -358,65 +355,58 @@ describe('Job State actions', () => {
window.clearTimeout = origTimeout;
});
- it('should commit STOP_POLLING_JOB_LOG mutation ', (done) => {
+ it('should commit STOP_POLLING_JOB_LOG mutation ', async () => {
const jobLogTimeout = 7;
- testAction(
+ await testAction(
stopPollingJobLog,
null,
{ ...mockedState, jobLogTimeout },
[{ type: types.SET_JOB_LOG_TIMEOUT, payload: 0 }, { type: types.STOP_POLLING_JOB_LOG }],
[],
- )
- .then(() => {
- expect(window.clearTimeout).toHaveBeenCalledWith(jobLogTimeout);
- })
- .then(done)
- .catch(done.fail);
+ );
+ expect(window.clearTimeout).toHaveBeenCalledWith(jobLogTimeout);
});
});
describe('receiveJobLogSuccess', () => {
- it('should commit RECEIVE_JOB_LOG_SUCCESS mutation ', (done) => {
- testAction(
+ it('should commit RECEIVE_JOB_LOG_SUCCESS mutation ', () => {
+ return testAction(
receiveJobLogSuccess,
'hello world',
mockedState,
[{ type: types.RECEIVE_JOB_LOG_SUCCESS, payload: 'hello world' }],
[],
- done,
);
});
});
describe('receiveJobLogError', () => {
- it('should commit stop polling job log', (done) => {
- testAction(receiveJobLogError, null, mockedState, [], [{ type: 'stopPollingJobLog' }], done);
+ it('should commit stop polling job log', () => {
+ return testAction(receiveJobLogError, null, mockedState, [], [{ type: 'stopPollingJobLog' }]);
});
});
describe('toggleCollapsibleLine', () => {
- it('should commit TOGGLE_COLLAPSIBLE_LINE mutation ', (done) => {
- testAction(
+ it('should commit TOGGLE_COLLAPSIBLE_LINE mutation ', () => {
+ return testAction(
toggleCollapsibleLine,
{ isClosed: true },
mockedState,
[{ type: types.TOGGLE_COLLAPSIBLE_LINE, payload: { isClosed: true } }],
[],
- done,
);
});
});
describe('requestJobsForStage', () => {
- it('should commit REQUEST_JOBS_FOR_STAGE mutation ', (done) => {
- testAction(
+ it('should commit REQUEST_JOBS_FOR_STAGE mutation ', () => {
+ return testAction(
requestJobsForStage,
{ name: 'deploy' },
mockedState,
[{ type: types.REQUEST_JOBS_FOR_STAGE, payload: { name: 'deploy' } }],
[],
- done,
);
});
});
@@ -433,12 +423,12 @@ describe('Job State actions', () => {
});
describe('success', () => {
- it('dispatches requestJobsForStage and receiveJobsForStageSuccess ', (done) => {
+ it('dispatches requestJobsForStage and receiveJobsForStageSuccess ', () => {
mock
.onGet(`${TEST_HOST}/jobs.json`)
.replyOnce(200, { latest_statuses: [{ id: 121212, name: 'build' }], retried: [] });
- testAction(
+ return testAction(
fetchJobsForStage,
{ dropdown_path: `${TEST_HOST}/jobs.json` },
mockedState,
@@ -453,7 +443,6 @@ describe('Job State actions', () => {
type: 'receiveJobsForStageSuccess',
},
],
- done,
);
});
});
@@ -463,8 +452,8 @@ describe('Job State actions', () => {
mock.onGet(`${TEST_HOST}/jobs.json`).reply(500);
});
- it('dispatches requestJobsForStage and receiveJobsForStageError', (done) => {
- testAction(
+ it('dispatches requestJobsForStage and receiveJobsForStageError', () => {
+ return testAction(
fetchJobsForStage,
{ dropdown_path: `${TEST_HOST}/jobs.json` },
mockedState,
@@ -478,34 +467,31 @@ describe('Job State actions', () => {
type: 'receiveJobsForStageError',
},
],
- done,
);
});
});
});
describe('receiveJobsForStageSuccess', () => {
- it('should commit RECEIVE_JOBS_FOR_STAGE_SUCCESS mutation ', (done) => {
- testAction(
+ it('should commit RECEIVE_JOBS_FOR_STAGE_SUCCESS mutation ', () => {
+ return testAction(
receiveJobsForStageSuccess,
[{ id: 121212, name: 'karma' }],
mockedState,
[{ type: types.RECEIVE_JOBS_FOR_STAGE_SUCCESS, payload: [{ id: 121212, name: 'karma' }] }],
[],
- done,
);
});
});
describe('receiveJobsForStageError', () => {
- it('should commit RECEIVE_JOBS_FOR_STAGE_ERROR mutation ', (done) => {
- testAction(
+ it('should commit RECEIVE_JOBS_FOR_STAGE_ERROR mutation ', () => {
+ return testAction(
receiveJobsForStageError,
null,
mockedState,
[{ type: types.RECEIVE_JOBS_FOR_STAGE_ERROR }],
[],
- done,
);
});
});
diff --git a/spec/frontend/labels/components/promote_label_modal_spec.js b/spec/frontend/labels/components/promote_label_modal_spec.js
index d2fbdfc9a8d..8cfaba6f98a 100644
--- a/spec/frontend/labels/components/promote_label_modal_spec.js
+++ b/spec/frontend/labels/components/promote_label_modal_spec.js
@@ -50,7 +50,7 @@ describe('Promote label modal', () => {
vm.$destroy();
});
- it('redirects when a label is promoted', (done) => {
+ it('redirects when a label is promoted', () => {
const responseURL = `${TEST_HOST}/dummy/endpoint`;
jest.spyOn(axios, 'post').mockImplementation((url) => {
expect(url).toBe(labelMockData.url);
@@ -65,39 +65,35 @@ describe('Promote label modal', () => {
});
});
- vm.onSubmit()
- .then(() => {
- expect(eventHub.$emit).toHaveBeenCalledWith('promoteLabelModal.requestFinished', {
- labelUrl: labelMockData.url,
- successful: true,
- });
- })
- .then(done)
- .catch(done.fail);
+ return vm.onSubmit().then(() => {
+ expect(eventHub.$emit).toHaveBeenCalledWith('promoteLabelModal.requestFinished', {
+ labelUrl: labelMockData.url,
+ successful: true,
+ });
+ });
});
- it('displays an error if promoting a label failed', (done) => {
+ it('displays an error if promoting a label failed', () => {
const dummyError = new Error('promoting label failed');
dummyError.response = { status: 500 };
+
jest.spyOn(axios, 'post').mockImplementation((url) => {
expect(url).toBe(labelMockData.url);
expect(eventHub.$emit).toHaveBeenCalledWith(
'promoteLabelModal.requestStarted',
labelMockData.url,
);
+
return Promise.reject(dummyError);
});
- vm.onSubmit()
- .catch((error) => {
- expect(error).toBe(dummyError);
- expect(eventHub.$emit).toHaveBeenCalledWith('promoteLabelModal.requestFinished', {
- labelUrl: labelMockData.url,
- successful: false,
- });
- })
- .then(done)
- .catch(done.fail);
+ return vm.onSubmit().catch((error) => {
+ expect(error).toBe(dummyError);
+ expect(eventHub.$emit).toHaveBeenCalledWith('promoteLabelModal.requestFinished', {
+ labelUrl: labelMockData.url,
+ successful: false,
+ });
+ });
});
});
});
diff --git a/spec/frontend/lib/apollo/suppress_network_errors_during_navigation_link_spec.js b/spec/frontend/lib/apollo/suppress_network_errors_during_navigation_link_spec.js
index 971ba8b583c..5ac7a7985a8 100644
--- a/spec/frontend/lib/apollo/suppress_network_errors_during_navigation_link_spec.js
+++ b/spec/frontend/lib/apollo/suppress_network_errors_during_navigation_link_spec.js
@@ -82,34 +82,39 @@ describe('getSuppressNetworkErrorsDuringNavigationLink', () => {
isNavigatingAway.mockReturnValue(false);
});
- it('forwards successful requests', (done) => {
+ it('forwards successful requests', () => {
createSubscription(makeMockSuccessLink(), {
next({ data }) {
expect(data).toEqual({ foo: { id: 1 } });
},
- error: () => done.fail('Should not happen'),
- complete: () => done(),
+ error: () => {
+ throw new Error('Should not happen');
+ },
});
});
- it('forwards GraphQL errors', (done) => {
+ it('forwards GraphQL errors', () => {
createSubscription(makeMockGraphQLErrorLink(), {
next({ errors }) {
expect(errors).toEqual([{ message: 'foo' }]);
},
- error: () => done.fail('Should not happen'),
- complete: () => done(),
+ error: () => {
+ throw new Error('Should not happen');
+ },
});
});
- it('forwards network errors', (done) => {
+ it('forwards network errors', () => {
createSubscription(makeMockNetworkErrorLink(), {
- next: () => done.fail('Should not happen'),
+ next: () => {
+ throw new Error('Should not happen');
+ },
error: (error) => {
expect(error.message).toBe('NetworkError');
- done();
},
- complete: () => done.fail('Should not happen'),
+ complete: () => {
+ throw new Error('Should not happen');
+ },
});
});
});
@@ -119,23 +124,25 @@ describe('getSuppressNetworkErrorsDuringNavigationLink', () => {
isNavigatingAway.mockReturnValue(true);
});
- it('forwards successful requests', (done) => {
+ it('forwards successful requests', () => {
createSubscription(makeMockSuccessLink(), {
next({ data }) {
expect(data).toEqual({ foo: { id: 1 } });
},
- error: () => done.fail('Should not happen'),
- complete: () => done(),
+ error: () => {
+ throw new Error('Should not happen');
+ },
});
});
- it('forwards GraphQL errors', (done) => {
+ it('forwards GraphQL errors', () => {
createSubscription(makeMockGraphQLErrorLink(), {
next({ errors }) {
expect(errors).toEqual([{ message: 'foo' }]);
},
- error: () => done.fail('Should not happen'),
- complete: () => done(),
+ error: () => {
+ throw new Error('Should not happen');
+ },
});
});
});
diff --git a/spec/frontend/lib/gfm/index_spec.js b/spec/frontend/lib/gfm/index_spec.js
new file mode 100644
index 00000000000..5c72b5a51a7
--- /dev/null
+++ b/spec/frontend/lib/gfm/index_spec.js
@@ -0,0 +1,46 @@
+import { render } from '~/lib/gfm';
+
+describe('gfm', () => {
+ describe('render', () => {
+ it('processes Commonmark and provides an ast to the renderer function', async () => {
+ let result;
+
+ await render({
+ markdown: 'This is text',
+ renderer: (tree) => {
+ result = tree;
+ },
+ });
+
+ expect(result.type).toBe('root');
+ });
+
+ it('transforms raw HTML into individual nodes in the AST', async () => {
+ let result;
+
+ await render({
+ markdown: '<strong>This is bold text</strong>',
+ renderer: (tree) => {
+ result = tree;
+ },
+ });
+
+ expect(result.children[0].children[0]).toMatchObject({
+ type: 'element',
+ tagName: 'strong',
+ properties: {},
+ });
+ });
+
+ it('returns the result of executing the renderer function', async () => {
+ const result = await render({
+ markdown: '<strong>This is bold text</strong>',
+ renderer: () => {
+ return 'rendered tree';
+ },
+ });
+
+ expect(result).toBe('rendered tree');
+ });
+ });
+});
diff --git a/spec/frontend/lib/utils/apollo_startup_js_link_spec.js b/spec/frontend/lib/utils/apollo_startup_js_link_spec.js
index e58bc063004..06573f346e0 100644
--- a/spec/frontend/lib/utils/apollo_startup_js_link_spec.js
+++ b/spec/frontend/lib/utils/apollo_startup_js_link_spec.js
@@ -58,17 +58,16 @@ describe('StartupJSLink', () => {
link = ApolloLink.from([startupLink, new ApolloLink(() => Observable.of(FORWARDED_RESPONSE))]);
};
- it('forwards requests if no calls are set up', (done) => {
+ it('forwards requests if no calls are set up', () => {
setupLink();
link.request(mockOperation()).subscribe((result) => {
expect(result).toEqual(FORWARDED_RESPONSE);
expect(startupLink.startupCalls).toBe(null);
expect(startupLink.request).toEqual(StartupJSLink.noopRequest);
- done();
});
});
- it('forwards requests if the operation is not pre-loaded', (done) => {
+ it('forwards requests if the operation is not pre-loaded', () => {
window.gl = {
startup_graphql_calls: [
{
@@ -82,12 +81,11 @@ describe('StartupJSLink', () => {
link.request(mockOperation({ operationName: 'notLoaded' })).subscribe((result) => {
expect(result).toEqual(FORWARDED_RESPONSE);
expect(startupLink.startupCalls.size).toBe(1);
- done();
});
});
describe('variable match errors: ', () => {
- it('forwards requests if the variables are not matching', (done) => {
+ it('forwards requests if the variables are not matching', () => {
window.gl = {
startup_graphql_calls: [
{
@@ -101,11 +99,10 @@ describe('StartupJSLink', () => {
link.request(mockOperation()).subscribe((result) => {
expect(result).toEqual(FORWARDED_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
- done();
});
});
- it('forwards requests if more variables are set in the operation', (done) => {
+ it('forwards requests if more variables are set in the operation', () => {
window.gl = {
startup_graphql_calls: [
{
@@ -118,11 +115,10 @@ describe('StartupJSLink', () => {
link.request(mockOperation()).subscribe((result) => {
expect(result).toEqual(FORWARDED_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
- done();
});
});
- it('forwards requests if less variables are set in the operation', (done) => {
+ it('forwards requests if less variables are set in the operation', () => {
window.gl = {
startup_graphql_calls: [
{
@@ -136,11 +132,10 @@ describe('StartupJSLink', () => {
link.request(mockOperation({ variables: { id: 3 } })).subscribe((result) => {
expect(result).toEqual(FORWARDED_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
- done();
});
});
- it('forwards requests if different variables are set', (done) => {
+ it('forwards requests if different variables are set', () => {
window.gl = {
startup_graphql_calls: [
{
@@ -154,11 +149,10 @@ describe('StartupJSLink', () => {
link.request(mockOperation({ variables: { id: 3 } })).subscribe((result) => {
expect(result).toEqual(FORWARDED_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
- done();
});
});
- it('forwards requests if array variables have a different order', (done) => {
+ it('forwards requests if array variables have a different order', () => {
window.gl = {
startup_graphql_calls: [
{
@@ -172,13 +166,12 @@ describe('StartupJSLink', () => {
link.request(mockOperation({ variables: { id: [4, 3] } })).subscribe((result) => {
expect(result).toEqual(FORWARDED_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
- done();
});
});
});
describe('error handling', () => {
- it('forwards the call if the fetchCall is failing with a HTTP Error', (done) => {
+ it('forwards the call if the fetchCall is failing with a HTTP Error', () => {
window.gl = {
startup_graphql_calls: [
{
@@ -192,11 +185,10 @@ describe('StartupJSLink', () => {
link.request(mockOperation()).subscribe((result) => {
expect(result).toEqual(FORWARDED_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
- done();
});
});
- it('forwards the call if it errors (e.g. failing JSON)', (done) => {
+ it('forwards the call if it errors (e.g. failing JSON)', () => {
window.gl = {
startup_graphql_calls: [
{
@@ -210,11 +202,10 @@ describe('StartupJSLink', () => {
link.request(mockOperation()).subscribe((result) => {
expect(result).toEqual(FORWARDED_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
- done();
});
});
- it('forwards the call if the response contains an error', (done) => {
+ it('forwards the call if the response contains an error', () => {
window.gl = {
startup_graphql_calls: [
{
@@ -228,11 +219,10 @@ describe('StartupJSLink', () => {
link.request(mockOperation()).subscribe((result) => {
expect(result).toEqual(FORWARDED_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
- done();
});
});
- it("forwards the call if the response doesn't contain a data object", (done) => {
+ it("forwards the call if the response doesn't contain a data object", () => {
window.gl = {
startup_graphql_calls: [
{
@@ -246,12 +236,11 @@ describe('StartupJSLink', () => {
link.request(mockOperation()).subscribe((result) => {
expect(result).toEqual(FORWARDED_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
- done();
});
});
});
- it('resolves the request if the operation is matching', (done) => {
+ it('resolves the request if the operation is matching', () => {
window.gl = {
startup_graphql_calls: [
{
@@ -265,11 +254,10 @@ describe('StartupJSLink', () => {
link.request(mockOperation()).subscribe((result) => {
expect(result).toEqual(STARTUP_JS_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
- done();
});
});
- it('resolves the request exactly once', (done) => {
+ it('resolves the request exactly once', () => {
window.gl = {
startup_graphql_calls: [
{
@@ -285,12 +273,11 @@ describe('StartupJSLink', () => {
expect(startupLink.startupCalls.size).toBe(0);
link.request(mockOperation()).subscribe((result2) => {
expect(result2).toEqual(FORWARDED_RESPONSE);
- done();
});
});
});
- it('resolves the request if the variables have a different order', (done) => {
+ it('resolves the request if the variables have a different order', () => {
window.gl = {
startup_graphql_calls: [
{
@@ -304,11 +291,10 @@ describe('StartupJSLink', () => {
link.request(mockOperation({ variables: { name: 'foo', id: 3 } })).subscribe((result) => {
expect(result).toEqual(STARTUP_JS_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
- done();
});
});
- it('resolves the request if the variables have undefined values', (done) => {
+ it('resolves the request if the variables have undefined values', () => {
window.gl = {
startup_graphql_calls: [
{
@@ -324,11 +310,10 @@ describe('StartupJSLink', () => {
.subscribe((result) => {
expect(result).toEqual(STARTUP_JS_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
- done();
});
});
- it('resolves the request if the variables are of an array format', (done) => {
+ it('resolves the request if the variables are of an array format', () => {
window.gl = {
startup_graphql_calls: [
{
@@ -342,11 +327,10 @@ describe('StartupJSLink', () => {
link.request(mockOperation({ variables: { id: [3, 4] } })).subscribe((result) => {
expect(result).toEqual(STARTUP_JS_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
- done();
});
});
- it('resolves multiple requests correctly', (done) => {
+ it('resolves multiple requests correctly', () => {
window.gl = {
startup_graphql_calls: [
{
@@ -368,7 +352,6 @@ describe('StartupJSLink', () => {
link.request(mockOperation({ operationName: OPERATION_NAME })).subscribe((result2) => {
expect(result2).toEqual(STARTUP_JS_RESPONSE);
expect(startupLink.startupCalls.size).toBe(0);
- done();
});
});
});
diff --git a/spec/frontend/lib/utils/common_utils_spec.js b/spec/frontend/lib/utils/common_utils_spec.js
index 0be0bf89210..763a9bd30fe 100644
--- a/spec/frontend/lib/utils/common_utils_spec.js
+++ b/spec/frontend/lib/utils/common_utils_spec.js
@@ -266,15 +266,18 @@ describe('common_utils', () => {
});
describe('debounceByAnimationFrame', () => {
- it('debounces a function to allow a maximum of one call per animation frame', (done) => {
+ it('debounces a function to allow a maximum of one call per animation frame', () => {
const spy = jest.fn();
const debouncedSpy = commonUtils.debounceByAnimationFrame(spy);
- window.requestAnimationFrame(() => {
- debouncedSpy();
- debouncedSpy();
+
+ return new Promise((resolve) => {
window.requestAnimationFrame(() => {
- expect(spy).toHaveBeenCalledTimes(1);
- done();
+ debouncedSpy();
+ debouncedSpy();
+ window.requestAnimationFrame(() => {
+ expect(spy).toHaveBeenCalledTimes(1);
+ resolve();
+ });
});
});
});
@@ -372,28 +375,24 @@ describe('common_utils', () => {
jest.spyOn(window, 'setTimeout');
});
- it('solves the promise from the callback', (done) => {
+ it('solves the promise from the callback', () => {
const expectedResponseValue = 'Success!';
- commonUtils
+ return commonUtils
.backOff((next, stop) =>
new Promise((resolve) => {
resolve(expectedResponseValue);
- })
- .then((resp) => {
- stop(resp);
- })
- .catch(done.fail),
+ }).then((resp) => {
+ stop(resp);
+ }),
)
.then((respBackoff) => {
expect(respBackoff).toBe(expectedResponseValue);
- done();
- })
- .catch(done.fail);
+ });
});
- it('catches the rejected promise from the callback ', (done) => {
+ it('catches the rejected promise from the callback ', () => {
const errorMessage = 'Mistakes were made!';
- commonUtils
+ return commonUtils
.backOff((next, stop) => {
new Promise((resolve, reject) => {
reject(new Error(errorMessage));
@@ -406,39 +405,34 @@ describe('common_utils', () => {
.catch((errBackoffResp) => {
expect(errBackoffResp instanceof Error).toBe(true);
expect(errBackoffResp.message).toBe(errorMessage);
- done();
});
});
- it('solves the promise correctly after retrying a third time', (done) => {
+ it('solves the promise correctly after retrying a third time', () => {
let numberOfCalls = 1;
const expectedResponseValue = 'Success!';
- commonUtils
+ return commonUtils
.backOff((next, stop) =>
- Promise.resolve(expectedResponseValue)
- .then((resp) => {
- if (numberOfCalls < 3) {
- numberOfCalls += 1;
- next();
- jest.runOnlyPendingTimers();
- } else {
- stop(resp);
- }
- })
- .catch(done.fail),
+ Promise.resolve(expectedResponseValue).then((resp) => {
+ if (numberOfCalls < 3) {
+ numberOfCalls += 1;
+ next();
+ jest.runOnlyPendingTimers();
+ } else {
+ stop(resp);
+ }
+ }),
)
.then((respBackoff) => {
const timeouts = window.setTimeout.mock.calls.map(([, timeout]) => timeout);
expect(timeouts).toEqual([2000, 4000]);
expect(respBackoff).toBe(expectedResponseValue);
- done();
- })
- .catch(done.fail);
+ });
});
- it('rejects the backOff promise after timing out', (done) => {
- commonUtils
+ it('rejects the backOff promise after timing out', () => {
+ return commonUtils
.backOff((next) => {
next();
jest.runOnlyPendingTimers();
@@ -449,7 +443,6 @@ describe('common_utils', () => {
expect(timeouts).toEqual([2000, 4000, 8000, 16000, 32000, 32000]);
expect(errBackoffResp instanceof Error).toBe(true);
expect(errBackoffResp.message).toBe('BACKOFF_TIMEOUT');
- done();
});
});
});
diff --git a/spec/frontend/lib/utils/confirm_via_gl_modal/confirm_modal_spec.js b/spec/frontend/lib/utils/confirm_via_gl_modal/confirm_modal_spec.js
index e06d1384610..d6131b1a1d7 100644
--- a/spec/frontend/lib/utils/confirm_via_gl_modal/confirm_modal_spec.js
+++ b/spec/frontend/lib/utils/confirm_via_gl_modal/confirm_modal_spec.js
@@ -5,12 +5,23 @@ import ConfirmModal from '~/lib/utils/confirm_via_gl_modal/confirm_modal.vue';
describe('Confirm Modal', () => {
let wrapper;
let modal;
+ const SECONDARY_TEXT = 'secondaryText';
+ const SECONDARY_VARIANT = 'danger';
- const createComponent = ({ primaryText, primaryVariant, title, hideCancel = false } = {}) => {
+ const createComponent = ({
+ primaryText,
+ primaryVariant,
+ secondaryText,
+ secondaryVariant,
+ title,
+ hideCancel = false,
+ } = {}) => {
wrapper = mount(ConfirmModal, {
propsData: {
primaryText,
primaryVariant,
+ secondaryText,
+ secondaryVariant,
hideCancel,
title,
},
@@ -65,6 +76,19 @@ describe('Confirm Modal', () => {
expect(props.actionCancel).toBeNull();
});
+ it('should not show secondary Button when secondary Text is not set', () => {
+ createComponent();
+ const props = findGlModal().props();
+ expect(props.actionSecondary).toBeNull();
+ });
+
+ it('should show secondary Button when secondaryText is set', () => {
+ createComponent({ secondaryText: SECONDARY_TEXT, secondaryVariant: SECONDARY_VARIANT });
+ const actionSecondary = findGlModal().props('actionSecondary');
+ expect(actionSecondary.text).toEqual(SECONDARY_TEXT);
+ expect(actionSecondary.attributes.variant).toEqual(SECONDARY_VARIANT);
+ });
+
it('should set the modal title when the `title` prop is set', () => {
const title = 'Modal title';
createComponent({ title });
diff --git a/spec/frontend/lib/utils/datetime/date_calculation_utility_spec.js b/spec/frontend/lib/utils/datetime/date_calculation_utility_spec.js
new file mode 100644
index 00000000000..47bb512cbb5
--- /dev/null
+++ b/spec/frontend/lib/utils/datetime/date_calculation_utility_spec.js
@@ -0,0 +1,17 @@
+import { newDateAsLocaleTime } from '~/lib/utils/datetime/date_calculation_utility';
+
+describe('newDateAsLocaleTime', () => {
+ it.each`
+ string | expected
+ ${'2022-03-22'} | ${new Date('2022-03-22T00:00:00.000Z')}
+ ${'2022-03-22T00:00:00.000Z'} | ${new Date('2022-03-22T00:00:00.000Z')}
+ ${2022} | ${null}
+ ${[]} | ${null}
+ ${{}} | ${null}
+ ${true} | ${null}
+ ${null} | ${null}
+ ${undefined} | ${null}
+ `('returns $expected given $string', ({ string, expected }) => {
+ expect(newDateAsLocaleTime(string)).toEqual(expected);
+ });
+});
diff --git a/spec/frontend/lib/utils/datetime/date_format_utility_spec.js b/spec/frontend/lib/utils/datetime/date_format_utility_spec.js
index 1adc70450e8..018ae12c908 100644
--- a/spec/frontend/lib/utils/datetime/date_format_utility_spec.js
+++ b/spec/frontend/lib/utils/datetime/date_format_utility_spec.js
@@ -133,3 +133,15 @@ describe('formatTimeAsSummary', () => {
expect(utils.formatTimeAsSummary({ [unit]: value })).toBe(result);
});
});
+
+describe('durationTimeFormatted', () => {
+ it.each`
+ duration | expectedOutput
+ ${87} | ${'00:01:27'}
+ ${141} | ${'00:02:21'}
+ ${12} | ${'00:00:12'}
+ ${60} | ${'00:01:00'}
+ `('returns $expectedOutput when provided $duration', ({ duration, expectedOutput }) => {
+ expect(utils.durationTimeFormatted(duration)).toBe(expectedOutput);
+ });
+});
diff --git a/spec/frontend/lib/utils/datetime/timeago_utility_spec.js b/spec/frontend/lib/utils/datetime/timeago_utility_spec.js
index 2314ec678d3..1ef7047d959 100644
--- a/spec/frontend/lib/utils/datetime/timeago_utility_spec.js
+++ b/spec/frontend/lib/utils/datetime/timeago_utility_spec.js
@@ -1,4 +1,4 @@
-import { getTimeago, localTimeAgo, timeFor } from '~/lib/utils/datetime/timeago_utility';
+import { getTimeago, localTimeAgo, timeFor, duration } from '~/lib/utils/datetime/timeago_utility';
import { s__ } from '~/locale';
import '~/commons/bootstrap';
@@ -66,6 +66,54 @@ describe('TimeAgo utils', () => {
});
});
+ describe('duration', () => {
+ const ONE_DAY = 24 * 60 * 60;
+
+ it.each`
+ secs | formatted
+ ${0} | ${'0 seconds'}
+ ${30} | ${'30 seconds'}
+ ${59} | ${'59 seconds'}
+ ${60} | ${'1 minute'}
+ ${-60} | ${'1 minute'}
+ ${2 * 60} | ${'2 minutes'}
+ ${60 * 60} | ${'1 hour'}
+ ${2 * 60 * 60} | ${'2 hours'}
+ ${ONE_DAY} | ${'1 day'}
+ ${2 * ONE_DAY} | ${'2 days'}
+ ${7 * ONE_DAY} | ${'1 week'}
+ ${14 * ONE_DAY} | ${'2 weeks'}
+ ${31 * ONE_DAY} | ${'1 month'}
+ ${61 * ONE_DAY} | ${'2 months'}
+ ${365 * ONE_DAY} | ${'1 year'}
+ ${365 * 2 * ONE_DAY} | ${'2 years'}
+ `('formats $secs as "$formatted"', ({ secs, formatted }) => {
+ const ms = secs * 1000;
+
+ expect(duration(ms)).toBe(formatted);
+ });
+
+ // `duration` can be used to format Rails month durations.
+ // Ensure formatting for quantities such as `2.months.to_i`
+ // based on ActiveSupport::Duration::SECONDS_PER_MONTH.
+ // See: https://api.rubyonrails.org/classes/ActiveSupport/Duration.html
+ const SECONDS_PER_MONTH = 2629746; // 1.month.to_i
+
+ it.each`
+ duration | secs | formatted
+ ${'1.month'} | ${SECONDS_PER_MONTH} | ${'1 month'}
+ ${'2.months'} | ${SECONDS_PER_MONTH * 2} | ${'2 months'}
+ ${'3.months'} | ${SECONDS_PER_MONTH * 3} | ${'3 months'}
+ `(
+ 'formats ActiveSupport::Duration of `$duration` ($secs) as "$formatted"',
+ ({ secs, formatted }) => {
+ const ms = secs * 1000;
+
+ expect(duration(ms)).toBe(formatted);
+ },
+ );
+ });
+
describe('localTimeAgo', () => {
beforeEach(() => {
document.body.innerHTML =
diff --git a/spec/frontend/lib/utils/poll_spec.js b/spec/frontend/lib/utils/poll_spec.js
index 861808e3ad8..1f150599983 100644
--- a/spec/frontend/lib/utils/poll_spec.js
+++ b/spec/frontend/lib/utils/poll_spec.js
@@ -50,58 +50,48 @@ describe('Poll', () => {
};
});
- it('calls the success callback when no header for interval is provided', (done) => {
+ it('calls the success callback when no header for interval is provided', () => {
mockServiceCall({ status: 200 });
setup();
- waitForAllCallsToFinish(1, () => {
+ return waitForAllCallsToFinish(1, () => {
expect(callbacks.success).toHaveBeenCalled();
expect(callbacks.error).not.toHaveBeenCalled();
-
- done();
});
});
- it('calls the error callback when the http request returns an error', (done) => {
+ it('calls the error callback when the http request returns an error', () => {
mockServiceCall({ status: 500 }, true);
setup();
- waitForAllCallsToFinish(1, () => {
+ return waitForAllCallsToFinish(1, () => {
expect(callbacks.success).not.toHaveBeenCalled();
expect(callbacks.error).toHaveBeenCalled();
-
- done();
});
});
- it('skips the error callback when request is aborted', (done) => {
+ it('skips the error callback when request is aborted', () => {
mockServiceCall({ status: 0 }, true);
setup();
- waitForAllCallsToFinish(1, () => {
+ return waitForAllCallsToFinish(1, () => {
expect(callbacks.success).not.toHaveBeenCalled();
expect(callbacks.error).not.toHaveBeenCalled();
expect(callbacks.notification).toHaveBeenCalled();
-
- done();
});
});
- it('should call the success callback when the interval header is -1', (done) => {
+ it('should call the success callback when the interval header is -1', () => {
mockServiceCall({ status: 200, headers: { 'poll-interval': -1 } });
- setup()
- .then(() => {
- expect(callbacks.success).toHaveBeenCalled();
- expect(callbacks.error).not.toHaveBeenCalled();
-
- done();
- })
- .catch(done.fail);
+ return setup().then(() => {
+ expect(callbacks.success).toHaveBeenCalled();
+ expect(callbacks.error).not.toHaveBeenCalled();
+ });
});
describe('for 2xx status code', () => {
successCodes.forEach((httpCode) => {
- it(`starts polling when http status is ${httpCode} and interval header is provided`, (done) => {
+ it(`starts polling when http status is ${httpCode} and interval header is provided`, () => {
mockServiceCall({ status: httpCode, headers: { 'poll-interval': 1 } });
const Polling = new Poll({
@@ -114,22 +104,20 @@ describe('Poll', () => {
Polling.makeRequest();
- waitForAllCallsToFinish(2, () => {
+ return waitForAllCallsToFinish(2, () => {
Polling.stop();
expect(service.fetch.mock.calls).toHaveLength(2);
expect(service.fetch).toHaveBeenCalledWith({ page: 1 });
expect(callbacks.success).toHaveBeenCalled();
expect(callbacks.error).not.toHaveBeenCalled();
-
- done();
});
});
});
});
describe('with delayed initial request', () => {
- it('delays the first request', async (done) => {
+ it('delays the first request', async () => {
mockServiceCall({ status: 200, headers: { 'poll-interval': 1 } });
const Polling = new Poll({
@@ -144,21 +132,19 @@ describe('Poll', () => {
expect(Polling.timeoutID).toBeTruthy();
- waitForAllCallsToFinish(2, () => {
+ return waitForAllCallsToFinish(2, () => {
Polling.stop();
expect(service.fetch.mock.calls).toHaveLength(2);
expect(service.fetch).toHaveBeenCalledWith({ page: 1 });
expect(callbacks.success).toHaveBeenCalled();
expect(callbacks.error).not.toHaveBeenCalled();
-
- done();
});
});
});
describe('stop', () => {
- it('stops polling when method is called', (done) => {
+ it('stops polling when method is called', () => {
mockServiceCall({ status: 200, headers: { 'poll-interval': 1 } });
const Polling = new Poll({
@@ -175,18 +161,16 @@ describe('Poll', () => {
Polling.makeRequest();
- waitForAllCallsToFinish(1, () => {
+ return waitForAllCallsToFinish(1, () => {
expect(service.fetch.mock.calls).toHaveLength(1);
expect(service.fetch).toHaveBeenCalledWith({ page: 1 });
expect(Polling.stop).toHaveBeenCalled();
-
- done();
});
});
});
describe('enable', () => {
- it('should enable polling upon a response', (done) => {
+ it('should enable polling upon a response', () => {
mockServiceCall({ status: 200 });
const Polling = new Poll({
resource: service,
@@ -200,19 +184,18 @@ describe('Poll', () => {
response: { status: 200, headers: { 'poll-interval': 1 } },
});
- waitForAllCallsToFinish(1, () => {
+ return waitForAllCallsToFinish(1, () => {
Polling.stop();
expect(service.fetch.mock.calls).toHaveLength(1);
expect(service.fetch).toHaveBeenCalledWith({ page: 4 });
expect(Polling.options.data).toEqual({ page: 4 });
- done();
});
});
});
describe('restart', () => {
- it('should restart polling when its called', (done) => {
+ it('should restart polling when its called', () => {
mockServiceCall({ status: 200, headers: { 'poll-interval': 1 } });
const Polling = new Poll({
@@ -238,7 +221,7 @@ describe('Poll', () => {
Polling.makeRequest();
- waitForAllCallsToFinish(2, () => {
+ return waitForAllCallsToFinish(2, () => {
Polling.stop();
expect(service.fetch.mock.calls).toHaveLength(2);
@@ -247,7 +230,6 @@ describe('Poll', () => {
expect(Polling.enable).toHaveBeenCalled();
expect(Polling.restart).toHaveBeenCalled();
expect(Polling.options.data).toEqual({ page: 4 });
- done();
});
});
});
diff --git a/spec/frontend/lib/utils/text_markdown_spec.js b/spec/frontend/lib/utils/text_markdown_spec.js
index a5877aa6e3e..103305f0797 100644
--- a/spec/frontend/lib/utils/text_markdown_spec.js
+++ b/spec/frontend/lib/utils/text_markdown_spec.js
@@ -178,12 +178,23 @@ describe('init markdown', () => {
it.each`
text | expected
${'- item'} | ${'- item\n- '}
+ ${'* item'} | ${'* item\n* '}
+ ${'+ item'} | ${'+ item\n+ '}
${'- [ ] item'} | ${'- [ ] item\n- [ ] '}
- ${'- [x] item'} | ${'- [x] item\n- [x] '}
+ ${'- [x] item'} | ${'- [x] item\n- [ ] '}
+ ${'- [X] item'} | ${'- [X] item\n- [ ] '}
+ ${'- [ ] nbsp (U+00A0)'} | ${'- [ ] nbsp (U+00A0)\n- [ ] '}
${'- item\n - second'} | ${'- item\n - second\n - '}
+ ${'- - -'} | ${'- - -'}
+ ${'- --'} | ${'- --'}
+ ${'* **'} | ${'* **'}
+ ${' ** * ** * ** * **'} | ${' ** * ** * ** * **'}
+ ${'- - -x'} | ${'- - -x\n- '}
+ ${'+ ++'} | ${'+ ++\n+ '}
${'1. item'} | ${'1. item\n2. '}
${'1. [ ] item'} | ${'1. [ ] item\n2. [ ] '}
- ${'1. [x] item'} | ${'1. [x] item\n2. [x] '}
+ ${'1. [x] item'} | ${'1. [x] item\n2. [ ] '}
+ ${'1. [X] item'} | ${'1. [X] item\n2. [ ] '}
${'108. item'} | ${'108. item\n109. '}
${'108. item\n - second'} | ${'108. item\n - second\n - '}
${'108. item\n 1. second'} | ${'108. item\n 1. second\n 2. '}
@@ -207,10 +218,12 @@ describe('init markdown', () => {
${'- item\n- '} | ${'- item\n'}
${'- [ ] item\n- [ ] '} | ${'- [ ] item\n'}
${'- [x] item\n- [x] '} | ${'- [x] item\n'}
+ ${'- [X] item\n- [X] '} | ${'- [X] item\n'}
${'- item\n - second\n - '} | ${'- item\n - second\n'}
${'1. item\n2. '} | ${'1. item\n'}
${'1. [ ] item\n2. [ ] '} | ${'1. [ ] item\n'}
${'1. [x] item\n2. [x] '} | ${'1. [x] item\n'}
+ ${'1. [X] item\n2. [X] '} | ${'1. [X] item\n'}
${'108. item\n109. '} | ${'108. item\n'}
${'108. item\n - second\n - '} | ${'108. item\n - second\n'}
${'108. item\n 1. second\n 1. '} | ${'108. item\n 1. second\n'}
diff --git a/spec/frontend/lib/utils/unit_format/formatter_factory_spec.js b/spec/frontend/lib/utils/unit_format/formatter_factory_spec.js
index 0ca70e0a77e..9632d0f98f4 100644
--- a/spec/frontend/lib/utils/unit_format/formatter_factory_spec.js
+++ b/spec/frontend/lib/utils/unit_format/formatter_factory_spec.js
@@ -31,12 +31,17 @@ describe('unit_format/formatter_factory', () => {
expect(formatNumber(12.345, 4)).toBe('12.3450');
});
- it('formats a large integer with a length limit', () => {
+ it('formats a large integer with a max length - using legacy positional argument', () => {
expect(formatNumber(10 ** 7, undefined)).toBe('10,000,000');
expect(formatNumber(10 ** 7, undefined, 9)).toBe('1.00e+7');
expect(formatNumber(10 ** 7, undefined, 10)).toBe('10,000,000');
});
+ it('formats a large integer with a max length', () => {
+ expect(formatNumber(10 ** 7, undefined, { maxLength: 9 })).toBe('1.00e+7');
+ expect(formatNumber(10 ** 7, undefined, { maxLength: 10 })).toBe('10,000,000');
+ });
+
describe('formats with a different locale', () => {
let originalLang;
@@ -92,7 +97,7 @@ describe('unit_format/formatter_factory', () => {
expect(formatSuffix(-1000000)).toBe('-1,000,000pop.');
});
- it('formats a floating point nugative number', () => {
+ it('formats a floating point negative number', () => {
expect(formatSuffix(-0.1)).toBe('-0.1pop.');
expect(formatSuffix(-0.1, 0)).toBe('-0pop.');
expect(formatSuffix(-0.1, 2)).toBe('-0.10pop.');
@@ -108,10 +113,20 @@ describe('unit_format/formatter_factory', () => {
expect(formatSuffix(10 ** 10)).toBe('10,000,000,000pop.');
});
- it('formats a large integer with a length limit', () => {
+ it('formats using a unit separator', () => {
+ expect(formatSuffix(10, 0, { unitSeparator: ' ' })).toBe('10 pop.');
+ expect(formatSuffix(10, 0, { unitSeparator: ' x ' })).toBe('10 x pop.');
+ });
+
+ it('formats a large integer with a max length - using legacy positional argument', () => {
expect(formatSuffix(10 ** 7, undefined, 10)).toBe('1.00e+7pop.');
expect(formatSuffix(10 ** 10, undefined, 10)).toBe('1.00e+10pop.');
});
+
+ it('formats a large integer with a max length', () => {
+ expect(formatSuffix(10 ** 7, undefined, { maxLength: 10 })).toBe('1.00e+7pop.');
+ expect(formatSuffix(10 ** 10, undefined, { maxLength: 10 })).toBe('1.00e+10pop.');
+ });
});
describe('scaledSIFormatter', () => {
@@ -143,6 +158,10 @@ describe('unit_format/formatter_factory', () => {
expect(formatGibibytes(10 ** 10)).toBe('10GB');
expect(formatGibibytes(10 ** 11)).toBe('100GB');
});
+
+ it('formats bytes using a unit separator', () => {
+ expect(formatGibibytes(1, 0, { unitSeparator: ' ' })).toBe('1 B');
+ });
});
describe('scaled format with offset', () => {
@@ -174,6 +193,19 @@ describe('unit_format/formatter_factory', () => {
expect(formatGigaBytes(10 ** 9)).toBe('1EB');
});
+ it('formats bytes using a unit separator', () => {
+ expect(formatGigaBytes(1, undefined, { unitSeparator: ' ' })).toBe('1 GB');
+ });
+
+ it('formats long byte numbers with max length - using legacy positional argument', () => {
+ expect(formatGigaBytes(1, 8, 7)).toBe('1.00e+0GB');
+ });
+
+ it('formats long byte numbers with max length', () => {
+ expect(formatGigaBytes(1, 8)).toBe('1.00000000GB');
+ expect(formatGigaBytes(1, 8, { maxLength: 7 })).toBe('1.00e+0GB');
+ });
+
it('formatting of too large numbers is not suported', () => {
// formatting YB is out of range
expect(() => scaledSIFormatter('B', 9)).toThrow();
@@ -216,6 +248,10 @@ describe('unit_format/formatter_factory', () => {
expect(formatMilligrams(-100)).toBe('-100mg');
expect(formatMilligrams(-(10 ** 4))).toBe('-10g');
});
+
+ it('formats using a unit separator', () => {
+ expect(formatMilligrams(1, undefined, { unitSeparator: ' ' })).toBe('1 mg');
+ });
});
});
@@ -253,6 +289,10 @@ describe('unit_format/formatter_factory', () => {
expect(formatScaledBin(10 * 1024 ** 3)).toBe('10GiB');
expect(formatScaledBin(100 * 1024 ** 3)).toBe('100GiB');
});
+
+ it('formats using a unit separator', () => {
+ expect(formatScaledBin(1, undefined, { unitSeparator: ' ' })).toBe('1 B');
+ });
});
describe('scaled format with offset', () => {
@@ -288,6 +328,10 @@ describe('unit_format/formatter_factory', () => {
expect(formatGibibytes(100 * 1024 ** 3)).toBe('100EiB');
});
+ it('formats using a unit separator', () => {
+ expect(formatGibibytes(1, undefined, { unitSeparator: ' ' })).toBe('1 GiB');
+ });
+
it('formatting of too large numbers is not suported', () => {
// formatting YB is out of range
expect(() => scaledBinaryFormatter('B', 9)).toThrow();
diff --git a/spec/frontend/lib/utils/unit_format/index_spec.js b/spec/frontend/lib/utils/unit_format/index_spec.js
index 7fd273f1b58..dc9d6ece48e 100644
--- a/spec/frontend/lib/utils/unit_format/index_spec.js
+++ b/spec/frontend/lib/utils/unit_format/index_spec.js
@@ -74,10 +74,13 @@ describe('unit_format', () => {
it('seconds', () => {
expect(seconds(1)).toBe('1s');
+ expect(seconds(1, undefined, { unitSeparator: ' ' })).toBe('1 s');
});
it('milliseconds', () => {
expect(milliseconds(1)).toBe('1ms');
+ expect(milliseconds(1, undefined, { unitSeparator: ' ' })).toBe('1 ms');
+
expect(milliseconds(100)).toBe('100ms');
expect(milliseconds(1000)).toBe('1,000ms');
expect(milliseconds(10_000)).toBe('10,000ms');
@@ -87,6 +90,7 @@ describe('unit_format', () => {
it('decimalBytes', () => {
expect(decimalBytes(1)).toBe('1B');
expect(decimalBytes(1, 1)).toBe('1.0B');
+ expect(decimalBytes(1, 1, { unitSeparator: ' ' })).toBe('1.0 B');
expect(decimalBytes(10)).toBe('10B');
expect(decimalBytes(10 ** 2)).toBe('100B');
@@ -104,31 +108,37 @@ describe('unit_format', () => {
it('kilobytes', () => {
expect(kilobytes(1)).toBe('1kB');
expect(kilobytes(1, 1)).toBe('1.0kB');
+ expect(kilobytes(1, 1, { unitSeparator: ' ' })).toBe('1.0 kB');
});
it('megabytes', () => {
expect(megabytes(1)).toBe('1MB');
expect(megabytes(1, 1)).toBe('1.0MB');
+ expect(megabytes(1, 1, { unitSeparator: ' ' })).toBe('1.0 MB');
});
it('gigabytes', () => {
expect(gigabytes(1)).toBe('1GB');
expect(gigabytes(1, 1)).toBe('1.0GB');
+ expect(gigabytes(1, 1, { unitSeparator: ' ' })).toBe('1.0 GB');
});
it('terabytes', () => {
expect(terabytes(1)).toBe('1TB');
expect(terabytes(1, 1)).toBe('1.0TB');
+ expect(terabytes(1, 1, { unitSeparator: ' ' })).toBe('1.0 TB');
});
it('petabytes', () => {
expect(petabytes(1)).toBe('1PB');
expect(petabytes(1, 1)).toBe('1.0PB');
+ expect(petabytes(1, 1, { unitSeparator: ' ' })).toBe('1.0 PB');
});
it('bytes', () => {
expect(bytes(1)).toBe('1B');
expect(bytes(1, 1)).toBe('1.0B');
+ expect(bytes(1, 1, { unitSeparator: ' ' })).toBe('1.0 B');
expect(bytes(10)).toBe('10B');
expect(bytes(100)).toBe('100B');
@@ -142,26 +152,31 @@ describe('unit_format', () => {
it('kibibytes', () => {
expect(kibibytes(1)).toBe('1KiB');
expect(kibibytes(1, 1)).toBe('1.0KiB');
+ expect(kibibytes(1, 1, { unitSeparator: ' ' })).toBe('1.0 KiB');
});
it('mebibytes', () => {
expect(mebibytes(1)).toBe('1MiB');
expect(mebibytes(1, 1)).toBe('1.0MiB');
+ expect(mebibytes(1, 1, { unitSeparator: ' ' })).toBe('1.0 MiB');
});
it('gibibytes', () => {
expect(gibibytes(1)).toBe('1GiB');
expect(gibibytes(1, 1)).toBe('1.0GiB');
+ expect(gibibytes(1, 1, { unitSeparator: ' ' })).toBe('1.0 GiB');
});
it('tebibytes', () => {
expect(tebibytes(1)).toBe('1TiB');
expect(tebibytes(1, 1)).toBe('1.0TiB');
+ expect(tebibytes(1, 1, { unitSeparator: ' ' })).toBe('1.0 TiB');
});
it('pebibytes', () => {
expect(pebibytes(1)).toBe('1PiB');
expect(pebibytes(1, 1)).toBe('1.0PiB');
+ expect(pebibytes(1, 1, { unitSeparator: ' ' })).toBe('1.0 PiB');
});
describe('getFormatter', () => {
diff --git a/spec/frontend/lib/utils/users_cache_spec.js b/spec/frontend/lib/utils/users_cache_spec.js
index 4034f39ee9c..30bdddd8e73 100644
--- a/spec/frontend/lib/utils/users_cache_spec.js
+++ b/spec/frontend/lib/utils/users_cache_spec.js
@@ -93,7 +93,7 @@ describe('UsersCache', () => {
.mockImplementation((query, options) => apiSpy(query, options));
});
- it('stores and returns data from API call if cache is empty', (done) => {
+ it('stores and returns data from API call if cache is empty', async () => {
apiSpy = (query, options) => {
expect(query).toBe('');
expect(options).toEqual({
@@ -105,16 +105,12 @@ describe('UsersCache', () => {
});
};
- UsersCache.retrieve(dummyUsername)
- .then((user) => {
- expect(user).toBe(dummyUser);
- expect(UsersCache.internalStorage[dummyUsername]).toBe(dummyUser);
- })
- .then(done)
- .catch(done.fail);
+ const user = await UsersCache.retrieve(dummyUsername);
+ expect(user).toBe(dummyUser);
+ expect(UsersCache.internalStorage[dummyUsername]).toBe(dummyUser);
});
- it('returns undefined if Ajax call fails and cache is empty', (done) => {
+ it('returns undefined if Ajax call fails and cache is empty', async () => {
const dummyError = new Error('server exploded');
apiSpy = (query, options) => {
@@ -126,26 +122,18 @@ describe('UsersCache', () => {
return Promise.reject(dummyError);
};
- UsersCache.retrieve(dummyUsername)
- .then((user) => done.fail(`Received unexpected user: ${JSON.stringify(user)}`))
- .catch((error) => {
- expect(error).toBe(dummyError);
- })
- .then(done)
- .catch(done.fail);
+ await expect(UsersCache.retrieve(dummyUsername)).rejects.toEqual(dummyError);
});
- it('makes no Ajax call if matching data exists', (done) => {
+ it('makes no Ajax call if matching data exists', async () => {
UsersCache.internalStorage[dummyUsername] = dummyUser;
- apiSpy = () => done.fail(new Error('expected no Ajax call!'));
+ apiSpy = () => {
+ throw new Error('expected no Ajax call!');
+ };
- UsersCache.retrieve(dummyUsername)
- .then((user) => {
- expect(user).toBe(dummyUser);
- })
- .then(done)
- .catch(done.fail);
+ const user = await UsersCache.retrieve(dummyUsername);
+ expect(user).toBe(dummyUser);
});
});
@@ -156,7 +144,7 @@ describe('UsersCache', () => {
jest.spyOn(UserApi, 'getUser').mockImplementation((id) => apiSpy(id));
});
- it('stores and returns data from API call if cache is empty', (done) => {
+ it('stores and returns data from API call if cache is empty', async () => {
apiSpy = (id) => {
expect(id).toBe(dummyUserId);
@@ -165,16 +153,12 @@ describe('UsersCache', () => {
});
};
- UsersCache.retrieveById(dummyUserId)
- .then((user) => {
- expect(user).toBe(dummyUser);
- expect(UsersCache.internalStorage[dummyUserId]).toBe(dummyUser);
- })
- .then(done)
- .catch(done.fail);
+ const user = await UsersCache.retrieveById(dummyUserId);
+ expect(user).toBe(dummyUser);
+ expect(UsersCache.internalStorage[dummyUserId]).toBe(dummyUser);
});
- it('returns undefined if Ajax call fails and cache is empty', (done) => {
+ it('returns undefined if Ajax call fails and cache is empty', async () => {
const dummyError = new Error('server exploded');
apiSpy = (id) => {
@@ -183,26 +167,18 @@ describe('UsersCache', () => {
return Promise.reject(dummyError);
};
- UsersCache.retrieveById(dummyUserId)
- .then((user) => done.fail(`Received unexpected user: ${JSON.stringify(user)}`))
- .catch((error) => {
- expect(error).toBe(dummyError);
- })
- .then(done)
- .catch(done.fail);
+ await expect(UsersCache.retrieveById(dummyUserId)).rejects.toEqual(dummyError);
});
- it('makes no Ajax call if matching data exists', (done) => {
+ it('makes no Ajax call if matching data exists', async () => {
UsersCache.internalStorage[dummyUserId] = dummyUser;
- apiSpy = () => done.fail(new Error('expected no Ajax call!'));
+ apiSpy = () => {
+ throw new Error('expected no Ajax call!');
+ };
- UsersCache.retrieveById(dummyUserId)
- .then((user) => {
- expect(user).toBe(dummyUser);
- })
- .then(done)
- .catch(done.fail);
+ const user = await UsersCache.retrieveById(dummyUserId);
+ expect(user).toBe(dummyUser);
});
});
@@ -213,7 +189,7 @@ describe('UsersCache', () => {
jest.spyOn(UserApi, 'getUserStatus').mockImplementation((id) => apiSpy(id));
});
- it('stores and returns data from API call if cache is empty', (done) => {
+ it('stores and returns data from API call if cache is empty', async () => {
apiSpy = (id) => {
expect(id).toBe(dummyUserId);
@@ -222,16 +198,12 @@ describe('UsersCache', () => {
});
};
- UsersCache.retrieveStatusById(dummyUserId)
- .then((userStatus) => {
- expect(userStatus).toBe(dummyUserStatus);
- expect(UsersCache.internalStorage[dummyUserId].status).toBe(dummyUserStatus);
- })
- .then(done)
- .catch(done.fail);
+ const userStatus = await UsersCache.retrieveStatusById(dummyUserId);
+ expect(userStatus).toBe(dummyUserStatus);
+ expect(UsersCache.internalStorage[dummyUserId].status).toBe(dummyUserStatus);
});
- it('returns undefined if Ajax call fails and cache is empty', (done) => {
+ it('returns undefined if Ajax call fails and cache is empty', async () => {
const dummyError = new Error('server exploded');
apiSpy = (id) => {
@@ -240,28 +212,20 @@ describe('UsersCache', () => {
return Promise.reject(dummyError);
};
- UsersCache.retrieveStatusById(dummyUserId)
- .then((userStatus) => done.fail(`Received unexpected user: ${JSON.stringify(userStatus)}`))
- .catch((error) => {
- expect(error).toBe(dummyError);
- })
- .then(done)
- .catch(done.fail);
+ await expect(UsersCache.retrieveStatusById(dummyUserId)).rejects.toEqual(dummyError);
});
- it('makes no Ajax call if matching data exists', (done) => {
+ it('makes no Ajax call if matching data exists', async () => {
UsersCache.internalStorage[dummyUserId] = {
status: dummyUserStatus,
};
- apiSpy = () => done.fail(new Error('expected no Ajax call!'));
+ apiSpy = () => {
+ throw new Error('expected no Ajax call!');
+ };
- UsersCache.retrieveStatusById(dummyUserId)
- .then((userStatus) => {
- expect(userStatus).toBe(dummyUserStatus);
- })
- .then(done)
- .catch(done.fail);
+ const userStatus = await UsersCache.retrieveStatusById(dummyUserId);
+ expect(userStatus).toBe(dummyUserStatus);
});
});
});
diff --git a/spec/frontend/members/components/table/members_table_spec.js b/spec/frontend/members/components/table/members_table_spec.js
index b2756e506eb..298a01e4f4d 100644
--- a/spec/frontend/members/components/table/members_table_spec.js
+++ b/spec/frontend/members/components/table/members_table_spec.js
@@ -10,6 +10,7 @@ import MemberAvatar from '~/members/components/table/member_avatar.vue';
import MemberSource from '~/members/components/table/member_source.vue';
import MembersTable from '~/members/components/table/members_table.vue';
import RoleDropdown from '~/members/components/table/role_dropdown.vue';
+import UserDate from '~/vue_shared/components/user_date.vue';
import {
MEMBER_TYPES,
MEMBER_STATE_CREATED,
@@ -106,14 +107,16 @@ describe('MembersTable', () => {
};
it.each`
- field | label | member | expectedComponent
- ${'account'} | ${'Account'} | ${memberMock} | ${MemberAvatar}
- ${'source'} | ${'Source'} | ${memberMock} | ${MemberSource}
- ${'granted'} | ${'Access granted'} | ${memberMock} | ${CreatedAt}
- ${'invited'} | ${'Invited'} | ${invite} | ${CreatedAt}
- ${'requested'} | ${'Requested'} | ${accessRequest} | ${CreatedAt}
- ${'maxRole'} | ${'Max role'} | ${memberCanUpdate} | ${RoleDropdown}
- ${'expiration'} | ${'Expiration'} | ${memberMock} | ${ExpirationDatepicker}
+ field | label | member | expectedComponent
+ ${'account'} | ${'Account'} | ${memberMock} | ${MemberAvatar}
+ ${'source'} | ${'Source'} | ${memberMock} | ${MemberSource}
+ ${'granted'} | ${'Access granted'} | ${memberMock} | ${CreatedAt}
+ ${'invited'} | ${'Invited'} | ${invite} | ${CreatedAt}
+ ${'requested'} | ${'Requested'} | ${accessRequest} | ${CreatedAt}
+ ${'maxRole'} | ${'Max role'} | ${memberCanUpdate} | ${RoleDropdown}
+ ${'expiration'} | ${'Expiration'} | ${memberMock} | ${ExpirationDatepicker}
+ ${'userCreatedAt'} | ${'Created on'} | ${memberMock} | ${UserDate}
+ ${'lastActivityOn'} | ${'Last activity'} | ${memberMock} | ${UserDate}
`('renders the $label field', ({ field, label, member, expectedComponent }) => {
createComponent({
members: [member],
diff --git a/spec/frontend/members/mock_data.js b/spec/frontend/members/mock_data.js
index 83856a00a15..06ccd107ce3 100644
--- a/spec/frontend/members/mock_data.js
+++ b/spec/frontend/members/mock_data.js
@@ -17,6 +17,7 @@ export const member = {
state: MEMBER_STATE_CREATED,
user: {
id: 123,
+ createdAt: '2022-03-10T18:03:04.812Z',
name: 'Administrator',
username: 'root',
webUrl: 'https://gitlab.com/root',
@@ -26,6 +27,7 @@ export const member = {
oncallSchedules: [{ name: 'schedule 1' }],
escalationPolicies: [{ name: 'policy 1' }],
availability: null,
+ lastActivityOn: '2022-03-15',
showStatus: true,
},
id: 238,
@@ -56,6 +58,7 @@ export const group = {
webUrl: 'https://gitlab.com/groups/parent-group/commit451',
},
id: 3,
+ isDirectMember: true,
createdAt: '2020-08-06T15:31:07.662Z',
expiresAt: null,
validRoles: { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 },
diff --git a/spec/frontend/merge_conflicts/store/actions_spec.js b/spec/frontend/merge_conflicts/store/actions_spec.js
index 3e1774a6d56..1b6a0f9e977 100644
--- a/spec/frontend/merge_conflicts/store/actions_spec.js
+++ b/spec/frontend/merge_conflicts/store/actions_spec.js
@@ -34,9 +34,9 @@ describe('merge conflicts actions', () => {
describe('fetchConflictsData', () => {
const conflictsPath = 'conflicts/path/mock';
- it('on success dispatches setConflictsData', (done) => {
+ it('on success dispatches setConflictsData', () => {
mock.onGet(conflictsPath).reply(200, {});
- testAction(
+ return testAction(
actions.fetchConflictsData,
conflictsPath,
{},
@@ -45,13 +45,12 @@ describe('merge conflicts actions', () => {
{ type: types.SET_LOADING_STATE, payload: false },
],
[{ type: 'setConflictsData', payload: {} }],
- done,
);
});
- it('when data has type equal to error ', (done) => {
+ it('when data has type equal to error ', () => {
mock.onGet(conflictsPath).reply(200, { type: 'error', message: 'error message' });
- testAction(
+ return testAction(
actions.fetchConflictsData,
conflictsPath,
{},
@@ -61,13 +60,12 @@ describe('merge conflicts actions', () => {
{ type: types.SET_LOADING_STATE, payload: false },
],
[],
- done,
);
});
- it('when request fails ', (done) => {
+ it('when request fails ', () => {
mock.onGet(conflictsPath).reply(400);
- testAction(
+ return testAction(
actions.fetchConflictsData,
conflictsPath,
{},
@@ -77,15 +75,14 @@ describe('merge conflicts actions', () => {
{ type: types.SET_LOADING_STATE, payload: false },
],
[],
- done,
);
});
});
describe('setConflictsData', () => {
- it('INTERACTIVE_RESOLVE_MODE updates the correct file ', (done) => {
+ it('INTERACTIVE_RESOLVE_MODE updates the correct file ', () => {
decorateFiles.mockReturnValue([{ bar: 'baz' }]);
- testAction(
+ return testAction(
actions.setConflictsData,
{ files, foo: 'bar' },
{},
@@ -96,7 +93,6 @@ describe('merge conflicts actions', () => {
},
],
[],
- done,
);
});
});
@@ -105,24 +101,21 @@ describe('merge conflicts actions', () => {
useMockLocationHelper();
const resolveConflictsPath = 'resolve/conflicts/path/mock';
- it('on success reloads the page', (done) => {
+ it('on success reloads the page', async () => {
mock.onPost(resolveConflictsPath).reply(200, { redirect_to: 'hrefPath' });
- testAction(
+ await testAction(
actions.submitResolvedConflicts,
resolveConflictsPath,
{},
[{ type: types.SET_SUBMIT_STATE, payload: true }],
[],
- () => {
- expect(window.location.assign).toHaveBeenCalledWith('hrefPath');
- done();
- },
);
+ expect(window.location.assign).toHaveBeenCalledWith('hrefPath');
});
- it('on errors shows flash', (done) => {
+ it('on errors shows flash', async () => {
mock.onPost(resolveConflictsPath).reply(400);
- testAction(
+ await testAction(
actions.submitResolvedConflicts,
resolveConflictsPath,
{},
@@ -131,13 +124,10 @@ describe('merge conflicts actions', () => {
{ type: types.SET_SUBMIT_STATE, payload: false },
],
[],
- () => {
- expect(createFlash).toHaveBeenCalledWith({
- message: 'Failed to save merge conflicts resolutions. Please try again!',
- });
- done();
- },
);
+ expect(createFlash).toHaveBeenCalledWith({
+ message: 'Failed to save merge conflicts resolutions. Please try again!',
+ });
});
});
@@ -193,9 +183,9 @@ describe('merge conflicts actions', () => {
});
describe('setViewType', () => {
- it('commits the right mutation', (done) => {
+ it('commits the right mutation', async () => {
const payload = 'viewType';
- testAction(
+ await testAction(
actions.setViewType,
payload,
{},
@@ -206,14 +196,11 @@ describe('merge conflicts actions', () => {
},
],
[],
- () => {
- expect(Cookies.set).toHaveBeenCalledWith('diff_view', payload, {
- expires: 365,
- secure: false,
- });
- done();
- },
);
+ expect(Cookies.set).toHaveBeenCalledWith('diff_view', payload, {
+ expires: 365,
+ secure: false,
+ });
});
});
@@ -252,8 +239,8 @@ describe('merge conflicts actions', () => {
});
describe('setFileResolveMode', () => {
- it('INTERACTIVE_RESOLVE_MODE updates the correct file ', (done) => {
- testAction(
+ it('INTERACTIVE_RESOLVE_MODE updates the correct file ', () => {
+ return testAction(
actions.setFileResolveMode,
{ file: files[0], mode: INTERACTIVE_RESOLVE_MODE },
{ conflictsData: { files }, getFileIndex: () => 0 },
@@ -267,11 +254,10 @@ describe('merge conflicts actions', () => {
},
],
[],
- done,
);
});
- it('EDIT_RESOLVE_MODE updates the correct file ', (done) => {
+ it('EDIT_RESOLVE_MODE updates the correct file ', async () => {
restoreFileLinesState.mockReturnValue([]);
const file = {
...files[0],
@@ -280,7 +266,7 @@ describe('merge conflicts actions', () => {
resolutionData: {},
resolveMode: EDIT_RESOLVE_MODE,
};
- testAction(
+ await testAction(
actions.setFileResolveMode,
{ file: files[0], mode: EDIT_RESOLVE_MODE },
{ conflictsData: { files }, getFileIndex: () => 0 },
@@ -294,17 +280,14 @@ describe('merge conflicts actions', () => {
},
],
[],
- () => {
- expect(restoreFileLinesState).toHaveBeenCalledWith(file);
- done();
- },
);
+ expect(restoreFileLinesState).toHaveBeenCalledWith(file);
});
});
describe('setPromptConfirmationState', () => {
- it('updates the correct file ', (done) => {
- testAction(
+ it('updates the correct file ', () => {
+ return testAction(
actions.setPromptConfirmationState,
{ file: files[0], promptDiscardConfirmation: true },
{ conflictsData: { files }, getFileIndex: () => 0 },
@@ -318,7 +301,6 @@ describe('merge conflicts actions', () => {
},
],
[],
- done,
);
});
});
@@ -333,11 +315,11 @@ describe('merge conflicts actions', () => {
],
};
- it('updates the correct file ', (done) => {
+ it('updates the correct file ', async () => {
const marLikeMockReturn = { foo: 'bar' };
markLine.mockReturnValue(marLikeMockReturn);
- testAction(
+ await testAction(
actions.handleSelected,
{ file, line: { id: 1, section: 'baz' } },
{ conflictsData: { files }, getFileIndex: () => 0 },
@@ -359,11 +341,8 @@ describe('merge conflicts actions', () => {
},
],
[],
- () => {
- expect(markLine).toHaveBeenCalledTimes(3);
- done();
- },
);
+ expect(markLine).toHaveBeenCalledTimes(3);
});
});
});
diff --git a/spec/frontend/milestones/components/delete_milestone_modal_spec.js b/spec/frontend/milestones/components/delete_milestone_modal_spec.js
index 8978de0e0e0..b9ba0833c4f 100644
--- a/spec/frontend/milestones/components/delete_milestone_modal_spec.js
+++ b/spec/frontend/milestones/components/delete_milestone_modal_spec.js
@@ -32,7 +32,7 @@ describe('delete_milestone_modal.vue', () => {
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
});
- it('deletes milestone and redirects to overview page', (done) => {
+ it('deletes milestone and redirects to overview page', async () => {
const responseURL = `${TEST_HOST}/delete_milestone_modal.vue/milestoneOverview`;
jest.spyOn(axios, 'delete').mockImplementation((url) => {
expect(url).toBe(props.milestoneUrl);
@@ -48,19 +48,15 @@ describe('delete_milestone_modal.vue', () => {
});
});
- vm.onSubmit()
- .then(() => {
- expect(redirectTo).toHaveBeenCalledWith(responseURL);
- expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestFinished', {
- milestoneUrl: props.milestoneUrl,
- successful: true,
- });
- })
- .then(done)
- .catch(done.fail);
+ await vm.onSubmit();
+ expect(redirectTo).toHaveBeenCalledWith(responseURL);
+ expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestFinished', {
+ milestoneUrl: props.milestoneUrl,
+ successful: true,
+ });
});
- it('displays error if deleting milestone failed', (done) => {
+ it('displays error if deleting milestone failed', async () => {
const dummyError = new Error('deleting milestone failed');
dummyError.response = { status: 418 };
jest.spyOn(axios, 'delete').mockImplementation((url) => {
@@ -73,17 +69,12 @@ describe('delete_milestone_modal.vue', () => {
return Promise.reject(dummyError);
});
- vm.onSubmit()
- .catch((error) => {
- expect(error).toBe(dummyError);
- expect(redirectTo).not.toHaveBeenCalled();
- expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestFinished', {
- milestoneUrl: props.milestoneUrl,
- successful: false,
- });
- })
- .then(done)
- .catch(done.fail);
+ await expect(vm.onSubmit()).rejects.toEqual(dummyError);
+ expect(redirectTo).not.toHaveBeenCalled();
+ expect(eventHub.$emit).toHaveBeenCalledWith('deleteMilestoneModal.requestFinished', {
+ milestoneUrl: props.milestoneUrl,
+ successful: false,
+ });
});
});
diff --git a/spec/frontend/milestones/components/milestone_combobox_spec.js b/spec/frontend/milestones/components/milestone_combobox_spec.js
index 1af39aff30c..afd85fb78ce 100644
--- a/spec/frontend/milestones/components/milestone_combobox_spec.js
+++ b/spec/frontend/milestones/components/milestone_combobox_spec.js
@@ -340,7 +340,9 @@ describe('Milestone combobox component', () => {
await nextTick();
expect(
- findFirstProjectMilestonesDropdownItem().find('span').classes('selected-item'),
+ findFirstProjectMilestonesDropdownItem()
+ .find('svg')
+ .classes('gl-new-dropdown-item-check-icon'),
).toBe(true);
selectFirstProjectMilestone();
@@ -348,8 +350,8 @@ describe('Milestone combobox component', () => {
await nextTick();
expect(
- findFirstProjectMilestonesDropdownItem().find('span').classes('selected-item'),
- ).toBe(false);
+ findFirstProjectMilestonesDropdownItem().find('svg').classes('gl-visibility-hidden'),
+ ).toBe(true);
});
describe('when a project milestones is selected', () => {
@@ -464,17 +466,19 @@ describe('Milestone combobox component', () => {
await nextTick();
- expect(findFirstGroupMilestonesDropdownItem().find('span').classes('selected-item')).toBe(
- true,
- );
+ expect(
+ findFirstGroupMilestonesDropdownItem()
+ .find('svg')
+ .classes('gl-new-dropdown-item-check-icon'),
+ ).toBe(true);
selectFirstGroupMilestone();
await nextTick();
- expect(findFirstGroupMilestonesDropdownItem().find('span').classes('selected-item')).toBe(
- false,
- );
+ expect(
+ findFirstGroupMilestonesDropdownItem().find('svg').classes('gl-visibility-hidden'),
+ ).toBe(true);
});
describe('when a group milestones is selected', () => {
diff --git a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap
index bd2e818df4f..28039321428 100644
--- a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap
+++ b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap
@@ -5,7 +5,7 @@ exports[`Dashboard template matches the default snapshot 1`] = `
class="prometheus-graphs"
data-qa-selector="prometheus_graphs"
environmentstate="available"
- metricsdashboardbasepath="/monitoring/monitor-project/-/environments/1/metrics"
+ metricsdashboardbasepath="/monitoring/monitor-project/-/metrics?environment=1"
metricsendpoint="/monitoring/monitor-project/-/environments/1/additional_metrics.json"
>
<div>
@@ -17,11 +17,11 @@ exports[`Dashboard template matches the default snapshot 1`] = `
primarybuttontext=""
secondarybuttonlink=""
secondarybuttontext=""
- title="Feature deprecation and removal"
- variant="danger"
+ title="Feature deprecation"
+ variant="warning"
>
<gl-sprintf-stub
- message="The metrics, logs and tracing features were deprecated in GitLab 14.7 and are %{epicStart} scheduled for removal %{epicEnd} in GitLab 15.0."
+ message="The metrics feature was deprecated in GitLab 14.7."
/>
<gl-sprintf-stub
diff --git a/spec/frontend/monitoring/components/__snapshots__/empty_state_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/empty_state_spec.js.snap
index 4f8a82692b8..08487a7a796 100644
--- a/spec/frontend/monitoring/components/__snapshots__/empty_state_spec.js.snap
+++ b/spec/frontend/monitoring/components/__snapshots__/empty_state_spec.js.snap
@@ -6,6 +6,7 @@ exports[`EmptyState shows gettingStarted state 1`] = `
<gl-empty-state-stub
description="Stay updated about the performance and health of your environment by configuring Prometheus to monitor your deployments."
+ invertindarkmode="true"
primarybuttonlink="/clustersPath"
primarybuttontext="Install on clusters"
secondarybuttonlink="/settingsPath"
@@ -22,6 +23,7 @@ exports[`EmptyState shows noData state 1`] = `
<gl-empty-state-stub
description="You are connected to the Prometheus server, but there is currently no data to display."
+ invertindarkmode="true"
primarybuttonlink="/settingsPath"
primarybuttontext="Configure Prometheus"
secondarybuttonlink=""
@@ -38,6 +40,7 @@ exports[`EmptyState shows unableToConnect state 1`] = `
<gl-empty-state-stub
description="Ensure connectivity is available from the GitLab server to the Prometheus server"
+ invertindarkmode="true"
primarybuttonlink="/documentationPath"
primarybuttontext="View documentation"
secondarybuttonlink="/settingsPath"
diff --git a/spec/frontend/monitoring/components/__snapshots__/group_empty_state_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/group_empty_state_spec.js.snap
index 9b2aa3a5b5b..1d7ff420a17 100644
--- a/spec/frontend/monitoring/components/__snapshots__/group_empty_state_spec.js.snap
+++ b/spec/frontend/monitoring/components/__snapshots__/group_empty_state_spec.js.snap
@@ -4,6 +4,7 @@ exports[`GroupEmptyState given state BAD_QUERY passes the expected props to GlEm
Object {
"compact": true,
"description": null,
+ "invertInDarkMode": true,
"primaryButtonLink": "/path/to/settings",
"primaryButtonText": "Verify configuration",
"secondaryButtonLink": null,
@@ -31,6 +32,7 @@ exports[`GroupEmptyState given state CONNECTION_FAILED passes the expected props
Object {
"compact": true,
"description": "We couldn't reach the Prometheus server. Either the server no longer exists or the configuration details need updating.",
+ "invertInDarkMode": true,
"primaryButtonLink": "/path/to/settings",
"primaryButtonText": "Verify configuration",
"secondaryButtonLink": null,
@@ -47,6 +49,7 @@ exports[`GroupEmptyState given state FOO STATE passes the expected props to GlEm
Object {
"compact": true,
"description": "An error occurred while loading the data. Please try again.",
+ "invertInDarkMode": true,
"primaryButtonLink": null,
"primaryButtonText": null,
"secondaryButtonLink": null,
@@ -63,6 +66,7 @@ exports[`GroupEmptyState given state LOADING passes the expected props to GlEmpt
Object {
"compact": true,
"description": "Creating graphs uses the data from the Prometheus server. If this takes a long time, ensure that data is available.",
+ "invertInDarkMode": true,
"primaryButtonLink": null,
"primaryButtonText": null,
"secondaryButtonLink": null,
@@ -79,6 +83,7 @@ exports[`GroupEmptyState given state NO_DATA passes the expected props to GlEmpt
Object {
"compact": true,
"description": null,
+ "invertInDarkMode": true,
"primaryButtonLink": null,
"primaryButtonText": null,
"secondaryButtonLink": null,
@@ -106,6 +111,7 @@ exports[`GroupEmptyState given state TIMEOUT passes the expected props to GlEmpt
Object {
"compact": true,
"description": null,
+ "invertInDarkMode": true,
"primaryButtonLink": null,
"primaryButtonText": null,
"secondaryButtonLink": null,
@@ -133,6 +139,7 @@ exports[`GroupEmptyState given state UNKNOWN_ERROR passes the expected props to
Object {
"compact": true,
"description": "An error occurred while loading the data. Please try again.",
+ "invertInDarkMode": true,
"primaryButtonLink": null,
"primaryButtonText": null,
"secondaryButtonLink": null,
diff --git a/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js b/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js
index d0d0c3071d5..d74f959ac0f 100644
--- a/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js
+++ b/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js
@@ -109,7 +109,7 @@ describe('Actions menu', () => {
describe('adding new metric from modal', () => {
let origPage;
- beforeEach((done) => {
+ beforeEach(() => {
jest.spyOn(Tracking, 'event').mockReturnValue();
createShallowWrapper();
@@ -118,7 +118,7 @@ describe('Actions menu', () => {
origPage = document.body.dataset.page;
document.body.dataset.page = 'projects:environments:metrics';
- nextTick(done);
+ return nextTick();
});
afterEach(() => {
diff --git a/spec/frontend/monitoring/components/dashboard_url_time_spec.js b/spec/frontend/monitoring/components/dashboard_url_time_spec.js
index 246dd598d19..64c48100b31 100644
--- a/spec/frontend/monitoring/components/dashboard_url_time_spec.js
+++ b/spec/frontend/monitoring/components/dashboard_url_time_spec.js
@@ -126,7 +126,7 @@ describe('dashboard invalid url parameters', () => {
});
it('redirects to different time range', async () => {
- const toUrl = `${mockProjectDir}/-/environments/1/metrics`;
+ const toUrl = `${mockProjectDir}/-/metrics?environment=1`;
removeParams.mockReturnValueOnce(toUrl);
createMountedWrapper();
diff --git a/spec/frontend/monitoring/store/actions_spec.js b/spec/frontend/monitoring/store/actions_spec.js
index f60c531e3f6..d1a13fbf9cd 100644
--- a/spec/frontend/monitoring/store/actions_spec.js
+++ b/spec/frontend/monitoring/store/actions_spec.js
@@ -7,9 +7,9 @@ import * as commonUtils from '~/lib/utils/common_utils';
import statusCodes from '~/lib/utils/http_status';
import { ENVIRONMENT_AVAILABLE_STATE } from '~/monitoring/constants';
-import getAnnotations from '~/monitoring/queries/getAnnotations.query.graphql';
-import getDashboardValidationWarnings from '~/monitoring/queries/getDashboardValidationWarnings.query.graphql';
-import getEnvironments from '~/monitoring/queries/getEnvironments.query.graphql';
+import getAnnotations from '~/monitoring/queries/get_annotations.query.graphql';
+import getDashboardValidationWarnings from '~/monitoring/queries/get_dashboard_validation_warnings.query.graphql';
+import getEnvironments from '~/monitoring/queries/get_environments.query.graphql';
import { createStore } from '~/monitoring/stores';
import {
setGettingStartedEmptyState,
@@ -88,8 +88,8 @@ describe('Monitoring store actions', () => {
// Setup
describe('setGettingStartedEmptyState', () => {
- it('should commit SET_GETTING_STARTED_EMPTY_STATE mutation', (done) => {
- testAction(
+ it('should commit SET_GETTING_STARTED_EMPTY_STATE mutation', () => {
+ return testAction(
setGettingStartedEmptyState,
null,
state,
@@ -99,14 +99,13 @@ describe('Monitoring store actions', () => {
},
],
[],
- done,
);
});
});
describe('setInitialState', () => {
- it('should commit SET_INITIAL_STATE mutation', (done) => {
- testAction(
+ it('should commit SET_INITIAL_STATE mutation', () => {
+ return testAction(
setInitialState,
{
currentDashboard: '.gitlab/dashboards/dashboard.yml',
@@ -123,7 +122,6 @@ describe('Monitoring store actions', () => {
},
],
[],
- done,
);
});
});
@@ -233,51 +231,39 @@ describe('Monitoring store actions', () => {
};
});
- it('dispatches a failure', (done) => {
- result()
- .then(() => {
- expect(commit).toHaveBeenCalledWith(
- types.SET_ALL_DASHBOARDS,
- mockDashboardsErrorResponse.all_dashboards,
- );
- expect(dispatch).toHaveBeenCalledWith(
- 'receiveMetricsDashboardFailure',
- new Error('Request failed with status code 500'),
- );
- expect(createFlash).toHaveBeenCalled();
- done();
- })
- .catch(done.fail);
+ it('dispatches a failure', async () => {
+ await result();
+ expect(commit).toHaveBeenCalledWith(
+ types.SET_ALL_DASHBOARDS,
+ mockDashboardsErrorResponse.all_dashboards,
+ );
+ expect(dispatch).toHaveBeenCalledWith(
+ 'receiveMetricsDashboardFailure',
+ new Error('Request failed with status code 500'),
+ );
+ expect(createFlash).toHaveBeenCalled();
});
- it('dispatches a failure action when a message is returned', (done) => {
- result()
- .then(() => {
- expect(dispatch).toHaveBeenCalledWith(
- 'receiveMetricsDashboardFailure',
- new Error('Request failed with status code 500'),
- );
- expect(createFlash).toHaveBeenCalledWith({
- message: expect.stringContaining(mockDashboardsErrorResponse.message),
- });
- done();
- })
- .catch(done.fail);
+ it('dispatches a failure action when a message is returned', async () => {
+ await result();
+ expect(dispatch).toHaveBeenCalledWith(
+ 'receiveMetricsDashboardFailure',
+ new Error('Request failed with status code 500'),
+ );
+ expect(createFlash).toHaveBeenCalledWith({
+ message: expect.stringContaining(mockDashboardsErrorResponse.message),
+ });
});
- it('does not show a flash error when showErrorBanner is disabled', (done) => {
+ it('does not show a flash error when showErrorBanner is disabled', async () => {
state.showErrorBanner = false;
- result()
- .then(() => {
- expect(dispatch).toHaveBeenCalledWith(
- 'receiveMetricsDashboardFailure',
- new Error('Request failed with status code 500'),
- );
- expect(createFlash).not.toHaveBeenCalled();
- done();
- })
- .catch(done.fail);
+ await result();
+ expect(dispatch).toHaveBeenCalledWith(
+ 'receiveMetricsDashboardFailure',
+ new Error('Request failed with status code 500'),
+ );
+ expect(createFlash).not.toHaveBeenCalled();
});
});
});
@@ -322,38 +308,30 @@ describe('Monitoring store actions', () => {
state.timeRange = defaultTimeRange;
});
- it('commits empty state when state.groups is empty', (done) => {
+ it('commits empty state when state.groups is empty', async () => {
const localGetters = {
metricsWithData: () => [],
};
- fetchDashboardData({ state, commit, dispatch, getters: localGetters })
- .then(() => {
- expect(Tracking.event).toHaveBeenCalledWith(
- document.body.dataset.page,
- 'dashboard_fetch',
- {
- label: 'custom_metrics_dashboard',
- property: 'count',
- value: 0,
- },
- );
- expect(dispatch).toHaveBeenCalledTimes(2);
- expect(dispatch).toHaveBeenCalledWith('fetchDeploymentsData');
- expect(dispatch).toHaveBeenCalledWith('fetchVariableMetricLabelValues', {
- defaultQueryParams: {
- start_time: expect.any(String),
- end_time: expect.any(String),
- step: expect.any(Number),
- },
- });
+ await fetchDashboardData({ state, commit, dispatch, getters: localGetters });
+ expect(Tracking.event).toHaveBeenCalledWith(document.body.dataset.page, 'dashboard_fetch', {
+ label: 'custom_metrics_dashboard',
+ property: 'count',
+ value: 0,
+ });
+ expect(dispatch).toHaveBeenCalledTimes(2);
+ expect(dispatch).toHaveBeenCalledWith('fetchDeploymentsData');
+ expect(dispatch).toHaveBeenCalledWith('fetchVariableMetricLabelValues', {
+ defaultQueryParams: {
+ start_time: expect.any(String),
+ end_time: expect.any(String),
+ step: expect.any(Number),
+ },
+ });
- expect(createFlash).not.toHaveBeenCalled();
- done();
- })
- .catch(done.fail);
+ expect(createFlash).not.toHaveBeenCalled();
});
- it('dispatches fetchPrometheusMetric for each panel query', (done) => {
+ it('dispatches fetchPrometheusMetric for each panel query', async () => {
state.dashboard.panelGroups = convertObjectPropsToCamelCase(
metricsDashboardResponse.dashboard.panel_groups,
);
@@ -363,34 +341,24 @@ describe('Monitoring store actions', () => {
metricsWithData: () => [metric.id],
};
- fetchDashboardData({ state, commit, dispatch, getters: localGetters })
- .then(() => {
- expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetric', {
- metric,
- defaultQueryParams: {
- start_time: expect.any(String),
- end_time: expect.any(String),
- step: expect.any(Number),
- },
- });
-
- expect(Tracking.event).toHaveBeenCalledWith(
- document.body.dataset.page,
- 'dashboard_fetch',
- {
- label: 'custom_metrics_dashboard',
- property: 'count',
- value: 1,
- },
- );
+ await fetchDashboardData({ state, commit, dispatch, getters: localGetters });
+ expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetric', {
+ metric,
+ defaultQueryParams: {
+ start_time: expect.any(String),
+ end_time: expect.any(String),
+ step: expect.any(Number),
+ },
+ });
- done();
- })
- .catch(done.fail);
- done();
+ expect(Tracking.event).toHaveBeenCalledWith(document.body.dataset.page, 'dashboard_fetch', {
+ label: 'custom_metrics_dashboard',
+ property: 'count',
+ value: 1,
+ });
});
- it('dispatches fetchPrometheusMetric for each panel query, handles an error', (done) => {
+ it('dispatches fetchPrometheusMetric for each panel query, handles an error', async () => {
state.dashboard.panelGroups = metricsDashboardViewModel.panelGroups;
const metric = state.dashboard.panelGroups[0].panels[0].metrics[0];
@@ -400,30 +368,24 @@ describe('Monitoring store actions', () => {
dispatch.mockRejectedValueOnce(new Error('Error fetching this metric'));
dispatch.mockResolvedValue();
- fetchDashboardData({ state, commit, dispatch })
- .then(() => {
- const defaultQueryParams = {
- start_time: expect.any(String),
- end_time: expect.any(String),
- step: expect.any(Number),
- };
-
- expect(dispatch).toHaveBeenCalledTimes(metricsDashboardPanelCount + 2); // plus 1 for deployments
- expect(dispatch).toHaveBeenCalledWith('fetchDeploymentsData');
- expect(dispatch).toHaveBeenCalledWith('fetchVariableMetricLabelValues', {
- defaultQueryParams,
- });
- expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetric', {
- metric,
- defaultQueryParams,
- });
+ await fetchDashboardData({ state, commit, dispatch });
+ const defaultQueryParams = {
+ start_time: expect.any(String),
+ end_time: expect.any(String),
+ step: expect.any(Number),
+ };
- expect(createFlash).toHaveBeenCalledTimes(1);
+ expect(dispatch).toHaveBeenCalledTimes(metricsDashboardPanelCount + 2); // plus 1 for deployments
+ expect(dispatch).toHaveBeenCalledWith('fetchDeploymentsData');
+ expect(dispatch).toHaveBeenCalledWith('fetchVariableMetricLabelValues', {
+ defaultQueryParams,
+ });
+ expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetric', {
+ metric,
+ defaultQueryParams,
+ });
- done();
- })
- .catch(done.fail);
- done();
+ expect(createFlash).toHaveBeenCalledTimes(1);
});
});
@@ -449,10 +411,10 @@ describe('Monitoring store actions', () => {
};
});
- it('commits result', (done) => {
+ it('commits result', () => {
mock.onGet(prometheusEndpointPath).reply(200, { data }); // One attempt
- testAction(
+ return testAction(
fetchPrometheusMetric,
{ metric, defaultQueryParams },
state,
@@ -472,10 +434,7 @@ describe('Monitoring store actions', () => {
},
],
[],
- () => {
- done();
- },
- ).catch(done.fail);
+ );
});
describe('without metric defined step', () => {
@@ -485,10 +444,10 @@ describe('Monitoring store actions', () => {
step: 60,
};
- it('uses calculated step', (done) => {
+ it('uses calculated step', async () => {
mock.onGet(prometheusEndpointPath).reply(200, { data }); // One attempt
- testAction(
+ await testAction(
fetchPrometheusMetric,
{ metric, defaultQueryParams },
state,
@@ -508,11 +467,8 @@ describe('Monitoring store actions', () => {
},
],
[],
- () => {
- expect(mock.history.get[0].params).toEqual(expectedParams);
- done();
- },
- ).catch(done.fail);
+ );
+ expect(mock.history.get[0].params).toEqual(expectedParams);
});
});
@@ -527,10 +483,10 @@ describe('Monitoring store actions', () => {
step: 7,
};
- it('uses metric step', (done) => {
+ it('uses metric step', async () => {
mock.onGet(prometheusEndpointPath).reply(200, { data }); // One attempt
- testAction(
+ await testAction(
fetchPrometheusMetric,
{ metric, defaultQueryParams },
state,
@@ -550,43 +506,39 @@ describe('Monitoring store actions', () => {
},
],
[],
- () => {
- expect(mock.history.get[0].params).toEqual(expectedParams);
- done();
- },
- ).catch(done.fail);
+ );
+ expect(mock.history.get[0].params).toEqual(expectedParams);
});
});
- it('commits failure, when waiting for results and getting a server error', (done) => {
+ it('commits failure, when waiting for results and getting a server error', async () => {
mock.onGet(prometheusEndpointPath).reply(500);
const error = new Error('Request failed with status code 500');
- testAction(
- fetchPrometheusMetric,
- { metric, defaultQueryParams },
- state,
- [
- {
- type: types.REQUEST_METRIC_RESULT,
- payload: {
- metricId: metric.metricId,
+ await expect(
+ testAction(
+ fetchPrometheusMetric,
+ { metric, defaultQueryParams },
+ state,
+ [
+ {
+ type: types.REQUEST_METRIC_RESULT,
+ payload: {
+ metricId: metric.metricId,
+ },
},
- },
- {
- type: types.RECEIVE_METRIC_RESULT_FAILURE,
- payload: {
- metricId: metric.metricId,
- error,
+ {
+ type: types.RECEIVE_METRIC_RESULT_FAILURE,
+ payload: {
+ metricId: metric.metricId,
+ error,
+ },
},
- },
- ],
- [],
- ).catch((e) => {
- expect(e).toEqual(error);
- done();
- });
+ ],
+ [],
+ ),
+ ).rejects.toEqual(error);
});
});
@@ -991,20 +943,16 @@ describe('Monitoring store actions', () => {
state.dashboardsEndpoint = '/dashboards.json';
});
- it('Succesful POST request resolves', (done) => {
+ it('Succesful POST request resolves', async () => {
mock.onPost(state.dashboardsEndpoint).reply(statusCodes.CREATED, {
dashboard: dashboardGitResponse[1],
});
- testAction(duplicateSystemDashboard, {}, state, [], [])
- .then(() => {
- expect(mock.history.post).toHaveLength(1);
- done();
- })
- .catch(done.fail);
+ await testAction(duplicateSystemDashboard, {}, state, [], []);
+ expect(mock.history.post).toHaveLength(1);
});
- it('Succesful POST request resolves to a dashboard', (done) => {
+ it('Succesful POST request resolves to a dashboard', async () => {
const mockCreatedDashboard = dashboardGitResponse[1];
const params = {
@@ -1025,50 +973,40 @@ describe('Monitoring store actions', () => {
dashboard: mockCreatedDashboard,
});
- testAction(duplicateSystemDashboard, params, state, [], [])
- .then((result) => {
- expect(mock.history.post).toHaveLength(1);
- expect(mock.history.post[0].data).toEqual(expectedPayload);
- expect(result).toEqual(mockCreatedDashboard);
-
- done();
- })
- .catch(done.fail);
+ const result = await testAction(duplicateSystemDashboard, params, state, [], []);
+ expect(mock.history.post).toHaveLength(1);
+ expect(mock.history.post[0].data).toEqual(expectedPayload);
+ expect(result).toEqual(mockCreatedDashboard);
});
- it('Failed POST request throws an error', (done) => {
+ it('Failed POST request throws an error', async () => {
mock.onPost(state.dashboardsEndpoint).reply(statusCodes.BAD_REQUEST);
- testAction(duplicateSystemDashboard, {}, state, [], []).catch((err) => {
- expect(mock.history.post).toHaveLength(1);
- expect(err).toEqual(expect.any(String));
-
- done();
- });
+ await expect(testAction(duplicateSystemDashboard, {}, state, [], [])).rejects.toEqual(
+ 'There was an error creating the dashboard.',
+ );
+ expect(mock.history.post).toHaveLength(1);
});
- it('Failed POST request throws an error with a description', (done) => {
+ it('Failed POST request throws an error with a description', async () => {
const backendErrorMsg = 'This file already exists!';
mock.onPost(state.dashboardsEndpoint).reply(statusCodes.BAD_REQUEST, {
error: backendErrorMsg,
});
- testAction(duplicateSystemDashboard, {}, state, [], []).catch((err) => {
- expect(mock.history.post).toHaveLength(1);
- expect(err).toEqual(expect.any(String));
- expect(err).toEqual(expect.stringContaining(backendErrorMsg));
-
- done();
- });
+ await expect(testAction(duplicateSystemDashboard, {}, state, [], [])).rejects.toEqual(
+ `There was an error creating the dashboard. ${backendErrorMsg}`,
+ );
+ expect(mock.history.post).toHaveLength(1);
});
});
// Variables manipulation
describe('updateVariablesAndFetchData', () => {
- it('should commit UPDATE_VARIABLE_VALUE mutation and fetch data', (done) => {
- testAction(
+ it('should commit UPDATE_VARIABLE_VALUE mutation and fetch data', () => {
+ return testAction(
updateVariablesAndFetchData,
{ pod: 'POD' },
state,
@@ -1083,7 +1021,6 @@ describe('Monitoring store actions', () => {
type: 'fetchDashboardData',
},
],
- done,
);
});
});
diff --git a/spec/frontend/monitoring/store/utils_spec.js b/spec/frontend/monitoring/store/utils_spec.js
index 697bdb9185f..c25de8caa95 100644
--- a/spec/frontend/monitoring/store/utils_spec.js
+++ b/spec/frontend/monitoring/store/utils_spec.js
@@ -547,7 +547,7 @@ describe('parseEnvironmentsResponse', () => {
{
id: 1,
name: 'env-1',
- metrics_path: `${projectPath}/environments/1/metrics`,
+ metrics_path: `${projectPath}/-/metrics?environment=1`,
},
],
},
@@ -562,7 +562,7 @@ describe('parseEnvironmentsResponse', () => {
{
id: 12,
name: 'env-12',
- metrics_path: `${projectPath}/environments/12/metrics`,
+ metrics_path: `${projectPath}/-/metrics?environment=12`,
},
],
},
diff --git a/spec/frontend/mr_notes/stores/actions_spec.js b/spec/frontend/mr_notes/stores/actions_spec.js
index c6578453d85..568c1b930c9 100644
--- a/spec/frontend/mr_notes/stores/actions_spec.js
+++ b/spec/frontend/mr_notes/stores/actions_spec.js
@@ -1,64 +1,37 @@
import MockAdapter from 'axios-mock-adapter';
-
-import testAction from 'helpers/vuex_action_helper';
import axios from '~/lib/utils/axios_utils';
-import { setEndpoints, setMrMetadata, fetchMrMetadata } from '~/mr_notes/stores/actions';
-import mutationTypes from '~/mr_notes/stores/mutation_types';
+import { createStore } from '~/mr_notes/stores';
describe('MR Notes Mutator Actions', () => {
+ let store;
+
+ beforeEach(() => {
+ store = createStore();
+ });
+
describe('setEndpoints', () => {
- it('should trigger the SET_ENDPOINTS state mutation', (done) => {
+ it('sets endpoints', async () => {
const endpoints = { endpointA: 'a' };
- testAction(
- setEndpoints,
- endpoints,
- {},
- [
- {
- type: mutationTypes.SET_ENDPOINTS,
- payload: endpoints,
- },
- ],
- [],
- done,
- );
- });
- });
+ await store.dispatch('setEndpoints', endpoints);
- describe('setMrMetadata', () => {
- it('should trigger the SET_MR_METADATA state mutation', async () => {
- const mrMetadata = { propA: 'a', propB: 'b' };
-
- await testAction(
- setMrMetadata,
- mrMetadata,
- {},
- [
- {
- type: mutationTypes.SET_MR_METADATA,
- payload: mrMetadata,
- },
- ],
- [],
- );
+ expect(store.state.page.endpoints).toEqual(endpoints);
});
});
describe('fetchMrMetadata', () => {
const mrMetadata = { meta: true, data: 'foo' };
- const state = {
- endpoints: {
- metadata: 'metadata',
- },
- };
+ const metadata = 'metadata';
+ const endpoints = { metadata };
let mock;
- beforeEach(() => {
+ beforeEach(async () => {
+ await store.dispatch('setEndpoints', endpoints);
+
mock = new MockAdapter(axios);
- mock.onGet(state.endpoints.metadata).reply(200, mrMetadata);
+ mock.onGet(metadata).reply(200, mrMetadata);
});
afterEach(() => {
@@ -66,27 +39,26 @@ describe('MR Notes Mutator Actions', () => {
});
it('should fetch the data from the API', async () => {
- await fetchMrMetadata({ state, dispatch: () => {} });
+ await store.dispatch('fetchMrMetadata');
await axios.waitForAll();
expect(mock.history.get).toHaveLength(1);
- expect(mock.history.get[0].url).toBe(state.endpoints.metadata);
+ expect(mock.history.get[0].url).toBe(metadata);
+ });
+
+ it('should set the fetched data into state', async () => {
+ await store.dispatch('fetchMrMetadata');
+
+ expect(store.state.page.mrMetadata).toEqual(mrMetadata);
});
- it('should set the fetched data into state', () => {
- return testAction(
- fetchMrMetadata,
- {},
- state,
- [],
- [
- {
- type: 'setMrMetadata',
- payload: mrMetadata,
- },
- ],
- );
+ it('should set failedToLoadMetadata flag when request fails', async () => {
+ mock.onGet(metadata).reply(500);
+
+ await store.dispatch('fetchMrMetadata');
+
+ expect(store.state.page.failedToLoadMetadata).toBe(true);
});
});
});
diff --git a/spec/frontend/notes/components/diff_discussion_header_spec.js b/spec/frontend/notes/components/diff_discussion_header_spec.js
index 9f94dd693cb..7878737fd31 100644
--- a/spec/frontend/notes/components/diff_discussion_header_spec.js
+++ b/spec/frontend/notes/components/diff_discussion_header_spec.js
@@ -4,7 +4,7 @@ import { nextTick } from 'vue';
import diffDiscussionHeader from '~/notes/components/diff_discussion_header.vue';
import createStore from '~/notes/stores';
-import mockDiffFile from '../../diffs/mock_data/diff_discussions';
+import mockDiffFile from 'jest/diffs/mock_data/diff_discussions';
import { discussionMock } from '../mock_data';
describe('diff_discussion_header component', () => {
diff --git a/spec/frontend/notes/components/note_actions_spec.js b/spec/frontend/notes/components/note_actions_spec.js
index 780f24b3aa8..bf5a6b4966a 100644
--- a/spec/frontend/notes/components/note_actions_spec.js
+++ b/spec/frontend/notes/components/note_actions_spec.js
@@ -87,8 +87,7 @@ describe('noteActions', () => {
});
it('should render emoji link', () => {
- expect(wrapper.find('.js-add-award').exists()).toBe(true);
- expect(wrapper.find('.js-add-award').attributes('data-position')).toBe('right');
+ expect(wrapper.find('[data-testid="note-emoji-button"]').exists()).toBe(true);
});
describe('actions dropdown', () => {
diff --git a/spec/frontend/notes/components/note_form_spec.js b/spec/frontend/notes/components/note_form_spec.js
index 3e80b24f128..b709141f4ac 100644
--- a/spec/frontend/notes/components/note_form_spec.js
+++ b/spec/frontend/notes/components/note_form_spec.js
@@ -81,7 +81,6 @@ describe('issue_note_form component', () => {
it('should show conflict message if note changes outside the component', async () => {
wrapper.setProps({
...props,
- isEditing: true,
noteBody: 'Foo',
});
@@ -111,6 +110,12 @@ describe('issue_note_form component', () => {
);
});
+ it('should set data-supports-quick-actions to enable autocomplete', () => {
+ const textarea = wrapper.find('textarea');
+
+ expect(textarea.attributes('data-supports-quick-actions')).toBe('true');
+ });
+
it('should link to markdown docs', () => {
const { markdownDocsPath } = notesDataMock;
const markdownField = wrapper.find(MarkdownField);
@@ -171,7 +176,6 @@ describe('issue_note_form component', () => {
it('should be possible to cancel', async () => {
wrapper.setProps({
...props,
- isEditing: true,
});
await nextTick();
@@ -185,7 +189,6 @@ describe('issue_note_form component', () => {
it('should be possible to update the note', async () => {
wrapper.setProps({
...props,
- isEditing: true,
});
await nextTick();
diff --git a/spec/frontend/notes/components/note_header_spec.js b/spec/frontend/notes/components/note_header_spec.js
index 4671d33219d..3513b562e0a 100644
--- a/spec/frontend/notes/components/note_header_spec.js
+++ b/spec/frontend/notes/components/note_header_spec.js
@@ -263,7 +263,7 @@ describe('NoteHeader component', () => {
});
describe('when author username link is hovered', () => {
- it('toggles hover specific CSS classes on author name link', (done) => {
+ it('toggles hover specific CSS classes on author name link', async () => {
createComponent({ author });
const authorUsernameLink = wrapper.find({ ref: 'authorUsernameLink' });
@@ -271,19 +271,15 @@ describe('NoteHeader component', () => {
authorUsernameLink.trigger('mouseenter');
- nextTick(() => {
- expect(authorNameLink.classes()).toContain('hover');
- expect(authorNameLink.classes()).toContain('text-underline');
+ await nextTick();
+ expect(authorNameLink.classes()).toContain('hover');
+ expect(authorNameLink.classes()).toContain('text-underline');
- authorUsernameLink.trigger('mouseleave');
+ authorUsernameLink.trigger('mouseleave');
- nextTick(() => {
- expect(authorNameLink.classes()).not.toContain('hover');
- expect(authorNameLink.classes()).not.toContain('text-underline');
-
- done();
- });
- });
+ await nextTick();
+ expect(authorNameLink.classes()).not.toContain('hover');
+ expect(authorNameLink.classes()).not.toContain('text-underline');
});
});
@@ -296,5 +292,13 @@ describe('NoteHeader component', () => {
createComponent({ isConfidential: status });
expect(findConfidentialIndicator().exists()).toBe(status);
});
+
+ it('shows confidential indicator tooltip for project context', () => {
+ createComponent({ isConfidential: true, noteableType: 'issue' });
+
+ expect(findConfidentialIndicator().attributes('title')).toBe(
+ 'This comment is confidential and only visible to project members',
+ );
+ });
});
});
diff --git a/spec/frontend/notes/components/noteable_discussion_spec.js b/spec/frontend/notes/components/noteable_discussion_spec.js
index 727ef02dcbb..c46d3bbe5b2 100644
--- a/spec/frontend/notes/components/noteable_discussion_spec.js
+++ b/spec/frontend/notes/components/noteable_discussion_spec.js
@@ -86,7 +86,6 @@ describe('noteable_discussion component', () => {
const noteFormProps = noteForm.props();
expect(noteFormProps.discussion).toBe(discussionMock);
- expect(noteFormProps.isEditing).toBe(false);
expect(noteFormProps.line).toBe(null);
expect(noteFormProps.saveButtonTitle).toBe('Comment');
expect(noteFormProps.autosaveKey).toBe(`Note/Issue/${discussionMock.id}/Reply`);
diff --git a/spec/frontend/notes/components/noteable_note_spec.js b/spec/frontend/notes/components/noteable_note_spec.js
index c7115a5911b..385edc59eb6 100644
--- a/spec/frontend/notes/components/noteable_note_spec.js
+++ b/spec/frontend/notes/components/noteable_note_spec.js
@@ -11,6 +11,7 @@ import NoteBody from '~/notes/components/note_body.vue';
import NoteHeader from '~/notes/components/note_header.vue';
import issueNote from '~/notes/components/noteable_note.vue';
import NotesModule from '~/notes/stores/modules';
+import { NOTEABLE_TYPE_MAPPING } from '~/notes/constants';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
@@ -226,6 +227,7 @@ describe('issue_note', () => {
expect(noteHeaderProps.author).toBe(note.author);
expect(noteHeaderProps.createdAt).toBe(note.created_at);
expect(noteHeaderProps.noteId).toBe(note.id);
+ expect(noteHeaderProps.noteableType).toBe(NOTEABLE_TYPE_MAPPING[note.noteable_type]);
});
it('should render note actions', () => {
diff --git a/spec/frontend/notes/components/notes_app_spec.js b/spec/frontend/notes/components/notes_app_spec.js
index bf36d6cb7a2..e227af88d3f 100644
--- a/spec/frontend/notes/components/notes_app_spec.js
+++ b/spec/frontend/notes/components/notes_app_spec.js
@@ -300,16 +300,18 @@ describe('note_app', () => {
await nextTick();
expect(wrapper.find(`.edit-note a[href="${markdownDocsPath}"]`).text().trim()).toEqual(
- 'Markdown is supported',
+ 'Markdown',
);
});
- it('should not render quick actions docs url', async () => {
+ it('should render quick actions docs url', async () => {
wrapper.find('.js-note-edit').trigger('click');
const { quickActionsDocsPath } = mockData.notesDataMock;
await nextTick();
- expect(wrapper.find(`.edit-note a[href="${quickActionsDocsPath}"]`).exists()).toBe(false);
+ expect(wrapper.find(`.edit-note a[href="${quickActionsDocsPath}"]`).text().trim()).toEqual(
+ 'quick actions',
+ );
});
});
diff --git a/spec/frontend/notes/components/sort_discussion_spec.js b/spec/frontend/notes/components/sort_discussion_spec.js
index a279dfd1ef3..bde27b7e5fc 100644
--- a/spec/frontend/notes/components/sort_discussion_spec.js
+++ b/spec/frontend/notes/components/sort_discussion_spec.js
@@ -38,8 +38,8 @@ describe('Sort Discussion component', () => {
createComponent();
});
- it('has local storage sync', () => {
- expect(findLocalStorageSync().exists()).toBe(true);
+ it('has local storage sync with the correct props', () => {
+ expect(findLocalStorageSync().props('asString')).toBe(true);
});
it('calls setDiscussionSortDirection when update is emitted', () => {
diff --git a/spec/frontend/notes/deprecated_notes_spec.js b/spec/frontend/notes/deprecated_notes_spec.js
index 7c52920da90..7193475c96a 100644
--- a/spec/frontend/notes/deprecated_notes_spec.js
+++ b/spec/frontend/notes/deprecated_notes_spec.js
@@ -561,7 +561,7 @@ describe.skip('Old Notes (~/deprecated_notes.js)', () => {
});
describe('postComment', () => {
- it('disables the submit button', (done) => {
+ it('disables the submit button', async () => {
const $submitButton = $form.find('.js-comment-submit-button');
expect($submitButton).not.toBeDisabled();
@@ -574,13 +574,8 @@ describe.skip('Old Notes (~/deprecated_notes.js)', () => {
return [200, note];
});
- notes
- .postComment(dummyEvent)
- .then(() => {
- expect($submitButton).not.toBeDisabled();
- })
- .then(done)
- .catch(done.fail);
+ await notes.postComment(dummyEvent);
+ expect($submitButton).not.toBeDisabled();
});
});
diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js
index 7424a87bc0f..75e7756cd6b 100644
--- a/spec/frontend/notes/stores/actions_spec.js
+++ b/spec/frontend/notes/stores/actions_spec.js
@@ -62,118 +62,109 @@ describe('Actions Notes Store', () => {
});
describe('setNotesData', () => {
- it('should set received notes data', (done) => {
- testAction(
+ it('should set received notes data', () => {
+ return testAction(
actions.setNotesData,
notesDataMock,
{ notesData: {} },
[{ type: 'SET_NOTES_DATA', payload: notesDataMock }],
[],
- done,
);
});
});
describe('setNoteableData', () => {
- it('should set received issue data', (done) => {
- testAction(
+ it('should set received issue data', () => {
+ return testAction(
actions.setNoteableData,
noteableDataMock,
{ noteableData: {} },
[{ type: 'SET_NOTEABLE_DATA', payload: noteableDataMock }],
[],
- done,
);
});
});
describe('setUserData', () => {
- it('should set received user data', (done) => {
- testAction(
+ it('should set received user data', () => {
+ return testAction(
actions.setUserData,
userDataMock,
{ userData: {} },
[{ type: 'SET_USER_DATA', payload: userDataMock }],
[],
- done,
);
});
});
describe('setLastFetchedAt', () => {
- it('should set received timestamp', (done) => {
- testAction(
+ it('should set received timestamp', () => {
+ return testAction(
actions.setLastFetchedAt,
'timestamp',
{ lastFetchedAt: {} },
[{ type: 'SET_LAST_FETCHED_AT', payload: 'timestamp' }],
[],
- done,
);
});
});
describe('setInitialNotes', () => {
- it('should set initial notes', (done) => {
- testAction(
+ it('should set initial notes', () => {
+ return testAction(
actions.setInitialNotes,
[individualNote],
{ notes: [] },
[{ type: 'ADD_OR_UPDATE_DISCUSSIONS', payload: [individualNote] }],
[],
- done,
);
});
});
describe('setTargetNoteHash', () => {
- it('should set target note hash', (done) => {
- testAction(
+ it('should set target note hash', () => {
+ return testAction(
actions.setTargetNoteHash,
'hash',
{ notes: [] },
[{ type: 'SET_TARGET_NOTE_HASH', payload: 'hash' }],
[],
- done,
);
});
});
describe('toggleDiscussion', () => {
- it('should toggle discussion', (done) => {
- testAction(
+ it('should toggle discussion', () => {
+ return testAction(
actions.toggleDiscussion,
{ discussionId: discussionMock.id },
{ notes: [discussionMock] },
[{ type: 'TOGGLE_DISCUSSION', payload: { discussionId: discussionMock.id } }],
[],
- done,
);
});
});
describe('expandDiscussion', () => {
- it('should expand discussion', (done) => {
- testAction(
+ it('should expand discussion', () => {
+ return testAction(
actions.expandDiscussion,
{ discussionId: discussionMock.id },
{ notes: [discussionMock] },
[{ type: 'EXPAND_DISCUSSION', payload: { discussionId: discussionMock.id } }],
[{ type: 'diffs/renderFileForDiscussionId', payload: discussionMock.id }],
- done,
);
});
});
describe('collapseDiscussion', () => {
- it('should commit collapse discussion', (done) => {
- testAction(
+ it('should commit collapse discussion', () => {
+ return testAction(
actions.collapseDiscussion,
{ discussionId: discussionMock.id },
{ notes: [discussionMock] },
[{ type: 'COLLAPSE_DISCUSSION', payload: { discussionId: discussionMock.id } }],
[],
- done,
);
});
});
@@ -184,28 +175,18 @@ describe('Actions Notes Store', () => {
});
describe('closeMergeRequest', () => {
- it('sets state as closed', (done) => {
- store
- .dispatch('closeIssuable', { notesData: { closeIssuePath: '' } })
- .then(() => {
- expect(store.state.noteableData.state).toEqual('closed');
- expect(store.state.isToggleStateButtonLoading).toEqual(false);
- done();
- })
- .catch(done.fail);
+ it('sets state as closed', async () => {
+ await store.dispatch('closeIssuable', { notesData: { closeIssuePath: '' } });
+ expect(store.state.noteableData.state).toEqual('closed');
+ expect(store.state.isToggleStateButtonLoading).toEqual(false);
});
});
describe('reopenMergeRequest', () => {
- it('sets state as reopened', (done) => {
- store
- .dispatch('reopenIssuable', { notesData: { reopenIssuePath: '' } })
- .then(() => {
- expect(store.state.noteableData.state).toEqual('reopened');
- expect(store.state.isToggleStateButtonLoading).toEqual(false);
- done();
- })
- .catch(done.fail);
+ it('sets state as reopened', async () => {
+ await store.dispatch('reopenIssuable', { notesData: { reopenIssuePath: '' } });
+ expect(store.state.noteableData.state).toEqual('reopened');
+ expect(store.state.isToggleStateButtonLoading).toEqual(false);
});
});
});
@@ -222,42 +203,39 @@ describe('Actions Notes Store', () => {
});
describe('toggleStateButtonLoading', () => {
- it('should set loading as true', (done) => {
- testAction(
+ it('should set loading as true', () => {
+ return testAction(
actions.toggleStateButtonLoading,
true,
{},
[{ type: 'TOGGLE_STATE_BUTTON_LOADING', payload: true }],
[],
- done,
);
});
- it('should set loading as false', (done) => {
- testAction(
+ it('should set loading as false', () => {
+ return testAction(
actions.toggleStateButtonLoading,
false,
{},
[{ type: 'TOGGLE_STATE_BUTTON_LOADING', payload: false }],
[],
- done,
);
});
});
describe('toggleIssueLocalState', () => {
- it('sets issue state as closed', (done) => {
- testAction(actions.toggleIssueLocalState, 'closed', {}, [{ type: 'CLOSE_ISSUE' }], [], done);
+ it('sets issue state as closed', () => {
+ return testAction(actions.toggleIssueLocalState, 'closed', {}, [{ type: 'CLOSE_ISSUE' }], []);
});
- it('sets issue state as reopened', (done) => {
- testAction(
+ it('sets issue state as reopened', () => {
+ return testAction(
actions.toggleIssueLocalState,
'reopened',
{},
[{ type: 'REOPEN_ISSUE' }],
[],
- done,
);
});
});
@@ -291,8 +269,8 @@ describe('Actions Notes Store', () => {
return store.dispatch('stopPolling');
};
- beforeEach((done) => {
- store.dispatch('setNotesData', notesDataMock).then(done).catch(done.fail);
+ beforeEach(() => {
+ return store.dispatch('setNotesData', notesDataMock);
});
afterEach(() => {
@@ -405,14 +383,13 @@ describe('Actions Notes Store', () => {
});
describe('setNotesFetchedState', () => {
- it('should set notes fetched state', (done) => {
- testAction(
+ it('should set notes fetched state', () => {
+ return testAction(
actions.setNotesFetchedState,
true,
{},
[{ type: 'SET_NOTES_FETCHED_STATE', payload: true }],
[],
- done,
);
});
});
@@ -432,10 +409,10 @@ describe('Actions Notes Store', () => {
document.body.setAttribute('data-page', '');
});
- it('commits DELETE_NOTE and dispatches updateMergeRequestWidget', (done) => {
+ it('commits DELETE_NOTE and dispatches updateMergeRequestWidget', () => {
const note = { path: endpoint, id: 1 };
- testAction(
+ return testAction(
actions.removeNote,
note,
store.state,
@@ -453,16 +430,15 @@ describe('Actions Notes Store', () => {
type: 'updateResolvableDiscussionsCounts',
},
],
- done,
);
});
- it('dispatches removeDiscussionsFromDiff on merge request page', (done) => {
+ it('dispatches removeDiscussionsFromDiff on merge request page', () => {
const note = { path: endpoint, id: 1 };
document.body.setAttribute('data-page', 'projects:merge_requests:show');
- testAction(
+ return testAction(
actions.removeNote,
note,
store.state,
@@ -483,7 +459,6 @@ describe('Actions Notes Store', () => {
type: 'diffs/removeDiscussionsFromDiff',
},
],
- done,
);
});
});
@@ -503,10 +478,10 @@ describe('Actions Notes Store', () => {
document.body.setAttribute('data-page', '');
});
- it('dispatches removeNote', (done) => {
+ it('dispatches removeNote', () => {
const note = { path: endpoint, id: 1 };
- testAction(
+ return testAction(
actions.deleteNote,
note,
{},
@@ -520,7 +495,6 @@ describe('Actions Notes Store', () => {
},
},
],
- done,
);
});
});
@@ -536,8 +510,8 @@ describe('Actions Notes Store', () => {
axiosMock.onAny().reply(200, res);
});
- it('commits ADD_NEW_NOTE and dispatches updateMergeRequestWidget', (done) => {
- testAction(
+ it('commits ADD_NEW_NOTE and dispatches updateMergeRequestWidget', () => {
+ return testAction(
actions.createNewNote,
{ endpoint: `${TEST_HOST}`, data: {} },
store.state,
@@ -558,7 +532,6 @@ describe('Actions Notes Store', () => {
type: 'updateResolvableDiscussionsCounts',
},
],
- done,
);
});
});
@@ -572,14 +545,13 @@ describe('Actions Notes Store', () => {
axiosMock.onAny().replyOnce(200, res);
});
- it('does not commit ADD_NEW_NOTE or dispatch updateMergeRequestWidget', (done) => {
- testAction(
+ it('does not commit ADD_NEW_NOTE or dispatch updateMergeRequestWidget', () => {
+ return testAction(
actions.createNewNote,
{ endpoint: `${TEST_HOST}`, data: {} },
store.state,
[],
[],
- done,
);
});
});
@@ -595,8 +567,8 @@ describe('Actions Notes Store', () => {
});
describe('as note', () => {
- it('commits UPDATE_NOTE and dispatches updateMergeRequestWidget', (done) => {
- testAction(
+ it('commits UPDATE_NOTE and dispatches updateMergeRequestWidget', () => {
+ return testAction(
actions.toggleResolveNote,
{ endpoint: `${TEST_HOST}`, isResolved: true, discussion: false },
store.state,
@@ -614,14 +586,13 @@ describe('Actions Notes Store', () => {
type: 'updateMergeRequestWidget',
},
],
- done,
);
});
});
describe('as discussion', () => {
- it('commits UPDATE_DISCUSSION and dispatches updateMergeRequestWidget', (done) => {
- testAction(
+ it('commits UPDATE_DISCUSSION and dispatches updateMergeRequestWidget', () => {
+ return testAction(
actions.toggleResolveNote,
{ endpoint: `${TEST_HOST}`, isResolved: true, discussion: true },
store.state,
@@ -639,7 +610,6 @@ describe('Actions Notes Store', () => {
type: 'updateMergeRequestWidget',
},
],
- done,
);
});
});
@@ -656,41 +626,38 @@ describe('Actions Notes Store', () => {
});
describe('setCommentsDisabled', () => {
- it('should set comments disabled state', (done) => {
- testAction(
+ it('should set comments disabled state', () => {
+ return testAction(
actions.setCommentsDisabled,
true,
null,
[{ type: 'DISABLE_COMMENTS', payload: true }],
[],
- done,
);
});
});
describe('updateResolvableDiscussionsCounts', () => {
- it('commits UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS', (done) => {
- testAction(
+ it('commits UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS', () => {
+ return testAction(
actions.updateResolvableDiscussionsCounts,
null,
{},
[{ type: 'UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS' }],
[],
- done,
);
});
});
describe('convertToDiscussion', () => {
- it('commits CONVERT_TO_DISCUSSION with noteId', (done) => {
+ it('commits CONVERT_TO_DISCUSSION with noteId', () => {
const noteId = 'dummy-note-id';
- testAction(
+ return testAction(
actions.convertToDiscussion,
noteId,
{},
[{ type: 'CONVERT_TO_DISCUSSION', payload: noteId }],
[],
- done,
);
});
});
@@ -786,11 +753,11 @@ describe('Actions Notes Store', () => {
describe('replyToDiscussion', () => {
const payload = { endpoint: TEST_HOST, data: {} };
- it('updates discussion if response contains disussion', (done) => {
+ it('updates discussion if response contains disussion', () => {
const discussion = { notes: [] };
axiosMock.onAny().reply(200, { discussion });
- testAction(
+ return testAction(
actions.replyToDiscussion,
payload,
{
@@ -802,15 +769,14 @@ describe('Actions Notes Store', () => {
{ type: 'startTaskList' },
{ type: 'updateResolvableDiscussionsCounts' },
],
- done,
);
});
- it('adds a reply to a discussion', (done) => {
+ it('adds a reply to a discussion', () => {
const res = {};
axiosMock.onAny().reply(200, res);
- testAction(
+ return testAction(
actions.replyToDiscussion,
payload,
{
@@ -818,21 +784,19 @@ describe('Actions Notes Store', () => {
},
[{ type: mutationTypes.ADD_NEW_REPLY_TO_DISCUSSION, payload: res }],
[],
- done,
);
});
});
describe('removeConvertedDiscussion', () => {
- it('commits CONVERT_TO_DISCUSSION with noteId', (done) => {
+ it('commits CONVERT_TO_DISCUSSION with noteId', () => {
const noteId = 'dummy-id';
- testAction(
+ return testAction(
actions.removeConvertedDiscussion,
noteId,
{},
[{ type: 'REMOVE_CONVERTED_DISCUSSION', payload: noteId }],
[],
- done,
);
});
});
@@ -849,8 +813,8 @@ describe('Actions Notes Store', () => {
};
});
- it('when unresolved, dispatches action', (done) => {
- testAction(
+ it('when unresolved, dispatches action', () => {
+ return testAction(
actions.resolveDiscussion,
{ discussionId },
{ ...state, ...getters },
@@ -865,20 +829,18 @@ describe('Actions Notes Store', () => {
},
},
],
- done,
);
});
- it('when resolved, does nothing', (done) => {
+ it('when resolved, does nothing', () => {
getters.isDiscussionResolved = (id) => id === discussionId;
- testAction(
+ return testAction(
actions.resolveDiscussion,
{ discussionId },
{ ...state, ...getters },
[],
[],
- done,
);
});
});
@@ -891,22 +853,17 @@ describe('Actions Notes Store', () => {
const res = { errors: { something: ['went wrong'] } };
const error = { message: 'Unprocessable entity', response: { data: res } };
- it('throws an error', (done) => {
- actions
- .saveNote(
+ it('throws an error', async () => {
+ await expect(
+ actions.saveNote(
{
commit() {},
dispatch: () => Promise.reject(error),
},
payload,
- )
- .then(() => done.fail('Expected error to be thrown!'))
- .catch((err) => {
- expect(err).toBe(error);
- expect(createFlash).not.toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
+ ),
+ ).rejects.toEqual(error);
+ expect(createFlash).not.toHaveBeenCalled();
});
});
@@ -914,46 +871,35 @@ describe('Actions Notes Store', () => {
const res = { errors: { base: ['something went wrong'] } };
const error = { message: 'Unprocessable entity', response: { data: res } };
- it('sets flash alert using errors.base message', (done) => {
- actions
- .saveNote(
- {
- commit() {},
- dispatch: () => Promise.reject(error),
- },
- { ...payload, flashContainer },
- )
- .then((resp) => {
- expect(resp.hasFlash).toBe(true);
- expect(createFlash).toHaveBeenCalledWith({
- message: 'Your comment could not be submitted because something went wrong',
- parent: flashContainer,
- });
- })
- .catch(() => done.fail('Expected success response!'))
- .then(done)
- .catch(done.fail);
+ it('sets flash alert using errors.base message', async () => {
+ const resp = await actions.saveNote(
+ {
+ commit() {},
+ dispatch: () => Promise.reject(error),
+ },
+ { ...payload, flashContainer },
+ );
+ expect(resp.hasFlash).toBe(true);
+ expect(createFlash).toHaveBeenCalledWith({
+ message: 'Your comment could not be submitted because something went wrong',
+ parent: flashContainer,
+ });
});
});
describe('if response contains no errors', () => {
const res = { valid: true };
- it('returns the response', (done) => {
- actions
- .saveNote(
- {
- commit() {},
- dispatch: () => Promise.resolve(res),
- },
- payload,
- )
- .then((data) => {
- expect(data).toBe(res);
- expect(createFlash).not.toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
+ it('returns the response', async () => {
+ const data = await actions.saveNote(
+ {
+ commit() {},
+ dispatch: () => Promise.resolve(res),
+ },
+ payload,
+ );
+ expect(data).toBe(res);
+ expect(createFlash).not.toHaveBeenCalled();
});
});
});
@@ -970,19 +916,17 @@ describe('Actions Notes Store', () => {
flashContainer = {};
});
- const testSubmitSuggestion = (done, expectFn) => {
- actions
- .submitSuggestion(
- { commit, dispatch },
- { discussionId, noteId, suggestionId, flashContainer },
- )
- .then(expectFn)
- .then(done)
- .catch(done.fail);
+ const testSubmitSuggestion = async (expectFn) => {
+ await actions.submitSuggestion(
+ { commit, dispatch },
+ { discussionId, noteId, suggestionId, flashContainer },
+ );
+
+ expectFn();
};
- it('when service success, commits and resolves discussion', (done) => {
- testSubmitSuggestion(done, () => {
+ it('when service success, commits and resolves discussion', () => {
+ testSubmitSuggestion(() => {
expect(commit.mock.calls).toEqual([
[mutationTypes.SET_RESOLVING_DISCUSSION, true],
[mutationTypes.SET_RESOLVING_DISCUSSION, false],
@@ -997,12 +941,12 @@ describe('Actions Notes Store', () => {
});
});
- it('when service fails, flashes error message', (done) => {
+ it('when service fails, flashes error message', () => {
const response = { response: { data: { message: TEST_ERROR_MESSAGE } } };
Api.applySuggestion.mockReturnValue(Promise.reject(response));
- testSubmitSuggestion(done, () => {
+ return testSubmitSuggestion(() => {
expect(commit.mock.calls).toEqual([
[mutationTypes.SET_RESOLVING_DISCUSSION, true],
[mutationTypes.SET_RESOLVING_DISCUSSION, false],
@@ -1015,12 +959,12 @@ describe('Actions Notes Store', () => {
});
});
- it('when service fails, and no error message available, uses default message', (done) => {
+ it('when service fails, and no error message available, uses default message', () => {
const response = { response: 'foo' };
Api.applySuggestion.mockReturnValue(Promise.reject(response));
- testSubmitSuggestion(done, () => {
+ return testSubmitSuggestion(() => {
expect(commit.mock.calls).toEqual([
[mutationTypes.SET_RESOLVING_DISCUSSION, true],
[mutationTypes.SET_RESOLVING_DISCUSSION, false],
@@ -1033,10 +977,10 @@ describe('Actions Notes Store', () => {
});
});
- it('when resolve discussion fails, fail gracefully', (done) => {
+ it('when resolve discussion fails, fail gracefully', () => {
dispatch.mockReturnValue(Promise.reject());
- testSubmitSuggestion(done, () => {
+ return testSubmitSuggestion(() => {
expect(createFlash).not.toHaveBeenCalled();
});
});
@@ -1056,16 +1000,14 @@ describe('Actions Notes Store', () => {
flashContainer = {};
});
- const testSubmitSuggestionBatch = (done, expectFn) => {
- actions
- .submitSuggestionBatch({ commit, dispatch, state }, { flashContainer })
- .then(expectFn)
- .then(done)
- .catch(done.fail);
+ const testSubmitSuggestionBatch = async (expectFn) => {
+ await actions.submitSuggestionBatch({ commit, dispatch, state }, { flashContainer });
+
+ expectFn();
};
- it('when service succeeds, commits, resolves discussions, resets batch and applying batch state', (done) => {
- testSubmitSuggestionBatch(done, () => {
+ it('when service succeeds, commits, resolves discussions, resets batch and applying batch state', () => {
+ testSubmitSuggestionBatch(() => {
expect(commit.mock.calls).toEqual([
[mutationTypes.SET_APPLYING_BATCH_STATE, true],
[mutationTypes.SET_RESOLVING_DISCUSSION, true],
@@ -1085,12 +1027,12 @@ describe('Actions Notes Store', () => {
});
});
- it('when service fails, flashes error message, resets applying batch state', (done) => {
+ it('when service fails, flashes error message, resets applying batch state', () => {
const response = { response: { data: { message: TEST_ERROR_MESSAGE } } };
Api.applySuggestionBatch.mockReturnValue(Promise.reject(response));
- testSubmitSuggestionBatch(done, () => {
+ testSubmitSuggestionBatch(() => {
expect(commit.mock.calls).toEqual([
[mutationTypes.SET_APPLYING_BATCH_STATE, true],
[mutationTypes.SET_RESOLVING_DISCUSSION, true],
@@ -1106,12 +1048,12 @@ describe('Actions Notes Store', () => {
});
});
- it('when service fails, and no error message available, uses default message', (done) => {
+ it('when service fails, and no error message available, uses default message', () => {
const response = { response: 'foo' };
Api.applySuggestionBatch.mockReturnValue(Promise.reject(response));
- testSubmitSuggestionBatch(done, () => {
+ testSubmitSuggestionBatch(() => {
expect(commit.mock.calls).toEqual([
[mutationTypes.SET_APPLYING_BATCH_STATE, true],
[mutationTypes.SET_RESOLVING_DISCUSSION, true],
@@ -1128,10 +1070,10 @@ describe('Actions Notes Store', () => {
});
});
- it('when resolve discussions fails, fails gracefully, resets batch and applying batch state', (done) => {
+ it('when resolve discussions fails, fails gracefully, resets batch and applying batch state', () => {
dispatch.mockReturnValue(Promise.reject());
- testSubmitSuggestionBatch(done, () => {
+ testSubmitSuggestionBatch(() => {
expect(commit.mock.calls).toEqual([
[mutationTypes.SET_APPLYING_BATCH_STATE, true],
[mutationTypes.SET_RESOLVING_DISCUSSION, true],
@@ -1148,14 +1090,13 @@ describe('Actions Notes Store', () => {
describe('addSuggestionInfoToBatch', () => {
const suggestionInfo = batchSuggestionsInfoMock[0];
- it("adds a suggestion's info to the current batch", (done) => {
- testAction(
+ it("adds a suggestion's info to the current batch", () => {
+ return testAction(
actions.addSuggestionInfoToBatch,
suggestionInfo,
{ batchSuggestionsInfo: [] },
[{ type: 'ADD_SUGGESTION_TO_BATCH', payload: suggestionInfo }],
[],
- done,
);
});
});
@@ -1163,14 +1104,13 @@ describe('Actions Notes Store', () => {
describe('removeSuggestionInfoFromBatch', () => {
const suggestionInfo = batchSuggestionsInfoMock[0];
- it("removes a suggestion's info the current batch", (done) => {
- testAction(
+ it("removes a suggestion's info the current batch", () => {
+ return testAction(
actions.removeSuggestionInfoFromBatch,
suggestionInfo.suggestionId,
{ batchSuggestionsInfo: [suggestionInfo] },
[{ type: 'REMOVE_SUGGESTION_FROM_BATCH', payload: suggestionInfo.suggestionId }],
[],
- done,
);
});
});
@@ -1209,8 +1149,8 @@ describe('Actions Notes Store', () => {
});
describe('setDiscussionSortDirection', () => {
- it('calls the correct mutation with the correct args', (done) => {
- testAction(
+ it('calls the correct mutation with the correct args', () => {
+ return testAction(
actions.setDiscussionSortDirection,
{ direction: notesConstants.DESC, persist: false },
{},
@@ -1221,20 +1161,18 @@ describe('Actions Notes Store', () => {
},
],
[],
- done,
);
});
});
describe('setSelectedCommentPosition', () => {
- it('calls the correct mutation with the correct args', (done) => {
- testAction(
+ it('calls the correct mutation with the correct args', () => {
+ return testAction(
actions.setSelectedCommentPosition,
{},
{},
[{ type: mutationTypes.SET_SELECTED_COMMENT_POSITION, payload: {} }],
[],
- done,
);
});
});
@@ -1248,9 +1186,9 @@ describe('Actions Notes Store', () => {
};
describe('if response contains no errors', () => {
- it('dispatches requestDeleteDescriptionVersion', (done) => {
+ it('dispatches requestDeleteDescriptionVersion', () => {
axiosMock.onDelete(endpoint).replyOnce(200);
- testAction(
+ return testAction(
actions.softDeleteDescriptionVersion,
payload,
{},
@@ -1264,35 +1202,33 @@ describe('Actions Notes Store', () => {
payload: payload.versionId,
},
],
- done,
);
});
});
describe('if response contains errors', () => {
const errorMessage = 'Request failed with status code 503';
- it('dispatches receiveDeleteDescriptionVersionError and throws an error', (done) => {
+ it('dispatches receiveDeleteDescriptionVersionError and throws an error', async () => {
axiosMock.onDelete(endpoint).replyOnce(503);
- testAction(
- actions.softDeleteDescriptionVersion,
- payload,
- {},
- [],
- [
- {
- type: 'requestDeleteDescriptionVersion',
- },
- {
- type: 'receiveDeleteDescriptionVersionError',
- payload: new Error(errorMessage),
- },
- ],
- )
- .then(() => done.fail('Expected error to be thrown'))
- .catch(() => {
- expect(createFlash).toHaveBeenCalled();
- done();
- });
+ await expect(
+ testAction(
+ actions.softDeleteDescriptionVersion,
+ payload,
+ {},
+ [],
+ [
+ {
+ type: 'requestDeleteDescriptionVersion',
+ },
+ {
+ type: 'receiveDeleteDescriptionVersionError',
+ payload: new Error(errorMessage),
+ },
+ ],
+ ),
+ ).rejects.toEqual(new Error());
+
+ expect(createFlash).toHaveBeenCalled();
});
});
});
@@ -1306,14 +1242,13 @@ describe('Actions Notes Store', () => {
});
describe('updateAssignees', () => {
- it('update the assignees state', (done) => {
- testAction(
+ it('update the assignees state', () => {
+ return testAction(
actions.updateAssignees,
[userDataMock.id],
{ state: noteableDataMock },
[{ type: mutationTypes.UPDATE_ASSIGNEES, payload: [userDataMock.id] }],
[],
- done,
);
});
});
@@ -1376,28 +1311,26 @@ describe('Actions Notes Store', () => {
});
describe('updateDiscussionPosition', () => {
- it('update the assignees state', (done) => {
+ it('update the assignees state', () => {
const updatedPosition = { discussionId: 1, position: { test: true } };
- testAction(
+ return testAction(
actions.updateDiscussionPosition,
updatedPosition,
{ state: { discussions: [] } },
[{ type: mutationTypes.UPDATE_DISCUSSION_POSITION, payload: updatedPosition }],
[],
- done,
);
});
});
describe('setFetchingState', () => {
- it('commits SET_NOTES_FETCHING_STATE', (done) => {
- testAction(
+ it('commits SET_NOTES_FETCHING_STATE', () => {
+ return testAction(
actions.setFetchingState,
true,
null,
[{ type: mutationTypes.SET_NOTES_FETCHING_STATE, payload: true }],
[],
- done,
);
});
});
@@ -1409,9 +1342,9 @@ describe('Actions Notes Store', () => {
window.gon = {};
});
- it('updates the discussions and dispatches `updateResolvableDiscussionsCounts`', (done) => {
+ it('updates the discussions and dispatches `updateResolvableDiscussionsCounts`', () => {
axiosMock.onAny().reply(200, { discussion });
- testAction(
+ return testAction(
actions.fetchDiscussions,
{},
null,
@@ -1420,14 +1353,13 @@ describe('Actions Notes Store', () => {
{ type: mutationTypes.SET_FETCHING_DISCUSSIONS, payload: false },
],
[{ type: 'updateResolvableDiscussionsCounts' }],
- done,
);
});
- it('dispatches `fetchDiscussionsBatch` action if `paginatedIssueDiscussions` feature flag is enabled', (done) => {
+ it('dispatches `fetchDiscussionsBatch` action if `paginatedIssueDiscussions` feature flag is enabled', () => {
window.gon = { features: { paginatedIssueDiscussions: true } };
- testAction(
+ return testAction(
actions.fetchDiscussions,
{ path: 'test-path', filter: 'test-filter', persistFilter: 'test-persist-filter' },
null,
@@ -1444,7 +1376,6 @@ describe('Actions Notes Store', () => {
},
},
],
- done,
);
});
});
@@ -1458,9 +1389,9 @@ describe('Actions Notes Store', () => {
const actionPayload = { config, path: 'test-path', perPage: 20 };
- it('updates the discussions and dispatches `updateResolvableDiscussionsCounts if there are no headers', (done) => {
+ it('updates the discussions and dispatches `updateResolvableDiscussionsCounts if there are no headers', () => {
axiosMock.onAny().reply(200, { discussion }, {});
- testAction(
+ return testAction(
actions.fetchDiscussionsBatch,
actionPayload,
null,
@@ -1469,13 +1400,12 @@ describe('Actions Notes Store', () => {
{ type: mutationTypes.SET_FETCHING_DISCUSSIONS, payload: false },
],
[{ type: 'updateResolvableDiscussionsCounts' }],
- done,
);
});
- it('dispatches itself if there is `x-next-page-cursor` header', (done) => {
+ it('dispatches itself if there is `x-next-page-cursor` header', () => {
axiosMock.onAny().reply(200, { discussion }, { 'x-next-page-cursor': 1 });
- testAction(
+ return testAction(
actions.fetchDiscussionsBatch,
actionPayload,
null,
@@ -1486,7 +1416,6 @@ describe('Actions Notes Store', () => {
payload: { ...actionPayload, perPage: 30, cursor: 1 },
},
],
- done,
);
});
});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_button_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_button_spec.js
index 6d7bf528495..ad67128502a 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_button_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_button_spec.js
@@ -1,7 +1,7 @@
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlTooltip, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import component from '~/packages_and_registries/container_registry/explorer/components/delete_button.vue';
+import { LIST_DELETE_BUTTON_DISABLED_FOR_MIGRATION } from '~/packages_and_registries/container_registry/explorer/constants/list';
describe('delete_button', () => {
let wrapper;
@@ -12,6 +12,7 @@ describe('delete_button', () => {
};
const findButton = () => wrapper.find(GlButton);
+ const findTooltip = () => wrapper.find(GlTooltip);
const mountComponent = (props) => {
wrapper = shallowMount(component, {
@@ -19,8 +20,9 @@ describe('delete_button', () => {
...defaultProps,
...props,
},
- directives: {
- GlTooltip: createMockDirective(),
+ stubs: {
+ GlTooltip,
+ GlSprintf,
},
});
};
@@ -33,41 +35,50 @@ describe('delete_button', () => {
describe('tooltip', () => {
it('the title is controlled by tooltipTitle prop', () => {
mountComponent();
- const tooltip = getBinding(wrapper.element, 'gl-tooltip');
+ const tooltip = findTooltip();
expect(tooltip).toBeDefined();
- expect(tooltip.value.title).toBe(defaultProps.tooltipTitle);
+ expect(tooltip.text()).toBe(defaultProps.tooltipTitle);
});
it('is disabled when tooltipTitle is disabled', () => {
mountComponent({ tooltipDisabled: true });
- const tooltip = getBinding(wrapper.element, 'gl-tooltip');
- expect(tooltip.value.disabled).toBe(true);
+ expect(findTooltip().props('disabled')).toBe(true);
});
- describe('button', () => {
- it('exists', () => {
- mountComponent();
- expect(findButton().exists()).toBe(true);
+ it('works with a link', () => {
+ mountComponent({
+ tooltipTitle: LIST_DELETE_BUTTON_DISABLED_FOR_MIGRATION,
+ tooltipLink: 'foo',
});
+ expect(findTooltip().text()).toMatchInterpolatedText(
+ LIST_DELETE_BUTTON_DISABLED_FOR_MIGRATION,
+ );
+ });
+ });
- it('has the correct props/attributes bound', () => {
- mountComponent({ disabled: true });
- expect(findButton().attributes()).toMatchObject({
- 'aria-label': 'Foo title',
- icon: 'remove',
- title: 'Foo title',
- variant: 'danger',
- disabled: 'true',
- category: 'secondary',
- });
- });
+ describe('button', () => {
+ it('exists', () => {
+ mountComponent();
+ expect(findButton().exists()).toBe(true);
+ });
- it('emits a delete event', () => {
- mountComponent();
- expect(wrapper.emitted('delete')).toEqual(undefined);
- findButton().vm.$emit('click');
- expect(wrapper.emitted('delete')).toEqual([[]]);
+ it('has the correct props/attributes bound', () => {
+ mountComponent({ disabled: true });
+ expect(findButton().attributes()).toMatchObject({
+ 'aria-label': 'Foo title',
+ icon: 'remove',
+ title: 'Foo title',
+ variant: 'danger',
+ disabled: 'true',
+ category: 'secondary',
});
});
+
+ it('emits a delete event', () => {
+ mountComponent();
+ expect(wrapper.emitted('delete')).toEqual(undefined);
+ findButton().vm.$emit('click');
+ expect(wrapper.emitted('delete')).toEqual([[]]);
+ });
});
});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js
index 411bef54e40..690d827ec67 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js
@@ -10,6 +10,7 @@ import {
LIST_DELETE_BUTTON_DISABLED,
REMOVE_REPOSITORY_LABEL,
IMAGE_DELETE_SCHEDULED_STATUS,
+ IMAGE_MIGRATING_STATE,
SCHEDULED_STATUS,
ROOT_IMAGE_TEXT,
} from '~/packages_and_registries/container_registry/explorer/constants';
@@ -41,6 +42,9 @@ describe('Image List Row', () => {
item,
...props,
},
+ provide: {
+ config: {},
+ },
directives: {
GlTooltip: createMockDirective(),
},
@@ -178,6 +182,12 @@ describe('Image List Row', () => {
expect(findDeleteBtn().props('disabled')).toBe(state);
},
);
+
+ it('is disabled when migrationState is importing', () => {
+ mountComponent({ item: { ...item, migrationState: IMAGE_MIGRATING_STATE } });
+
+ expect(findDeleteBtn().props('disabled')).toBe(true);
+ });
});
describe('tags count', () => {
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js
index c91a9c0f0fb..7d09c09d03b 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js
@@ -1,4 +1,4 @@
-import { GlSprintf } from '@gitlab/ui';
+import { GlSprintf, GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import Component from '~/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue';
@@ -6,6 +6,7 @@ import {
CONTAINER_REGISTRY_TITLE,
LIST_INTRO_TEXT,
EXPIRATION_POLICY_DISABLED_TEXT,
+ SET_UP_CLEANUP,
} from '~/packages_and_registries/container_registry/explorer/constants';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
@@ -21,6 +22,7 @@ describe('registry_header', () => {
const findCommandsSlot = () => wrapper.find('[data-testid="commands-slot"]');
const findImagesCountSubHeader = () => wrapper.find('[data-testid="images-count"]');
const findExpirationPolicySubHeader = () => wrapper.find('[data-testid="expiration-policy"]');
+ const findSetupCleanUpLink = () => wrapper.findComponent(GlLink);
const mountComponent = async (propsData, slots) => {
wrapper = shallowMount(Component, {
@@ -88,6 +90,7 @@ describe('registry_header', () => {
});
const text = findExpirationPolicySubHeader();
+
expect(text.exists()).toBe(true);
expect(text.props()).toMatchObject({
text: EXPIRATION_POLICY_DISABLED_TEXT,
@@ -100,12 +103,17 @@ describe('registry_header', () => {
await mountComponent({
expirationPolicy: { enabled: true },
expirationPolicyHelpPagePath: 'foo',
+ showCleanupPolicyLink: true,
imagesCount: 1,
});
const text = findExpirationPolicySubHeader();
+ const cleanupLink = findSetupCleanUpLink();
+
expect(text.exists()).toBe(true);
expect(text.props('text')).toBe('Expiration policy will run in ');
+ expect(cleanupLink.exists()).toBe(true);
+ expect(cleanupLink.text()).toBe(SET_UP_CLEANUP);
});
it('when the expiration policy is completely disabled', async () => {
await mountComponent({
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js b/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js
index fda1db4b7e1..7e6f88fe5bc 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js
@@ -5,6 +5,7 @@ export const imagesListResponse = [
name: 'rails-12009',
path: 'gitlab-org/gitlab-test/rails-12009',
status: null,
+ migrationState: 'default',
location: '0.0.0.0:5000/gitlab-org/gitlab-test/rails-12009',
canDelete: true,
createdAt: '2020-11-03T13:29:21Z',
@@ -17,6 +18,7 @@ export const imagesListResponse = [
name: 'rails-20572',
path: 'gitlab-org/gitlab-test/rails-20572',
status: null,
+ migrationState: 'default',
location: '0.0.0.0:5000/gitlab-org/gitlab-test/rails-20572',
canDelete: true,
createdAt: '2020-09-21T06:57:43Z',
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js
index da4bfcde217..79403d29d18 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js
@@ -6,7 +6,6 @@ import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql';
-import CleanupPolicyEnabledAlert from '~/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue';
import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
import DeleteImage from '~/packages_and_registries/container_registry/explorer/components/delete_image.vue';
import CliCommands from '~/packages_and_registries/shared/components/cli_commands.vue';
@@ -58,7 +57,6 @@ describe('List Page', () => {
const findPersistedSearch = () => wrapper.findComponent(PersistedSearch);
const findEmptySearchMessage = () => wrapper.find('[data-testid="emptySearch"]');
const findDeleteImage = () => wrapper.findComponent(DeleteImage);
- const findCleanupAlert = () => wrapper.findComponent(CleanupPolicyEnabledAlert);
const fireFirstSortUpdate = () => {
findPersistedSearch().vm.$emit('update', { sort: 'UPDATED_DESC', filters: [] });
@@ -511,33 +509,4 @@ describe('List Page', () => {
testTrackingCall('confirm_delete');
});
});
-
- describe('cleanup is on alert', () => {
- it('exist when showCleanupPolicyOnAlert is true and has the correct props', async () => {
- mountComponent({
- config: {
- showCleanupPolicyOnAlert: true,
- projectPath: 'foo',
- isGroupPage: false,
- cleanupPoliciesSettingsPath: 'bar',
- },
- });
-
- await waitForApolloRequestRender();
-
- expect(findCleanupAlert().exists()).toBe(true);
- expect(findCleanupAlert().props()).toMatchObject({
- projectPath: 'foo',
- cleanupPoliciesSettingsPath: 'bar',
- });
- });
-
- it('is hidden when showCleanupPolicyOnAlert is false', async () => {
- mountComponent();
-
- await waitForApolloRequestRender();
-
- expect(findCleanupAlert().exists()).toBe(false);
- });
- });
});
diff --git a/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js b/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js
index 79894e25889..dbe9793fb8c 100644
--- a/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js
+++ b/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js
@@ -1,19 +1,26 @@
import {
+ GlAlert,
+ GlDropdown,
+ GlDropdownItem,
GlFormInputGroup,
GlFormGroup,
+ GlModal,
GlSkeletonLoader,
GlSprintf,
GlEmptyState,
} from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import MockAdapter from 'axios-mock-adapter';
import createMockApollo from 'helpers/mock_apollo_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { stripTypenames } from 'helpers/graphql_helpers';
import waitForPromises from 'helpers/wait_for_promises';
import { GRAPHQL_PAGE_SIZE } from '~/packages_and_registries/dependency_proxy/constants';
+import axios from '~/lib/utils/axios_utils';
import DependencyProxyApp from '~/packages_and_registries/dependency_proxy/app.vue';
+import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import ManifestsList from '~/packages_and_registries/dependency_proxy/components/manifests_list.vue';
@@ -21,13 +28,25 @@ import getDependencyProxyDetailsQuery from '~/packages_and_registries/dependency
import { proxyDetailsQuery, proxyData, pagination, proxyManifests } from './mock_data';
+const dummyApiVersion = 'v3000';
+const dummyGrouptId = 1;
+const dummyUrlRoot = '/gitlab';
+const dummyGon = {
+ api_version: dummyApiVersion,
+ relative_url_root: dummyUrlRoot,
+};
+let originalGon;
+const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${dummyGrouptId}/dependency_proxy/cache`;
+
describe('DependencyProxyApp', () => {
let wrapper;
let apolloProvider;
let resolver;
+ let mock;
const provideDefaults = {
groupPath: 'gitlab-org',
+ groupId: dummyGrouptId,
dependencyProxyAvailable: true,
noManifestsIllustration: 'noManifestsIllustration',
};
@@ -43,9 +62,14 @@ describe('DependencyProxyApp', () => {
apolloProvider,
provide,
stubs: {
+ GlAlert,
+ GlDropdown,
+ GlDropdownItem,
GlFormInputGroup,
GlFormGroup,
+ GlModal,
GlSprintf,
+ TitleArea,
},
});
}
@@ -59,13 +83,24 @@ describe('DependencyProxyApp', () => {
const findProxyCountText = () => wrapper.findByTestId('proxy-count');
const findManifestList = () => wrapper.findComponent(ManifestsList);
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
+ const findClearCacheDropdownList = () => wrapper.findComponent(GlDropdown);
+ const findClearCacheModal = () => wrapper.findComponent(GlModal);
+ const findClearCacheAlert = () => wrapper.findComponent(GlAlert);
beforeEach(() => {
resolver = jest.fn().mockResolvedValue(proxyDetailsQuery());
+
+ originalGon = window.gon;
+ window.gon = { ...dummyGon };
+
+ mock = new MockAdapter(axios);
+ mock.onDelete(expectedUrl).reply(202, {});
});
afterEach(() => {
wrapper.destroy();
+ window.gon = originalGon;
+ mock.restore();
});
describe('when the dependency proxy is not available', () => {
@@ -95,6 +130,12 @@ describe('DependencyProxyApp', () => {
expect(resolver).not.toHaveBeenCalled();
});
+
+ it('hides the clear cache dropdown list', () => {
+ createComponent(createComponentArguments);
+
+ expect(findClearCacheDropdownList().exists()).toBe(false);
+ });
});
describe('when the dependency proxy is available', () => {
@@ -165,6 +206,7 @@ describe('DependencyProxyApp', () => {
}),
);
createComponent();
+
return waitForPromises();
});
@@ -214,6 +256,34 @@ describe('DependencyProxyApp', () => {
fullPath: provideDefaults.groupPath,
});
});
+
+ it('shows the clear cache dropdown list', () => {
+ expect(findClearCacheDropdownList().exists()).toBe(true);
+
+ const clearCacheDropdownItem = findClearCacheDropdownList().findComponent(
+ GlDropdownItem,
+ );
+
+ expect(clearCacheDropdownItem.text()).toBe('Clear cache');
+ });
+
+ it('shows the clear cache confirmation modal', () => {
+ const modal = findClearCacheModal();
+
+ expect(modal.find('.modal-title').text()).toContain('Clear 2 images from cache?');
+ expect(modal.props('actionPrimary').text).toBe('Clear cache');
+ });
+
+ it('submits the clear cache request', async () => {
+ findClearCacheModal().vm.$emit('primary', { preventDefault: jest.fn() });
+
+ await waitForPromises();
+
+ expect(findClearCacheAlert().exists()).toBe(true);
+ expect(findClearCacheAlert().text()).toBe(
+ 'All items in the cache are scheduled for removal.',
+ );
+ });
});
});
});
diff --git a/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_header_spec.js b/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_header_spec.js
new file mode 100644
index 00000000000..636f3eeb04a
--- /dev/null
+++ b/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_header_spec.js
@@ -0,0 +1,88 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlSprintf } from '@gitlab/ui';
+import { nextTick } from 'vue';
+import TitleArea from '~/vue_shared/components/registry/title_area.vue';
+import HarborListHeader from '~/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue';
+import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
+import {
+ HARBOR_REGISTRY_TITLE,
+ LIST_INTRO_TEXT,
+} from '~/packages_and_registries/harbor_registry/constants/index';
+
+describe('harbor_list_header', () => {
+ let wrapper;
+
+ const findTitleArea = () => wrapper.find(TitleArea);
+ const findCommandsSlot = () => wrapper.find('[data-testid="commands-slot"]');
+ const findImagesMetaDataItem = () => wrapper.find(MetadataItem);
+
+ const mountComponent = async (propsData, slots) => {
+ wrapper = shallowMount(HarborListHeader, {
+ stubs: {
+ GlSprintf,
+ TitleArea,
+ },
+ propsData,
+ slots,
+ });
+ await nextTick();
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('header', () => {
+ it('has a title', () => {
+ mountComponent({ metadataLoading: true });
+
+ expect(findTitleArea().props()).toMatchObject({
+ title: HARBOR_REGISTRY_TITLE,
+ metadataLoading: true,
+ });
+ });
+
+ it('has a commands slot', () => {
+ mountComponent(null, { commands: '<div data-testid="commands-slot">baz</div>' });
+
+ expect(findCommandsSlot().text()).toBe('baz');
+ });
+
+ describe('sub header parts', () => {
+ describe('images count', () => {
+ it('exists', async () => {
+ await mountComponent({ imagesCount: 1 });
+
+ expect(findImagesMetaDataItem().exists()).toBe(true);
+ });
+
+ it('when there is one image', async () => {
+ await mountComponent({ imagesCount: 1 });
+
+ expect(findImagesMetaDataItem().props()).toMatchObject({
+ text: '1 Image repository',
+ icon: 'container-image',
+ });
+ });
+
+ it('when there is more than one image', async () => {
+ await mountComponent({ imagesCount: 3 });
+
+ expect(findImagesMetaDataItem().props('text')).toBe('3 Image repositories');
+ });
+ });
+ });
+ });
+
+ describe('info messages', () => {
+ describe('default message', () => {
+ it('is correctly bound to title_area props', () => {
+ mountComponent({ helpPagePath: 'foo' });
+
+ expect(findTitleArea().props('infoMessages')).toEqual([
+ { text: LIST_INTRO_TEXT, link: 'foo' },
+ ]);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_row_spec.js b/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_row_spec.js
new file mode 100644
index 00000000000..8560c4f78f7
--- /dev/null
+++ b/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_row_spec.js
@@ -0,0 +1,99 @@
+import { shallowMount, RouterLinkStub as RouterLink } from '@vue/test-utils';
+import { GlIcon, GlSprintf, GlSkeletonLoader } from '@gitlab/ui';
+
+import HarborListRow from '~/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue';
+import ListItem from '~/vue_shared/components/registry/list_item.vue';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import { harborListResponse } from '../../mock_data';
+
+describe('Harbor List Row', () => {
+ let wrapper;
+ const [item] = harborListResponse.repositories;
+
+ const findDetailsLink = () => wrapper.find(RouterLink);
+ const findClipboardButton = () => wrapper.findComponent(ClipboardButton);
+ const findTagsCount = () => wrapper.find('[data-testid="tags-count"]');
+ const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
+
+ const mountComponent = (props) => {
+ wrapper = shallowMount(HarborListRow, {
+ stubs: {
+ RouterLink,
+ GlSprintf,
+ ListItem,
+ },
+ propsData: {
+ item,
+ ...props,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('image title and path', () => {
+ it('contains a link to the details page', () => {
+ mountComponent();
+
+ const link = findDetailsLink();
+ expect(link.text()).toBe(item.name);
+ expect(findDetailsLink().props('to')).toMatchObject({
+ name: 'details',
+ params: {
+ id: item.id,
+ },
+ });
+ });
+
+ it('contains a clipboard button', () => {
+ mountComponent();
+ const button = findClipboardButton();
+ expect(button.exists()).toBe(true);
+ expect(button.props('text')).toBe(item.location);
+ expect(button.props('title')).toBe(item.location);
+ });
+ });
+
+ describe('tags count', () => {
+ it('exists', () => {
+ mountComponent();
+ expect(findTagsCount().exists()).toBe(true);
+ });
+
+ it('contains a tag icon', () => {
+ mountComponent();
+ const icon = findTagsCount().find(GlIcon);
+ expect(icon.exists()).toBe(true);
+ expect(icon.props('name')).toBe('tag');
+ });
+
+ describe('loading state', () => {
+ it('shows a loader when metadataLoading is true', () => {
+ mountComponent({ metadataLoading: true });
+
+ expect(findSkeletonLoader().exists()).toBe(true);
+ });
+
+ it('hides the tags count while loading', () => {
+ mountComponent({ metadataLoading: true });
+
+ expect(findTagsCount().exists()).toBe(false);
+ });
+ });
+
+ describe('tags count text', () => {
+ it('with one tag in the image', () => {
+ mountComponent({ item: { ...item, artifactCount: 1 } });
+
+ expect(findTagsCount().text()).toMatchInterpolatedText('1 Tag');
+ });
+ it('with more than one tag in the image', () => {
+ mountComponent({ item: { ...item, artifactCount: 3 } });
+
+ expect(findTagsCount().text()).toMatchInterpolatedText('3 Tags');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_spec.js b/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_spec.js
new file mode 100644
index 00000000000..f018eff58c9
--- /dev/null
+++ b/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_spec.js
@@ -0,0 +1,39 @@
+import { shallowMount } from '@vue/test-utils';
+import HarborList from '~/packages_and_registries/harbor_registry/components/list/harbor_list.vue';
+import HarborListRow from '~/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue';
+import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
+import { harborListResponse } from '../../mock_data';
+
+describe('Harbor List', () => {
+ let wrapper;
+
+ const findHarborListRow = () => wrapper.findAll(HarborListRow);
+
+ const mountComponent = (props) => {
+ wrapper = shallowMount(HarborList, {
+ stubs: { RegistryList },
+ propsData: {
+ images: harborListResponse.repositories,
+ pageInfo: harborListResponse.pageInfo,
+ ...props,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('list', () => {
+ it('contains one list element for each image', () => {
+ mountComponent();
+
+ expect(findHarborListRow().length).toBe(harborListResponse.repositories.length);
+ });
+
+ it('passes down the metadataLoading prop', () => {
+ mountComponent({ metadataLoading: true });
+ expect(findHarborListRow().at(0).props('metadataLoading')).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/harbor_registry/mock_data.js b/spec/frontend/packages_and_registries/harbor_registry/mock_data.js
new file mode 100644
index 00000000000..85399c22e79
--- /dev/null
+++ b/spec/frontend/packages_and_registries/harbor_registry/mock_data.js
@@ -0,0 +1,175 @@
+export const harborListResponse = {
+ repositories: [
+ {
+ artifactCount: 1,
+ creationTime: '2022-03-02T06:35:53.205Z',
+ id: 25,
+ name: 'shao/flinkx',
+ projectId: 21,
+ pullCount: 0,
+ updateTime: '2022-03-02T06:35:53.205Z',
+ location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas',
+ },
+ {
+ artifactCount: 1,
+ creationTime: '2022-03-02T06:35:53.205Z',
+ id: 26,
+ name: 'shao/flinkx1',
+ projectId: 21,
+ pullCount: 0,
+ updateTime: '2022-03-02T06:35:53.205Z',
+ location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas',
+ },
+ {
+ artifactCount: 1,
+ creationTime: '2022-03-02T06:35:53.205Z',
+ id: 27,
+ name: 'shao/flinkx2',
+ projectId: 21,
+ pullCount: 0,
+ updateTime: '2022-03-02T06:35:53.205Z',
+ location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas',
+ },
+ ],
+ totalCount: 3,
+ pageInfo: {
+ hasNextPage: false,
+ hasPreviousPage: false,
+ },
+};
+
+export const harborTagsResponse = {
+ tags: [
+ {
+ digest: 'sha256:7f386a1844faf341353e1c20f2f39f11f397604fedc475435d13f756eeb235d1',
+ location:
+ 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:02310e655103823920157bc4410ea361dc638bc2cda59667d2cb1f2a988e264c',
+ path:
+ 'gitlab-org/gitlab/gitlab-ee-qa/cache:02310e655103823920157bc4410ea361dc638bc2cda59667d2cb1f2a988e264c',
+ name: '02310e655103823920157bc4410ea361dc638bc2cda59667d2cb1f2a988e264c',
+ revision: 'f53bde3d44699e04e11cf15fb415046a0913e2623d878d89bc21adb2cbda5255',
+ shortRevision: 'f53bde3d4',
+ createdAt: '2022-03-02T23:59:05+00:00',
+ totalSize: '6623124',
+ },
+ {
+ digest: 'sha256:4554416b84c4568fe93086620b637064ed029737aabe7308b96d50e3d9d92ed7',
+ location:
+ 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:02deb4dddf177212b50e883d5e4f6c03731fad1a18cd27261736cd9dbba79160',
+ path:
+ 'gitlab-org/gitlab/gitlab-ee-qa/cache:02deb4dddf177212b50e883d5e4f6c03731fad1a18cd27261736cd9dbba79160',
+ name: '02deb4dddf177212b50e883d5e4f6c03731fad1a18cd27261736cd9dbba79160',
+ revision: 'e1fe52d8bab66d71bd54a6b8784d3b9edbc68adbd6ea87f5fa44d9974144ef9e',
+ shortRevision: 'e1fe52d8b',
+ createdAt: '2022-02-10T01:09:56+00:00',
+ totalSize: '920760',
+ },
+ {
+ digest: 'sha256:14f37b60e52b9ce0e9f8f7094b311d265384798592f783487c30aaa3d58e6345',
+ location:
+ 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:03bc5971bab1e849ba52a20a31e7273053f22b2ddb1d04bd6b77d53a2635727a',
+ path:
+ 'gitlab-org/gitlab/gitlab-ee-qa/cache:03bc5971bab1e849ba52a20a31e7273053f22b2ddb1d04bd6b77d53a2635727a',
+ name: '03bc5971bab1e849ba52a20a31e7273053f22b2ddb1d04bd6b77d53a2635727a',
+ revision: 'c72770c6eb93c421bc496964b4bffc742b1ec2e642cdab876be7afda1856029f',
+ shortRevision: 'c72770c6e',
+ createdAt: '2021-12-22T04:48:48+00:00',
+ totalSize: '48609053',
+ },
+ {
+ digest: 'sha256:e925e3b8277ea23f387ed5fba5e78280cfac7cfb261a78cf046becf7b6a3faae',
+ location:
+ 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:03f495bc5714bff78bb14293320d336afdf47fd47ddff0c3c5f09f8da86d5d19',
+ path:
+ 'gitlab-org/gitlab/gitlab-ee-qa/cache:03f495bc5714bff78bb14293320d336afdf47fd47ddff0c3c5f09f8da86d5d19',
+ name: '03f495bc5714bff78bb14293320d336afdf47fd47ddff0c3c5f09f8da86d5d19',
+ revision: '1ac2a43194f4e15166abdf3f26e6ec92215240490b9cac834d63de1a3d87494a',
+ shortRevision: '1ac2a4319',
+ createdAt: '2022-03-09T11:02:27+00:00',
+ totalSize: '35141894',
+ },
+ {
+ digest: 'sha256:7d8303fd5c077787a8c879f8f66b69e2b5605f48ccd3f286e236fb0749fcc1ca',
+ location:
+ 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:05a4e58231e54b70aab2d6f22ba4fbe10e48aa4ddcbfef11c5662241c2ae4fda',
+ path:
+ 'gitlab-org/gitlab/gitlab-ee-qa/cache:05a4e58231e54b70aab2d6f22ba4fbe10e48aa4ddcbfef11c5662241c2ae4fda',
+ name: '05a4e58231e54b70aab2d6f22ba4fbe10e48aa4ddcbfef11c5662241c2ae4fda',
+ revision: 'cf8fee086701016e1a84e6824f0c896951fef4cce9d4745459558b87eec3232c',
+ shortRevision: 'cf8fee086',
+ createdAt: '2022-01-21T11:31:43+00:00',
+ totalSize: '48716070',
+ },
+ {
+ digest: 'sha256:b33611cefe20e4a41a6e0dce356a5d7ef3c177ea7536a58652f5b3a4f2f83549',
+ location:
+ 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:093d2746876997723541aec8b88687a4cdb3b5bbb0279c5089b7891317741a9a',
+ path:
+ 'gitlab-org/gitlab/gitlab-ee-qa/cache:093d2746876997723541aec8b88687a4cdb3b5bbb0279c5089b7891317741a9a',
+ name: '093d2746876997723541aec8b88687a4cdb3b5bbb0279c5089b7891317741a9a',
+ revision: '1a4b48198b13d55242c5164e64d41c4e9f75b5d9506bc6e0efc1534dd0dd1f15',
+ shortRevision: '1a4b48198',
+ createdAt: '2022-01-21T11:31:51+00:00',
+ totalSize: '6623127',
+ },
+ {
+ digest: 'sha256:d25c3c020e2dbd4711a67b9fe308f4cbb7b0bb21815e722a02f91c570dc5d519',
+ location:
+ 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:09698b3fae81dfd6e02554dbc82930f304a6356c8f541c80e8598a42aed985f7',
+ path:
+ 'gitlab-org/gitlab/gitlab-ee-qa/cache:09698b3fae81dfd6e02554dbc82930f304a6356c8f541c80e8598a42aed985f7',
+ name: '09698b3fae81dfd6e02554dbc82930f304a6356c8f541c80e8598a42aed985f7',
+ revision: '03e2e2777dde01c30469ee8c710973dd08a7a4f70494d7dc1583c24b525d7f61',
+ shortRevision: '03e2e2777',
+ createdAt: '2022-03-02T23:58:20+00:00',
+ totalSize: '911377',
+ },
+ {
+ digest: 'sha256:fb760e4d2184e9e8e39d6917534d4610fe01009734698a5653b2de1391ba28f4',
+ location:
+ 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:09b830c3eaf80d547f3b523d8e242a2c411085c349dab86c520f36c7b7644f95',
+ path:
+ 'gitlab-org/gitlab/gitlab-ee-qa/cache:09b830c3eaf80d547f3b523d8e242a2c411085c349dab86c520f36c7b7644f95',
+ name: '09b830c3eaf80d547f3b523d8e242a2c411085c349dab86c520f36c7b7644f95',
+ revision: '350e78d60646bf6967244448c6aaa14d21ecb9a0c6cf87e9ff0361cbe59b9012',
+ shortRevision: '350e78d60',
+ createdAt: '2022-01-19T13:49:14+00:00',
+ totalSize: '48710241',
+ },
+ {
+ digest: 'sha256:407250f380cea92729cbc038c420e74900f53b852e11edc6404fe75a0fd2c402',
+ location:
+ 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:0d03504a17b467eafc8c96bde70af26c74bd459a32b7eb2dd189dd6b3c121557',
+ path:
+ 'gitlab-org/gitlab/gitlab-ee-qa/cache:0d03504a17b467eafc8c96bde70af26c74bd459a32b7eb2dd189dd6b3c121557',
+ name: '0d03504a17b467eafc8c96bde70af26c74bd459a32b7eb2dd189dd6b3c121557',
+ revision: '76038370b7f3904364891457c4a6a234897255e6b9f45d0a852bf3a7e5257e18',
+ shortRevision: '76038370b',
+ createdAt: '2022-01-24T12:56:22+00:00',
+ totalSize: '280065',
+ },
+ {
+ digest: 'sha256:ada87f25218542951ce6720c27f3d0758e90c2540bd129f5cfb9e15b31e07b07',
+ location:
+ 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:0eb20a4a7cac2ebea821d420b3279654fe550fd8502f1785c1927aa84e5949eb',
+ path:
+ 'gitlab-org/gitlab/gitlab-ee-qa/cache:0eb20a4a7cac2ebea821d420b3279654fe550fd8502f1785c1927aa84e5949eb',
+ name: '0eb20a4a7cac2ebea821d420b3279654fe550fd8502f1785c1927aa84e5949eb',
+ revision: '3d4b49a7bbb36c48bb721f4d0e76e7950bec3878ee29cdfdd6da39f575d6d37f',
+ shortRevision: '3d4b49a7b',
+ createdAt: '2022-02-17T17:37:52+00:00',
+ totalSize: '48655767',
+ },
+ ],
+ totalCount: 100,
+ pageInfo: {
+ hasNextPage: false,
+ hasPreviousPage: false,
+ },
+};
+
+export const dockerCommands = {
+ dockerBuildCommand: 'foofoo',
+ dockerPushCommand: 'barbar',
+ dockerLoginCommand: 'bazbaz',
+};
diff --git a/spec/frontend/packages_and_registries/harbor_registry/pages/index_spec.js b/spec/frontend/packages_and_registries/harbor_registry/pages/index_spec.js
new file mode 100644
index 00000000000..55fc8066f65
--- /dev/null
+++ b/spec/frontend/packages_and_registries/harbor_registry/pages/index_spec.js
@@ -0,0 +1,24 @@
+import { shallowMount } from '@vue/test-utils';
+import component from '~/packages_and_registries/harbor_registry/pages/index.vue';
+
+describe('List Page', () => {
+ let wrapper;
+
+ const findRouterView = () => wrapper.find({ ref: 'router-view' });
+
+ const mountComponent = () => {
+ wrapper = shallowMount(component, {
+ stubs: {
+ RouterView: true,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it('has a router view', () => {
+ expect(findRouterView().exists()).toBe(true);
+ });
+});
diff --git a/spec/frontend/packages_and_registries/harbor_registry/pages/list_spec.js b/spec/frontend/packages_and_registries/harbor_registry/pages/list_spec.js
new file mode 100644
index 00000000000..61ee36a2794
--- /dev/null
+++ b/spec/frontend/packages_and_registries/harbor_registry/pages/list_spec.js
@@ -0,0 +1,140 @@
+import { shallowMount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import { GlSkeletonLoader } from '@gitlab/ui';
+import HarborListHeader from '~/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue';
+import HarborRegistryList from '~/packages_and_registries/harbor_registry/pages/list.vue';
+import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue';
+import waitForPromises from 'helpers/wait_for_promises';
+// import { harborListResponse } from '~/packages_and_registries/harbor_registry/mock_api.js';
+import HarborList from '~/packages_and_registries/harbor_registry/components/list/harbor_list.vue';
+import CliCommands from '~/packages_and_registries/shared/components/cli_commands.vue';
+import { SORT_FIELDS } from '~/packages_and_registries/harbor_registry/constants/index';
+import { harborListResponse, dockerCommands } from '../mock_data';
+
+let mockHarborListResponse;
+jest.mock('~/packages_and_registries/harbor_registry/mock_api.js', () => ({
+ harborListResponse: () => mockHarborListResponse,
+}));
+
+describe('Harbor List Page', () => {
+ let wrapper;
+
+ const waitForHarborPageRequest = async () => {
+ await waitForPromises();
+ await nextTick();
+ };
+
+ beforeEach(() => {
+ mockHarborListResponse = Promise.resolve(harborListResponse);
+ });
+
+ const findHarborListHeader = () => wrapper.findComponent(HarborListHeader);
+ const findPersistedSearch = () => wrapper.findComponent(PersistedSearch);
+ const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
+ const findHarborList = () => wrapper.findComponent(HarborList);
+ const findCliCommands = () => wrapper.findComponent(CliCommands);
+
+ const fireFirstSortUpdate = () => {
+ findPersistedSearch().vm.$emit('update', { sort: 'UPDATED_DESC', filters: [] });
+ };
+
+ const mountComponent = ({ config = { isGroupPage: false } } = {}) => {
+ wrapper = shallowMount(HarborRegistryList, {
+ stubs: {
+ HarborListHeader,
+ },
+ provide() {
+ return {
+ config,
+ ...dockerCommands,
+ };
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('contains harbor registry header', async () => {
+ mountComponent();
+ fireFirstSortUpdate();
+ await waitForHarborPageRequest();
+ await nextTick();
+
+ expect(findHarborListHeader().exists()).toBe(true);
+ expect(findHarborListHeader().props()).toMatchObject({
+ imagesCount: 3,
+ metadataLoading: false,
+ });
+ });
+
+ describe('isLoading is true', () => {
+ it('shows the skeleton loader', async () => {
+ mountComponent();
+ fireFirstSortUpdate();
+
+ expect(findSkeletonLoader().exists()).toBe(true);
+ });
+
+ it('harborList is not visible', () => {
+ mountComponent();
+
+ expect(findHarborList().exists()).toBe(false);
+ });
+
+ it('cli commands is not visible', () => {
+ mountComponent();
+
+ expect(findCliCommands().exists()).toBe(false);
+ });
+
+ it('title has the metadataLoading props set to true', async () => {
+ mountComponent();
+ fireFirstSortUpdate();
+
+ expect(findHarborListHeader().props('metadataLoading')).toBe(true);
+ });
+ });
+
+ describe('list is not empty', () => {
+ describe('unfiltered state', () => {
+ it('quick start is visible', async () => {
+ mountComponent();
+ fireFirstSortUpdate();
+
+ await waitForHarborPageRequest();
+ await nextTick();
+
+ expect(findCliCommands().exists()).toBe(true);
+ });
+
+ it('list component is visible', async () => {
+ mountComponent();
+ fireFirstSortUpdate();
+
+ await waitForHarborPageRequest();
+ await nextTick();
+
+ expect(findHarborList().exists()).toBe(true);
+ });
+ });
+
+ describe('search and sorting', () => {
+ it('has a persisted search box element', async () => {
+ mountComponent();
+ fireFirstSortUpdate();
+ await waitForHarborPageRequest();
+ await nextTick();
+
+ const harborRegistrySearch = findPersistedSearch();
+ expect(harborRegistrySearch.exists()).toBe(true);
+ expect(harborRegistrySearch.props()).toMatchObject({
+ defaultOrder: 'UPDATED',
+ defaultSort: 'desc',
+ sortableFields: SORT_FIELDS,
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/actions_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/actions_spec.js
index b9383d6c38c..31ab108558c 100644
--- a/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/actions_spec.js
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/details/store/actions_spec.js
@@ -20,10 +20,10 @@ jest.mock('~/api.js');
describe('Actions Package details store', () => {
describe('fetchPackageVersions', () => {
- it('should fetch the package versions', (done) => {
+ it('should fetch the package versions', async () => {
Api.projectPackage = jest.fn().mockResolvedValue({ data: packageEntity });
- testAction(
+ await testAction(
fetchPackageVersions,
undefined,
{ packageEntity },
@@ -33,20 +33,14 @@ describe('Actions Package details store', () => {
{ type: types.SET_LOADING, payload: false },
],
[],
- () => {
- expect(Api.projectPackage).toHaveBeenCalledWith(
- packageEntity.project_id,
- packageEntity.id,
- );
- done();
- },
);
+ expect(Api.projectPackage).toHaveBeenCalledWith(packageEntity.project_id, packageEntity.id);
});
- it("does not set the versions if they don't exist", (done) => {
+ it("does not set the versions if they don't exist", async () => {
Api.projectPackage = jest.fn().mockResolvedValue({ data: { packageEntity, versions: null } });
- testAction(
+ await testAction(
fetchPackageVersions,
undefined,
{ packageEntity },
@@ -55,20 +49,14 @@ describe('Actions Package details store', () => {
{ type: types.SET_LOADING, payload: false },
],
[],
- () => {
- expect(Api.projectPackage).toHaveBeenCalledWith(
- packageEntity.project_id,
- packageEntity.id,
- );
- done();
- },
);
+ expect(Api.projectPackage).toHaveBeenCalledWith(packageEntity.project_id, packageEntity.id);
});
- it('should create flash on API error', (done) => {
+ it('should create flash on API error', async () => {
Api.projectPackage = jest.fn().mockRejectedValue();
- testAction(
+ await testAction(
fetchPackageVersions,
undefined,
{ packageEntity },
@@ -77,41 +65,31 @@ describe('Actions Package details store', () => {
{ type: types.SET_LOADING, payload: false },
],
[],
- () => {
- expect(Api.projectPackage).toHaveBeenCalledWith(
- packageEntity.project_id,
- packageEntity.id,
- );
- expect(createFlash).toHaveBeenCalledWith({
- message: FETCH_PACKAGE_VERSIONS_ERROR,
- type: 'warning',
- });
- done();
- },
);
+ expect(Api.projectPackage).toHaveBeenCalledWith(packageEntity.project_id, packageEntity.id);
+ expect(createFlash).toHaveBeenCalledWith({
+ message: FETCH_PACKAGE_VERSIONS_ERROR,
+ type: 'warning',
+ });
});
});
describe('deletePackage', () => {
- it('should call Api.deleteProjectPackage', (done) => {
+ it('should call Api.deleteProjectPackage', async () => {
Api.deleteProjectPackage = jest.fn().mockResolvedValue();
- testAction(deletePackage, undefined, { packageEntity }, [], [], () => {
- expect(Api.deleteProjectPackage).toHaveBeenCalledWith(
- packageEntity.project_id,
- packageEntity.id,
- );
- done();
- });
+ await testAction(deletePackage, undefined, { packageEntity }, [], []);
+ expect(Api.deleteProjectPackage).toHaveBeenCalledWith(
+ packageEntity.project_id,
+ packageEntity.id,
+ );
});
- it('should create flash on API error', (done) => {
+ it('should create flash on API error', async () => {
Api.deleteProjectPackage = jest.fn().mockRejectedValue();
- testAction(deletePackage, undefined, { packageEntity }, [], [], () => {
- expect(createFlash).toHaveBeenCalledWith({
- message: DELETE_PACKAGE_ERROR_MESSAGE,
- type: 'warning',
- });
- done();
+ await testAction(deletePackage, undefined, { packageEntity }, [], []);
+ expect(createFlash).toHaveBeenCalledWith({
+ message: DELETE_PACKAGE_ERROR_MESSAGE,
+ type: 'warning',
});
});
});
@@ -119,37 +97,33 @@ describe('Actions Package details store', () => {
describe('deletePackageFile', () => {
const fileId = 'a_file_id';
- it('should call Api.deleteProjectPackageFile and commit the right data', (done) => {
+ it('should call Api.deleteProjectPackageFile and commit the right data', async () => {
const packageFiles = [{ id: 'foo' }, { id: fileId }];
Api.deleteProjectPackageFile = jest.fn().mockResolvedValue();
- testAction(
+ await testAction(
deletePackageFile,
fileId,
{ packageEntity, packageFiles },
[{ type: types.UPDATE_PACKAGE_FILES, payload: [{ id: 'foo' }] }],
[],
- () => {
- expect(Api.deleteProjectPackageFile).toHaveBeenCalledWith(
- packageEntity.project_id,
- packageEntity.id,
- fileId,
- );
- expect(createFlash).toHaveBeenCalledWith({
- message: DELETE_PACKAGE_FILE_SUCCESS_MESSAGE,
- type: 'success',
- });
- done();
- },
);
+ expect(Api.deleteProjectPackageFile).toHaveBeenCalledWith(
+ packageEntity.project_id,
+ packageEntity.id,
+ fileId,
+ );
+ expect(createFlash).toHaveBeenCalledWith({
+ message: DELETE_PACKAGE_FILE_SUCCESS_MESSAGE,
+ type: 'success',
+ });
});
- it('should create flash on API error', (done) => {
+
+ it('should create flash on API error', async () => {
Api.deleteProjectPackageFile = jest.fn().mockRejectedValue();
- testAction(deletePackageFile, fileId, { packageEntity }, [], [], () => {
- expect(createFlash).toHaveBeenCalledWith({
- message: DELETE_PACKAGE_FILE_ERROR_MESSAGE,
- type: 'warning',
- });
- done();
+ await testAction(deletePackageFile, fileId, { packageEntity }, [], []);
+ expect(createFlash).toHaveBeenCalledWith({
+ message: DELETE_PACKAGE_FILE_ERROR_MESSAGE,
+ type: 'warning',
});
});
});
diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap
index d82af8f9e63..a33528d2d91 100644
--- a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/__snapshots__/packages_list_app_spec.js.snap
@@ -21,7 +21,7 @@ exports[`packages_list_app renders 1`] = `
>
<img
alt=""
- class="gl-max-w-full"
+ class="gl-max-w-full gl-dark-invert-keep-hue"
role="img"
src="helpSvg"
/>
diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/stores/actions_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/stores/actions_spec.js
index 3fbfe1060dc..d596f2dae33 100644
--- a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/stores/actions_spec.js
+++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/stores/actions_spec.js
@@ -32,8 +32,8 @@ describe('Actions Package list store', () => {
};
const filter = [];
- it('should fetch the project packages list when isGroupPage is false', (done) => {
- testAction(
+ it('should fetch the project packages list when isGroupPage is false', async () => {
+ await testAction(
actions.requestPackagesList,
undefined,
{ config: { isGroupPage: false, resourceId: 1 }, sorting, filter },
@@ -43,17 +43,14 @@ describe('Actions Package list store', () => {
{ type: 'receivePackagesListSuccess', payload: { data: 'foo', headers } },
{ type: 'setLoading', payload: false },
],
- () => {
- expect(Api.projectPackages).toHaveBeenCalledWith(1, {
- params: { page: 1, per_page: 20, sort: sorting.sort, order_by: sorting.orderBy },
- });
- done();
- },
);
+ expect(Api.projectPackages).toHaveBeenCalledWith(1, {
+ params: { page: 1, per_page: 20, sort: sorting.sort, order_by: sorting.orderBy },
+ });
});
- it('should fetch the group packages list when isGroupPage is true', (done) => {
- testAction(
+ it('should fetch the group packages list when isGroupPage is true', async () => {
+ await testAction(
actions.requestPackagesList,
undefined,
{ config: { isGroupPage: true, resourceId: 2 }, sorting, filter },
@@ -63,19 +60,16 @@ describe('Actions Package list store', () => {
{ type: 'receivePackagesListSuccess', payload: { data: 'baz', headers } },
{ type: 'setLoading', payload: false },
],
- () => {
- expect(Api.groupPackages).toHaveBeenCalledWith(2, {
- params: { page: 1, per_page: 20, sort: sorting.sort, order_by: sorting.orderBy },
- });
- done();
- },
);
+ expect(Api.groupPackages).toHaveBeenCalledWith(2, {
+ params: { page: 1, per_page: 20, sort: sorting.sort, order_by: sorting.orderBy },
+ });
});
- it('should fetch packages of a certain type when a filter with a type is present', (done) => {
+ it('should fetch packages of a certain type when a filter with a type is present', async () => {
const packageType = 'maven';
- testAction(
+ await testAction(
actions.requestPackagesList,
undefined,
{
@@ -89,24 +83,21 @@ describe('Actions Package list store', () => {
{ type: 'receivePackagesListSuccess', payload: { data: 'foo', headers } },
{ type: 'setLoading', payload: false },
],
- () => {
- expect(Api.projectPackages).toHaveBeenCalledWith(1, {
- params: {
- page: 1,
- per_page: 20,
- sort: sorting.sort,
- order_by: sorting.orderBy,
- package_type: packageType,
- },
- });
- done();
- },
);
+ expect(Api.projectPackages).toHaveBeenCalledWith(1, {
+ params: {
+ page: 1,
+ per_page: 20,
+ sort: sorting.sort,
+ order_by: sorting.orderBy,
+ package_type: packageType,
+ },
+ });
});
- it('should create flash on API error', (done) => {
+ it('should create flash on API error', async () => {
Api.projectPackages = jest.fn().mockRejectedValue();
- testAction(
+ await testAction(
actions.requestPackagesList,
undefined,
{ config: { isGroupPage: false, resourceId: 2 }, sorting, filter },
@@ -115,15 +106,12 @@ describe('Actions Package list store', () => {
{ type: 'setLoading', payload: true },
{ type: 'setLoading', payload: false },
],
- () => {
- expect(createFlash).toHaveBeenCalled();
- done();
- },
);
+ expect(createFlash).toHaveBeenCalled();
});
- it('should force the terraform_module type when forceTerraform is true', (done) => {
- testAction(
+ it('should force the terraform_module type when forceTerraform is true', async () => {
+ await testAction(
actions.requestPackagesList,
undefined,
{ config: { isGroupPage: false, resourceId: 1, forceTerraform: true }, sorting, filter },
@@ -133,27 +121,24 @@ describe('Actions Package list store', () => {
{ type: 'receivePackagesListSuccess', payload: { data: 'foo', headers } },
{ type: 'setLoading', payload: false },
],
- () => {
- expect(Api.projectPackages).toHaveBeenCalledWith(1, {
- params: {
- page: 1,
- per_page: 20,
- sort: sorting.sort,
- order_by: sorting.orderBy,
- package_type: 'terraform_module',
- },
- });
- done();
- },
);
+ expect(Api.projectPackages).toHaveBeenCalledWith(1, {
+ params: {
+ page: 1,
+ per_page: 20,
+ sort: sorting.sort,
+ order_by: sorting.orderBy,
+ package_type: 'terraform_module',
+ },
+ });
});
});
describe('receivePackagesListSuccess', () => {
- it('should set received packages', (done) => {
+ it('should set received packages', () => {
const data = 'foo';
- testAction(
+ return testAction(
actions.receivePackagesListSuccess,
{ data, headers },
null,
@@ -162,33 +147,30 @@ describe('Actions Package list store', () => {
{ type: types.SET_PAGINATION, payload: headers },
],
[],
- done,
);
});
});
describe('setInitialState', () => {
- it('should commit setInitialState', (done) => {
- testAction(
+ it('should commit setInitialState', () => {
+ return testAction(
actions.setInitialState,
'1',
null,
[{ type: types.SET_INITIAL_STATE, payload: '1' }],
[],
- done,
);
});
});
describe('setLoading', () => {
- it('should commit set main loading', (done) => {
- testAction(
+ it('should commit set main loading', () => {
+ return testAction(
actions.setLoading,
true,
null,
[{ type: types.SET_MAIN_LOADING, payload: true }],
[],
- done,
);
});
});
@@ -199,11 +181,11 @@ describe('Actions Package list store', () => {
delete_api_path: 'foo',
},
};
- it('should perform a delete operation on _links.delete_api_path', (done) => {
+ it('should perform a delete operation on _links.delete_api_path', () => {
mock.onDelete(payload._links.delete_api_path).replyOnce(200);
Api.projectPackages = jest.fn().mockResolvedValue({ data: 'foo' });
- testAction(
+ return testAction(
actions.requestDeletePackage,
payload,
{ pagination: { page: 1 } },
@@ -212,13 +194,12 @@ describe('Actions Package list store', () => {
{ type: 'setLoading', payload: true },
{ type: 'requestPackagesList', payload: { page: 1 } },
],
- done,
);
});
- it('should stop the loading and call create flash on api error', (done) => {
+ it('should stop the loading and call create flash on api error', async () => {
mock.onDelete(payload._links.delete_api_path).replyOnce(400);
- testAction(
+ await testAction(
actions.requestDeletePackage,
payload,
null,
@@ -227,50 +208,44 @@ describe('Actions Package list store', () => {
{ type: 'setLoading', payload: true },
{ type: 'setLoading', payload: false },
],
- () => {
- expect(createFlash).toHaveBeenCalled();
- done();
- },
);
+ expect(createFlash).toHaveBeenCalled();
});
it.each`
property | actionPayload
${'_links'} | ${{}}
${'delete_api_path'} | ${{ _links: {} }}
- `('should reject and createFlash when $property is missing', ({ actionPayload }, done) => {
- testAction(actions.requestDeletePackage, actionPayload, null, [], []).catch((e) => {
+ `('should reject and createFlash when $property is missing', ({ actionPayload }) => {
+ return testAction(actions.requestDeletePackage, actionPayload, null, [], []).catch((e) => {
expect(e).toEqual(new Error(MISSING_DELETE_PATH_ERROR));
expect(createFlash).toHaveBeenCalledWith({
message: DELETE_PACKAGE_ERROR_MESSAGE,
});
- done();
});
});
});
describe('setSorting', () => {
- it('should commit SET_SORTING', (done) => {
- testAction(
+ it('should commit SET_SORTING', () => {
+ return testAction(
actions.setSorting,
'foo',
null,
[{ type: types.SET_SORTING, payload: 'foo' }],
[],
- done,
);
});
});
describe('setFilter', () => {
- it('should commit SET_FILTER', (done) => {
- testAction(
+ it('should commit SET_FILTER', () => {
+ return testAction(
actions.setFilter,
'foo',
null,
[{ type: types.SET_FILTER, payload: 'foo' }],
[],
- done,
);
});
});
diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js
index 9e91b15bc6e..3670cfca8ea 100644
--- a/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js
+++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js
@@ -73,7 +73,6 @@ describe('Package Search', () => {
mountComponent();
expect(findLocalStorageSync().props()).toMatchObject({
- asJson: true,
storageKey: 'package_registry_list_sorting',
value: {
orderBy: LIST_KEY_CREATED_AT,
diff --git a/spec/frontend/packages_and_registries/package_registry/pages/__snapshots__/list_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/pages/__snapshots__/list_spec.js.snap
index 0154486e224..17905a8db2d 100644
--- a/spec/frontend/packages_and_registries/package_registry/pages/__snapshots__/list_spec.js.snap
+++ b/spec/frontend/packages_and_registries/package_registry/pages/__snapshots__/list_spec.js.snap
@@ -21,7 +21,7 @@ exports[`PackagesListApp renders 1`] = `
>
<img
alt=""
- class="gl-max-w-full"
+ class="gl-max-w-full gl-dark-invert-keep-hue"
role="img"
src="emptyListIllustration"
/>
diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js
index a6c929844b1..0a72f0269ee 100644
--- a/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js
+++ b/spec/frontend/packages_and_registries/settings/project/settings/components/registry_settings_app_spec.js
@@ -12,7 +12,6 @@ import {
UNAVAILABLE_USER_FEATURE_TEXT,
} from '~/packages_and_registries/settings/project/constants';
import expirationPolicyQuery from '~/packages_and_registries/settings/project/graphql/queries/get_expiration_policy.query.graphql';
-import CleanupPolicyEnabledAlert from '~/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue';
import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue';
import {
@@ -31,12 +30,11 @@ describe('Registry Settings App', () => {
adminSettingsPath: 'settingsPath',
enableHistoricEntries: false,
helpPagePath: 'helpPagePath',
- showCleanupPolicyOnAlert: false,
+ showCleanupPolicyLink: false,
};
const findSettingsComponent = () => wrapper.find(SettingsForm);
const findAlert = () => wrapper.find(GlAlert);
- const findCleanupAlert = () => wrapper.findComponent(CleanupPolicyEnabledAlert);
const mountComponent = (provide = defaultProvidedValues, config) => {
wrapper = shallowMount(component, {
@@ -69,26 +67,6 @@ describe('Registry Settings App', () => {
wrapper.destroy();
});
- describe('cleanup is on alert', () => {
- it('exist when showCleanupPolicyOnAlert is true and has the correct props', () => {
- mountComponent({
- ...defaultProvidedValues,
- showCleanupPolicyOnAlert: true,
- });
-
- expect(findCleanupAlert().exists()).toBe(true);
- expect(findCleanupAlert().props()).toMatchObject({
- projectPath: 'path',
- });
- });
-
- it('is hidden when showCleanupPolicyOnAlert is false', async () => {
- mountComponent();
-
- expect(findCleanupAlert().exists()).toBe(false);
- });
- });
-
describe('isEdited status', () => {
it.each`
description | apiResponse | workingCopy | result
diff --git a/spec/frontend/packages_and_registries/shared/components/__snapshots__/cleanup_policy_enabled_alert_spec.js.snap b/spec/frontend/packages_and_registries/shared/components/__snapshots__/cleanup_policy_enabled_alert_spec.js.snap
deleted file mode 100644
index 2cded2ead2e..00000000000
--- a/spec/frontend/packages_and_registries/shared/components/__snapshots__/cleanup_policy_enabled_alert_spec.js.snap
+++ /dev/null
@@ -1,19 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`CleanupPolicyEnabledAlert renders 1`] = `
-<gl-alert-stub
- class="gl-mt-2"
- dismissible="true"
- dismisslabel="Dismiss"
- primarybuttonlink=""
- primarybuttontext=""
- secondarybuttonlink=""
- secondarybuttontext=""
- title=""
- variant="info"
->
- <gl-sprintf-stub
- message="Cleanup policies are now available for this project. %{linkStart}Click here to get started.%{linkEnd}"
- />
-</gl-alert-stub>
-`;
diff --git a/spec/frontend/packages_and_registries/shared/components/__snapshots__/registry_breadcrumb_spec.js.snap b/spec/frontend/packages_and_registries/shared/components/__snapshots__/registry_breadcrumb_spec.js.snap
index ceae8eebaef..3dd6023140f 100644
--- a/spec/frontend/packages_and_registries/shared/components/__snapshots__/registry_breadcrumb_spec.js.snap
+++ b/spec/frontend/packages_and_registries/shared/components/__snapshots__/registry_breadcrumb_spec.js.snap
@@ -10,11 +10,10 @@ exports[`Registry Breadcrumb when is not rootRoute renders 1`] = `
class="breadcrumb gl-breadcrumb-list"
>
<li
- class="breadcrumb-item gl-breadcrumb-item"
+ class="gl-breadcrumb-item"
>
<a
class=""
- href="/"
target="_self"
>
<span>
@@ -45,9 +44,10 @@ exports[`Registry Breadcrumb when is not rootRoute renders 1`] = `
<!---->
<li
- class="breadcrumb-item gl-breadcrumb-item"
+ class="gl-breadcrumb-item"
>
<a
+ aria-current="page"
class=""
href="#"
target="_self"
@@ -75,11 +75,11 @@ exports[`Registry Breadcrumb when is rootRoute renders 1`] = `
class="breadcrumb gl-breadcrumb-list"
>
<li
- class="breadcrumb-item gl-breadcrumb-item"
+ class="gl-breadcrumb-item"
>
<a
+ aria-current="page"
class=""
- href="/"
target="_self"
>
<span>
diff --git a/spec/frontend/packages_and_registries/shared/components/cleanup_policy_enabled_alert_spec.js b/spec/frontend/packages_and_registries/shared/components/cleanup_policy_enabled_alert_spec.js
deleted file mode 100644
index 269e087f5ac..00000000000
--- a/spec/frontend/packages_and_registries/shared/components/cleanup_policy_enabled_alert_spec.js
+++ /dev/null
@@ -1,49 +0,0 @@
-import { GlAlert } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import { nextTick } from 'vue';
-import component from '~/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue';
-import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
-
-describe('CleanupPolicyEnabledAlert', () => {
- let wrapper;
-
- const defaultProps = {
- projectPath: 'foo',
- cleanupPoliciesSettingsPath: 'label-bar',
- };
-
- const findAlert = () => wrapper.findComponent(GlAlert);
-
- const mountComponent = (props) => {
- wrapper = shallowMount(component, {
- stubs: {
- LocalStorageSync,
- },
- propsData: {
- ...defaultProps,
- ...props,
- },
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('renders', () => {
- mountComponent();
-
- expect(wrapper.element).toMatchSnapshot();
- });
-
- it('when dismissed is not visible', async () => {
- mountComponent();
-
- expect(findAlert().exists()).toBe(true);
- findAlert().vm.$emit('dismiss');
-
- await nextTick();
-
- expect(findAlert().exists()).toBe(false);
- });
-});
diff --git a/spec/frontend/packages_and_registries/shared/components/registry_breadcrumb_spec.js b/spec/frontend/packages_and_registries/shared/components/registry_breadcrumb_spec.js
index 6dfe116c285..15db454ac68 100644
--- a/spec/frontend/packages_and_registries/shared/components/registry_breadcrumb_spec.js
+++ b/spec/frontend/packages_and_registries/shared/components/registry_breadcrumb_spec.js
@@ -1,4 +1,4 @@
-import { mount } from '@vue/test-utils';
+import { mount, RouterLinkStub } from '@vue/test-utils';
import component from '~/packages_and_registries/shared/components/registry_breadcrumb.vue';
@@ -21,6 +21,9 @@ describe('Registry Breadcrumb', () => {
},
},
},
+ stubs: {
+ RouterLink: RouterLinkStub,
+ },
});
};
@@ -30,7 +33,6 @@ describe('Registry Breadcrumb', () => {
afterEach(() => {
wrapper.destroy();
- wrapper = null;
});
describe('when is rootRoute', () => {
@@ -46,7 +48,6 @@ describe('Registry Breadcrumb', () => {
const links = wrapper.findAll('a');
expect(links).toHaveLength(1);
- expect(links.at(0).attributes('href')).toBe('/');
});
it('the link text is calculated by nameGenerator', () => {
@@ -67,7 +68,6 @@ describe('Registry Breadcrumb', () => {
const links = wrapper.findAll('a');
expect(links).toHaveLength(2);
- expect(links.at(0).attributes('href')).toBe('/');
expect(links.at(1).attributes('href')).toBe('#');
});
diff --git a/spec/frontend/pager_spec.js b/spec/frontend/pager_spec.js
index 043ea470436..9df69124d66 100644
--- a/spec/frontend/pager_spec.js
+++ b/spec/frontend/pager_spec.js
@@ -68,34 +68,34 @@ describe('pager', () => {
it('shows loader while loading next page', async () => {
mockSuccess();
- jest.spyOn(Pager.loading, 'show').mockImplementation(() => {});
+ jest.spyOn(Pager.$loading, 'show').mockImplementation(() => {});
Pager.getOld();
await waitForPromises();
- expect(Pager.loading.show).toHaveBeenCalled();
+ expect(Pager.$loading.show).toHaveBeenCalled();
});
it('hides loader on success', async () => {
mockSuccess();
- jest.spyOn(Pager.loading, 'hide').mockImplementation(() => {});
+ jest.spyOn(Pager.$loading, 'hide').mockImplementation(() => {});
Pager.getOld();
await waitForPromises();
- expect(Pager.loading.hide).toHaveBeenCalled();
+ expect(Pager.$loading.hide).toHaveBeenCalled();
});
it('hides loader on error', async () => {
mockError();
- jest.spyOn(Pager.loading, 'hide').mockImplementation(() => {});
+ jest.spyOn(Pager.$loading, 'hide').mockImplementation(() => {});
Pager.getOld();
await waitForPromises();
- expect(Pager.loading.hide).toHaveBeenCalled();
+ expect(Pager.$loading.hide).toHaveBeenCalled();
});
it('sends request to url with offset and limit params', async () => {
@@ -122,12 +122,12 @@ describe('pager', () => {
Pager.limit = 20;
mockSuccess(1);
- jest.spyOn(Pager.loading, 'hide').mockImplementation(() => {});
+ jest.spyOn(Pager.$loading, 'hide').mockImplementation(() => {});
Pager.getOld();
await waitForPromises();
- expect(Pager.loading.hide).toHaveBeenCalled();
+ expect(Pager.$loading.hide).toHaveBeenCalled();
expect(Pager.disable).toBe(true);
});
@@ -175,5 +175,46 @@ describe('pager', () => {
expect(axios.get).toHaveBeenCalledWith(href, expect.any(Object));
});
});
+
+ describe('when `container` is passed', () => {
+ const href = '/some_list';
+ const container = '#js-pager';
+ let endlessScrollCallback;
+
+ beforeEach(() => {
+ jest.spyOn(axios, 'get');
+ jest.spyOn($.fn, 'endlessScroll').mockImplementation(({ callback }) => {
+ endlessScrollCallback = callback;
+ });
+ });
+
+ describe('when `container` is visible', () => {
+ it('makes API request', () => {
+ setFixtures(
+ `<div id="js-pager"><div class="content_list" data-href="${href}"></div></div>`,
+ );
+
+ Pager.init({ container });
+
+ endlessScrollCallback();
+
+ expect(axios.get).toHaveBeenCalledWith(href, expect.any(Object));
+ });
+ });
+
+ describe('when `container` is not visible', () => {
+ it('does not make API request', () => {
+ setFixtures(
+ `<div id="js-pager" style="display: none;"><div class="content_list" data-href="${href}"></div></div>`,
+ );
+
+ Pager.init({ container });
+
+ endlessScrollCallback();
+
+ expect(axios.get).not.toHaveBeenCalled();
+ });
+ });
+ });
});
});
diff --git a/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js b/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js
index 9f326dc33c0..3a4f93d4464 100644
--- a/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js
+++ b/spec/frontend/pages/admin/application_settings/account_and_limits_spec.js
@@ -23,13 +23,12 @@ describe('AccountAndLimits', () => {
expect($userInternalRegex.readOnly).toBeTruthy();
});
- it('is checked', (done) => {
+ it('is checked', () => {
if (!$userDefaultExternal.prop('checked')) $userDefaultExternal.click();
expect($userDefaultExternal.prop('checked')).toBeTruthy();
expect($userInternalRegex.placeholder).toEqual(PLACEHOLDER_USER_EXTERNAL_DEFAULT_TRUE);
expect($userInternalRegex.readOnly).toBeFalsy();
- done();
});
});
});
diff --git a/spec/frontend/pages/admin/jobs/index/components/stop_jobs_modal_spec.js b/spec/frontend/pages/admin/jobs/index/components/stop_jobs_modal_spec.js
index 52648d3ce00..ebf21c01324 100644
--- a/spec/frontend/pages/admin/jobs/index/components/stop_jobs_modal_spec.js
+++ b/spec/frontend/pages/admin/jobs/index/components/stop_jobs_modal_spec.js
@@ -26,7 +26,7 @@ describe('stop_jobs_modal.vue', () => {
});
describe('onSubmit', () => {
- it('stops jobs and redirects to overview page', (done) => {
+ it('stops jobs and redirects to overview page', async () => {
const responseURL = `${TEST_HOST}/stop_jobs_modal.vue/jobs`;
jest.spyOn(axios, 'post').mockImplementation((url) => {
expect(url).toBe(props.url);
@@ -37,29 +37,19 @@ describe('stop_jobs_modal.vue', () => {
});
});
- vm.onSubmit()
- .then(() => {
- expect(redirectTo).toHaveBeenCalledWith(responseURL);
- })
- .then(done)
- .catch(done.fail);
+ await vm.onSubmit();
+ expect(redirectTo).toHaveBeenCalledWith(responseURL);
});
- it('displays error if stopping jobs failed', (done) => {
+ it('displays error if stopping jobs failed', async () => {
const dummyError = new Error('stopping jobs failed');
jest.spyOn(axios, 'post').mockImplementation((url) => {
expect(url).toBe(props.url);
return Promise.reject(dummyError);
});
- vm.onSubmit()
- .then(done.fail)
- .catch((error) => {
- expect(error).toBe(dummyError);
- expect(redirectTo).not.toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
+ await expect(vm.onSubmit()).rejects.toEqual(dummyError);
+ expect(redirectTo).not.toHaveBeenCalled();
});
});
});
diff --git a/spec/frontend/pages/dashboard/todos/index/todos_spec.js b/spec/frontend/pages/dashboard/todos/index/todos_spec.js
index ef295e7d1ba..ae53afa7fba 100644
--- a/spec/frontend/pages/dashboard/todos/index/todos_spec.js
+++ b/spec/frontend/pages/dashboard/todos/index/todos_spec.js
@@ -31,15 +31,17 @@ describe('Todos', () => {
});
describe('goToTodoUrl', () => {
- it('opens the todo url', (done) => {
+ it('opens the todo url', () => {
const todoLink = todoItem.dataset.url;
+ let expectedUrl = null;
visitUrl.mockImplementation((url) => {
- expect(url).toEqual(todoLink);
- done();
+ expectedUrl = url;
});
todoItem.click();
+
+ expect(expectedUrl).toEqual(todoLink);
});
describe('meta click', () => {
diff --git a/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js b/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js
index 6fb03fa28fe..43c48617800 100644
--- a/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js
+++ b/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js
@@ -137,6 +137,16 @@ describe('BulkImportsHistoryApp', () => {
);
});
+ it('renders correct url for destination group when relative_url is empty', async () => {
+ mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS);
+ createComponent({ shallow: false });
+ await axios.waitForAll();
+
+ expect(wrapper.find('tbody tr a').attributes().href).toBe(
+ `/${DUMMY_RESPONSE[0].destination_namespace}/${DUMMY_RESPONSE[0].destination_name}`,
+ );
+ });
+
describe('details button', () => {
beforeEach(() => {
mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS);
diff --git a/spec/frontend/pages/import/history/components/import_error_details_spec.js b/spec/frontend/pages/import/history/components/import_error_details_spec.js
new file mode 100644
index 00000000000..4ff3f0361cf
--- /dev/null
+++ b/spec/frontend/pages/import/history/components/import_error_details_spec.js
@@ -0,0 +1,66 @@
+import { GlLoadingIcon } from '@gitlab/ui';
+import { mount, shallowMount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import ImportErrorDetails from '~/pages/import/history/components/import_error_details.vue';
+
+describe('ImportErrorDetails', () => {
+ const FAKE_ID = 5;
+ const API_URL = `/api/v4/projects/${FAKE_ID}`;
+
+ let wrapper;
+ let mock;
+
+ function createComponent({ shallow = true } = {}) {
+ const mountFn = shallow ? shallowMount : mount;
+ wrapper = mountFn(ImportErrorDetails, {
+ propsData: {
+ id: FAKE_ID,
+ },
+ });
+ }
+
+ const originalApiVersion = gon.api_version;
+ beforeAll(() => {
+ gon.api_version = 'v4';
+ });
+
+ afterAll(() => {
+ gon.api_version = originalApiVersion;
+ });
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ wrapper.destroy();
+ });
+
+ describe('general behavior', () => {
+ it('renders loading state when loading', () => {
+ createComponent();
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+ });
+
+ it('renders import_error if it is available', async () => {
+ const FAKE_IMPORT_ERROR = 'IMPORT ERROR';
+ mock.onGet(API_URL).reply(200, { import_error: FAKE_IMPORT_ERROR });
+ createComponent();
+ await axios.waitForAll();
+
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
+ expect(wrapper.find('pre').text()).toBe(FAKE_IMPORT_ERROR);
+ });
+
+ it('renders default text if error is not available', async () => {
+ mock.onGet(API_URL).reply(200, { import_error: null });
+ createComponent();
+ await axios.waitForAll();
+
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
+ expect(wrapper.find('pre').text()).toBe('No additional information provided.');
+ });
+ });
+});
diff --git a/spec/frontend/pages/import/history/components/import_history_app_spec.js b/spec/frontend/pages/import/history/components/import_history_app_spec.js
new file mode 100644
index 00000000000..0d821b114cf
--- /dev/null
+++ b/spec/frontend/pages/import/history/components/import_history_app_spec.js
@@ -0,0 +1,205 @@
+import { GlEmptyState, GlLoadingIcon, GlTable } from '@gitlab/ui';
+import { mount, shallowMount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import ImportErrorDetails from '~/pages/import/history/components/import_error_details.vue';
+import ImportHistoryApp from '~/pages/import/history/components/import_history_app.vue';
+import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import { stubComponent } from 'helpers/stub_component';
+
+describe('ImportHistoryApp', () => {
+ const API_URL = '/api/v4/projects.json';
+
+ const DEFAULT_HEADERS = {
+ 'x-page': 1,
+ 'x-per-page': 20,
+ 'x-next-page': 2,
+ 'x-total': 22,
+ 'x-total-pages': 2,
+ 'x-prev-page': null,
+ };
+ const DUMMY_RESPONSE = [
+ {
+ id: 1,
+ path_with_namespace: 'root/imported',
+ created_at: '2022-03-10T15:10:03.172Z',
+ import_url: null,
+ import_type: 'gitlab_project',
+ import_status: 'finished',
+ },
+ {
+ id: 2,
+ name_with_namespace: 'Administrator / Dummy',
+ path_with_namespace: 'root/dummy',
+ created_at: '2022-03-09T11:23:04.974Z',
+ import_url: 'https://dummy.github/url',
+ import_type: 'github',
+ import_status: 'failed',
+ },
+ {
+ id: 3,
+ name_with_namespace: 'Administrator / Dummy',
+ path_with_namespace: 'root/dummy2',
+ created_at: '2022-03-09T11:23:04.974Z',
+ import_url: 'git://non-http.url',
+ import_type: 'gi',
+ import_status: 'finished',
+ },
+ ];
+ let wrapper;
+ let mock;
+
+ function createComponent({ shallow = true } = {}) {
+ const mountFn = shallow ? shallowMount : mount;
+ wrapper = mountFn(ImportHistoryApp, {
+ provide: { assets: { gitlabLogo: 'http://dummy.host' } },
+ stubs: shallow ? { GlTable: { ...stubComponent(GlTable), props: ['items'] } } : {},
+ });
+ }
+
+ const originalApiVersion = gon.api_version;
+ beforeAll(() => {
+ gon.api_version = 'v4';
+ });
+
+ afterAll(() => {
+ gon.api_version = originalApiVersion;
+ });
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ wrapper.destroy();
+ });
+
+ describe('general behavior', () => {
+ it('renders loading state when loading', () => {
+ createComponent();
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+ });
+
+ it('renders empty state when no data is available', async () => {
+ mock.onGet(API_URL).reply(200, [], DEFAULT_HEADERS);
+ createComponent();
+ await axios.waitForAll();
+
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
+ expect(wrapper.find(GlEmptyState).exists()).toBe(true);
+ });
+
+ it('renders table with data when history is available', async () => {
+ mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS);
+ createComponent();
+ await axios.waitForAll();
+
+ const table = wrapper.find(GlTable);
+ expect(table.exists()).toBe(true);
+ expect(table.props().items).toStrictEqual(DUMMY_RESPONSE);
+ });
+
+ it('changes page when requested by pagination bar', async () => {
+ const NEW_PAGE = 4;
+
+ mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS);
+ createComponent();
+ await axios.waitForAll();
+ mock.resetHistory();
+
+ const FAKE_NEXT_PAGE_REPLY = [
+ {
+ id: 4,
+ path_with_namespace: 'root/some_other_project',
+ created_at: '2022-03-10T15:10:03.172Z',
+ import_url: null,
+ import_type: 'gitlab_project',
+ import_status: 'finished',
+ },
+ ];
+
+ mock.onGet(API_URL).reply(200, FAKE_NEXT_PAGE_REPLY, DEFAULT_HEADERS);
+
+ wrapper.findComponent(PaginationBar).vm.$emit('set-page', NEW_PAGE);
+ await axios.waitForAll();
+
+ expect(mock.history.get.length).toBe(1);
+ expect(mock.history.get[0].params).toStrictEqual(expect.objectContaining({ page: NEW_PAGE }));
+ expect(wrapper.find(GlTable).props().items).toStrictEqual(FAKE_NEXT_PAGE_REPLY);
+ });
+ });
+
+ it('changes page size when requested by pagination bar', async () => {
+ const NEW_PAGE_SIZE = 4;
+
+ mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS);
+ createComponent();
+ await axios.waitForAll();
+ mock.resetHistory();
+
+ wrapper.findComponent(PaginationBar).vm.$emit('set-page-size', NEW_PAGE_SIZE);
+ await axios.waitForAll();
+
+ expect(mock.history.get.length).toBe(1);
+ expect(mock.history.get[0].params).toStrictEqual(
+ expect.objectContaining({ per_page: NEW_PAGE_SIZE }),
+ );
+ });
+
+ it('resets page to 1 when page size is changed', async () => {
+ const NEW_PAGE_SIZE = 4;
+
+ mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS);
+ createComponent();
+ await axios.waitForAll();
+ wrapper.findComponent(PaginationBar).vm.$emit('set-page', 2);
+ await axios.waitForAll();
+ mock.resetHistory();
+
+ wrapper.findComponent(PaginationBar).vm.$emit('set-page-size', NEW_PAGE_SIZE);
+ await axios.waitForAll();
+
+ expect(mock.history.get.length).toBe(1);
+ expect(mock.history.get[0].params).toStrictEqual(
+ expect.objectContaining({ per_page: NEW_PAGE_SIZE, page: 1 }),
+ );
+ });
+
+ describe('details button', () => {
+ beforeEach(() => {
+ mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS);
+ createComponent({ shallow: false });
+ return axios.waitForAll();
+ });
+
+ it('renders details button if relevant item has failed', async () => {
+ expect(
+ extendedWrapper(wrapper.find('tbody').findAll('tr').at(1)).findByText('Details').exists(),
+ ).toBe(true);
+ });
+
+ it('does not render details button if relevant item does not failed', () => {
+ expect(
+ extendedWrapper(wrapper.find('tbody').findAll('tr').at(0)).findByText('Details').exists(),
+ ).toBe(false);
+ });
+
+ it('expands details when details button is clicked', async () => {
+ const ORIGINAL_ROW_INDEX = 1;
+ await extendedWrapper(wrapper.find('tbody').findAll('tr').at(ORIGINAL_ROW_INDEX))
+ .findByText('Details')
+ .trigger('click');
+
+ const detailsRowContent = wrapper
+ .find('tbody')
+ .findAll('tr')
+ .at(ORIGINAL_ROW_INDEX + 1)
+ .findComponent(ImportErrorDetails);
+
+ expect(detailsRowContent.exists()).toBe(true);
+ expect(detailsRowContent.props().id).toBe(DUMMY_RESPONSE[1].id);
+ });
+ });
+});
diff --git a/spec/frontend/pages/profiles/show/emoji_menu_spec.js b/spec/frontend/pages/profiles/show/emoji_menu_spec.js
index f35fb57aec7..fa6e7e51a60 100644
--- a/spec/frontend/pages/profiles/show/emoji_menu_spec.js
+++ b/spec/frontend/pages/profiles/show/emoji_menu_spec.js
@@ -46,22 +46,18 @@ describe('EmojiMenu', () => {
const dummyEmoji = 'tropical_fish';
const dummyVotesBlock = () => $('<div />');
- it('calls selectEmojiCallback', (done) => {
+ it('calls selectEmojiCallback', async () => {
expect(dummySelectEmojiCallback).not.toHaveBeenCalled();
- emojiMenu.addAward(dummyVotesBlock(), dummyAwardUrl, dummyEmoji, false, () => {
- expect(dummySelectEmojiCallback).toHaveBeenCalledWith(dummyEmoji, dummyEmojiTag);
- done();
- });
+ await emojiMenu.addAward(dummyVotesBlock(), dummyAwardUrl, dummyEmoji, false);
+ expect(dummySelectEmojiCallback).toHaveBeenCalledWith(dummyEmoji, dummyEmojiTag);
});
- it('does not make an axios request', (done) => {
+ it('does not make an axios request', async () => {
jest.spyOn(axios, 'request').mockReturnValue();
- emojiMenu.addAward(dummyVotesBlock(), dummyAwardUrl, dummyEmoji, false, () => {
- expect(axios.request).not.toHaveBeenCalled();
- done();
- });
+ await emojiMenu.addAward(dummyVotesBlock(), dummyAwardUrl, dummyEmoji, false);
+ expect(axios.request).not.toHaveBeenCalled();
});
});
diff --git a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_section_card_spec.js.snap b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_section_card_spec.js.snap
index 9e00ace761c..83feb621478 100644
--- a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_section_card_spec.js.snap
+++ b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_section_card_spec.js.snap
@@ -2,31 +2,26 @@
exports[`Learn GitLab Section Card renders correctly 1`] = `
<gl-card-stub
- bodyclass=""
- class="gl-pt-0 learn-gitlab-section-card"
+ bodyclass="gl-pt-0"
+ class="gl-pt-0 h-100"
footerclass=""
- headerclass=""
+ headerclass="gl-bg-white gl-border-0 gl-pb-0"
>
- <div
- class="learn-gitlab-section-card-header"
+ <img
+ src="workspace.svg"
+ />
+
+ <h2
+ class="gl-font-lg gl-mb-3"
>
- <img
- src="workspace.svg"
- />
-
- <h2
- class="gl-font-lg gl-mb-3"
- >
- Set up your workspace
- </h2>
-
- <p
- class="gl-text-gray-700 gl-mb-6"
- >
- Complete these tasks first so you can enjoy GitLab's features to their fullest:
- </p>
- </div>
+ Set up your workspace
+ </h2>
+ <p
+ class="gl-text-gray-700 gl-mb-6"
+ >
+ Complete these tasks first so you can enjoy GitLab's features to their fullest:
+ </p>
<learn-gitlab-section-link-stub
action="userAdded"
value="[object Object]"
diff --git a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap
index 62cf769cffd..269c7467c8b 100644
--- a/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap
+++ b/spec/frontend/pages/projects/learn_gitlab/components/__snapshots__/learn_gitlab_spec.js.snap
@@ -51,170 +51,204 @@ exports[`Learn GitLab renders correctly 1`] = `
</div>
<div
- class="row row-cols-1 row-cols-md-3 gl-mt-5"
+ class="row"
>
<div
- class="col gl-mb-6"
+ class="gl-mt-5 col-sm-12 col-mb-6 col-lg-4"
>
<div
- class="gl-card gl-pt-0 learn-gitlab-section-card"
+ class="gl-card gl-pt-0 h-100"
>
- <!---->
-
<div
- class="gl-card-body"
+ class="gl-card-header gl-bg-white gl-border-0 gl-pb-0"
>
- <div
- class="learn-gitlab-section-card-header"
+ <img
+ src="workspace.svg"
+ />
+
+ <h2
+ class="gl-font-lg gl-mb-3"
>
- <img
- src="workspace.svg"
- />
-
- <h2
- class="gl-font-lg gl-mb-3"
- >
- Set up your workspace
- </h2>
-
- <p
- class="gl-text-gray-700 gl-mb-6"
- >
- Complete these tasks first so you can enjoy GitLab's features to their fullest:
- </p>
- </div>
+ Set up your workspace
+ </h2>
+ <p
+ class="gl-text-gray-700 gl-mb-6"
+ >
+ Complete these tasks first so you can enjoy GitLab's features to their fullest:
+ </p>
+ </div>
+
+ <div
+ class="gl-card-body gl-pt-0"
+ >
<div
class="gl-mb-4"
>
- <span
- class="gl-text-green-500"
+ <!---->
+
+ <div
+ class="flex align-items-center"
>
- <svg
- aria-hidden="true"
- class="gl-icon s16"
- data-testid="completed-icon"
- role="img"
+ <span
+ class="gl-text-green-500"
>
- <use
- href="#check-circle-filled"
- />
- </svg>
-
- Invite your colleagues
-
- </span>
-
- <!---->
+ <svg
+ aria-hidden="true"
+ class="gl-icon s16"
+ data-testid="completed-icon"
+ role="img"
+ >
+ <use
+ href="#check-circle-filled"
+ />
+ </svg>
+
+ Invite your colleagues
+
+ </span>
+
+ <!---->
+ </div>
</div>
<div
class="gl-mb-4"
>
- <span
- class="gl-text-green-500"
+ <!---->
+
+ <div
+ class="flex align-items-center"
>
- <svg
- aria-hidden="true"
- class="gl-icon s16"
- data-testid="completed-icon"
- role="img"
+ <span
+ class="gl-text-green-500"
>
- <use
- href="#check-circle-filled"
- />
- </svg>
-
- Create or import a repository
-
- </span>
-
- <!---->
+ <svg
+ aria-hidden="true"
+ class="gl-icon s16"
+ data-testid="completed-icon"
+ role="img"
+ >
+ <use
+ href="#check-circle-filled"
+ />
+ </svg>
+
+ Create or import a repository
+
+ </span>
+
+ <!---->
+ </div>
</div>
<div
class="gl-mb-4"
>
- <a
- class="gl-link"
- data-testid="uncompleted-learn-gitlab-link"
- data-track-action="click_link"
- data-track-label="Set up CI/CD"
- href="http://example.com/"
- target="_self"
- >
-
- Set up CI/CD
-
- </a>
-
<!---->
+
+ <div
+ class="flex align-items-center"
+ >
+ <a
+ class="gl-link"
+ data-testid="uncompleted-learn-gitlab-link"
+ data-track-action="click_link"
+ data-track-label="Set up CI/CD"
+ href="http://example.com/"
+ target="_self"
+ >
+
+ Set up CI/CD
+
+ </a>
+
+ <!---->
+ </div>
</div>
<div
class="gl-mb-4"
>
- <a
- class="gl-link"
- data-testid="uncompleted-learn-gitlab-link"
- data-track-action="click_link"
- data-track-label="Start a free Ultimate trial"
- href="http://example.com/"
- target="_self"
- >
-
- Start a free Ultimate trial
-
- </a>
-
<!---->
+
+ <div
+ class="flex align-items-center"
+ >
+ <a
+ class="gl-link"
+ data-testid="uncompleted-learn-gitlab-link"
+ data-track-action="click_link"
+ data-track-label="Start a free Ultimate trial"
+ href="http://example.com/"
+ target="_self"
+ >
+
+ Start a free Ultimate trial
+
+ </a>
+
+ <!---->
+ </div>
</div>
<div
class="gl-mb-4"
>
- <a
- class="gl-link"
- data-testid="uncompleted-learn-gitlab-link"
- data-track-action="click_link"
- data-track-label="Add code owners"
- href="http://example.com/"
- target="_self"
- >
-
- Add code owners
-
- </a>
-
- <span
+ <div
class="gl-font-style-italic gl-text-gray-500"
data-testid="trial-only"
>
- - Trial only
+ Trial only
- </span>
+ </div>
+
+ <div
+ class="flex align-items-center"
+ >
+ <a
+ class="gl-link"
+ data-testid="uncompleted-learn-gitlab-link"
+ data-track-action="click_link"
+ data-track-label="Add code owners"
+ href="http://example.com/"
+ target="_self"
+ >
+
+ Add code owners
+
+ </a>
+
+ <!---->
+ </div>
</div>
<div
class="gl-mb-4"
>
- <a
- class="gl-link"
- data-testid="uncompleted-learn-gitlab-link"
- data-track-action="click_link"
- data-track-label="Add merge request approval"
- href="http://example.com/"
- target="_self"
- >
-
- Add merge request approval
-
- </a>
-
- <span
+ <div
class="gl-font-style-italic gl-text-gray-500"
data-testid="trial-only"
>
- - Trial only
+ Trial only
- </span>
+ </div>
+
+ <div
+ class="flex align-items-center"
+ >
+ <a
+ class="gl-link"
+ data-testid="uncompleted-learn-gitlab-link"
+ data-track-action="click_link"
+ data-track-label="Add merge request approval"
+ href="http://example.com/"
+ target="_self"
+ >
+
+ Add merge request approval
+
+ </a>
+
+ <!---->
+ </div>
</div>
</div>
@@ -222,71 +256,81 @@ exports[`Learn GitLab renders correctly 1`] = `
</div>
</div>
<div
- class="col gl-mb-6"
+ class="gl-mt-5 col-sm-12 col-mb-6 col-lg-4"
>
<div
- class="gl-card gl-pt-0 learn-gitlab-section-card"
+ class="gl-card gl-pt-0 h-100"
>
- <!---->
-
<div
- class="gl-card-body"
+ class="gl-card-header gl-bg-white gl-border-0 gl-pb-0"
>
- <div
- class="learn-gitlab-section-card-header"
+ <img
+ src="plan.svg"
+ />
+
+ <h2
+ class="gl-font-lg gl-mb-3"
>
- <img
- src="plan.svg"
- />
-
- <h2
- class="gl-font-lg gl-mb-3"
- >
- Plan and execute
- </h2>
-
- <p
- class="gl-text-gray-700 gl-mb-6"
- >
- Create a workflow for your new workspace, and learn how GitLab features work together:
- </p>
- </div>
+ Plan and execute
+ </h2>
+ <p
+ class="gl-text-gray-700 gl-mb-6"
+ >
+ Create a workflow for your new workspace, and learn how GitLab features work together:
+ </p>
+ </div>
+
+ <div
+ class="gl-card-body gl-pt-0"
+ >
<div
class="gl-mb-4"
>
- <a
- class="gl-link"
- data-testid="uncompleted-learn-gitlab-link"
- data-track-action="click_link"
- data-track-label="Create an issue"
- href="http://example.com/"
- target="_self"
- >
-
- Create an issue
-
- </a>
-
<!---->
+
+ <div
+ class="flex align-items-center"
+ >
+ <a
+ class="gl-link"
+ data-testid="uncompleted-learn-gitlab-link"
+ data-track-action="click_link"
+ data-track-label="Create an issue"
+ href="http://example.com/"
+ target="_self"
+ >
+
+ Create an issue
+
+ </a>
+
+ <!---->
+ </div>
</div>
<div
class="gl-mb-4"
>
- <a
- class="gl-link"
- data-testid="uncompleted-learn-gitlab-link"
- data-track-action="click_link"
- data-track-label="Submit a merge request"
- href="http://example.com/"
- target="_self"
- >
-
- Submit a merge request
-
- </a>
-
<!---->
+
+ <div
+ class="flex align-items-center"
+ >
+ <a
+ class="gl-link"
+ data-testid="uncompleted-learn-gitlab-link"
+ data-track-action="click_link"
+ data-track-label="Submit a merge request"
+ href="http://example.com/"
+ target="_self"
+ >
+
+ Submit a merge request
+
+ </a>
+
+ <!---->
+ </div>
</div>
</div>
@@ -294,54 +338,58 @@ exports[`Learn GitLab renders correctly 1`] = `
</div>
</div>
<div
- class="col gl-mb-6"
+ class="gl-mt-5 col-sm-12 col-mb-6 col-lg-4"
>
<div
- class="gl-card gl-pt-0 learn-gitlab-section-card"
+ class="gl-card gl-pt-0 h-100"
>
- <!---->
-
<div
- class="gl-card-body"
+ class="gl-card-header gl-bg-white gl-border-0 gl-pb-0"
>
- <div
- class="learn-gitlab-section-card-header"
+ <img
+ src="deploy.svg"
+ />
+
+ <h2
+ class="gl-font-lg gl-mb-3"
>
- <img
- src="deploy.svg"
- />
-
- <h2
- class="gl-font-lg gl-mb-3"
- >
- Deploy
- </h2>
-
- <p
- class="gl-text-gray-700 gl-mb-6"
- >
- Use your new GitLab workflow to deploy your application, monitor its health, and keep it secure:
- </p>
- </div>
+ Deploy
+ </h2>
+ <p
+ class="gl-text-gray-700 gl-mb-6"
+ >
+ Use your new GitLab workflow to deploy your application, monitor its health, and keep it secure:
+ </p>
+ </div>
+
+ <div
+ class="gl-card-body gl-pt-0"
+ >
<div
class="gl-mb-4"
>
- <a
- class="gl-link"
- data-testid="uncompleted-learn-gitlab-link"
- data-track-action="click_link"
- data-track-label="Run a Security scan using CI/CD"
- href="https://docs.gitlab.com/ee/foobar/"
- rel="noopener noreferrer"
- target="_blank"
- >
-
- Run a Security scan using CI/CD
-
- </a>
-
<!---->
+
+ <div
+ class="flex align-items-center"
+ >
+ <a
+ class="gl-link"
+ data-testid="uncompleted-learn-gitlab-link"
+ data-track-action="click_link"
+ data-track-label="Run a Security scan using CI/CD"
+ href="https://docs.gitlab.com/ee/foobar/"
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+
+ Run a Security scan using CI/CD
+
+ </a>
+
+ <!---->
+ </div>
</div>
</div>
diff --git a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js
index e21371123e8..b8ebf2a1430 100644
--- a/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js
+++ b/spec/frontend/pages/projects/learn_gitlab/components/learn_gitlab_section_link_spec.js
@@ -1,4 +1,4 @@
-import { shallowMount } from '@vue/test-utils';
+import { mount } from '@vue/test-utils';
import { stubExperiments } from 'helpers/experimentation_helper';
import { mockTracking, triggerEvent, unmockTracking } from 'helpers/tracking_helper';
import eventHub from '~/invite_members/event_hub';
@@ -26,7 +26,7 @@ describe('Learn GitLab Section Link', () => {
});
const createWrapper = (action = defaultAction, props = {}) => {
- wrapper = shallowMount(LearnGitlabSectionLink, {
+ wrapper = mount(LearnGitlabSectionLink, {
propsData: { action, value: { ...defaultProps, ...props } },
});
};
@@ -36,6 +36,8 @@ describe('Learn GitLab Section Link', () => {
const findUncompletedLink = () => wrapper.find('[data-testid="uncompleted-learn-gitlab-link"]');
+ const videoTutorialLink = () => wrapper.find('[data-testid="video-tutorial-link"]');
+
it('renders no icon when not completed', () => {
createWrapper(undefined, { completed: false });
@@ -130,4 +132,51 @@ describe('Learn GitLab Section Link', () => {
unmockTracking();
});
});
+
+ describe('video_tutorials_continuous_onboarding experiment', () => {
+ describe('when control', () => {
+ beforeEach(() => {
+ stubExperiments({ video_tutorials_continuous_onboarding: 'control' });
+ createWrapper('codeOwnersEnabled');
+ });
+
+ it('renders no video link', () => {
+ expect(videoTutorialLink().exists()).toBe(false);
+ });
+ });
+
+ describe('when candidate', () => {
+ beforeEach(() => {
+ stubExperiments({ video_tutorials_continuous_onboarding: 'candidate' });
+ createWrapper('codeOwnersEnabled');
+ });
+
+ it('renders video link with blank target', () => {
+ const videoLinkElement = videoTutorialLink();
+
+ expect(videoLinkElement.exists()).toBe(true);
+ expect(videoLinkElement.attributes('target')).toEqual('_blank');
+ });
+
+ it('tracks the click', () => {
+ const trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn);
+
+ videoTutorialLink().trigger('click');
+
+ expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_video_link', {
+ label: 'Add code owners',
+ property: 'Growth::Conversion::Experiment::LearnGitLab',
+ context: {
+ data: {
+ experiment: 'video_tutorials_continuous_onboarding',
+ variant: 'candidate',
+ },
+ schema: 'iglu:com.gitlab/gitlab_experiment/jsonschema/1-0-0',
+ },
+ });
+
+ unmockTracking();
+ });
+ });
+ });
});
diff --git a/spec/frontend/pages/projects/shared/permissions/components/project_feature_settings_spec.js b/spec/frontend/pages/projects/shared/permissions/components/project_feature_settings_spec.js
index 0fffcf433a3..5771e1b88e8 100644
--- a/spec/frontend/pages/projects/shared/permissions/components/project_feature_settings_spec.js
+++ b/spec/frontend/pages/projects/shared/permissions/components/project_feature_settings_spec.js
@@ -3,15 +3,17 @@ import { shallowMount } from '@vue/test-utils';
import ProjectFeatureSetting from '~/pages/projects/shared/permissions/components/project_feature_setting.vue';
describe('Project Feature Settings', () => {
+ const defaultOptions = [
+ [1, 1],
+ [2, 2],
+ [3, 3],
+ [4, 4],
+ [5, 5],
+ ];
+
const defaultProps = {
name: 'Test',
- options: [
- [1, 1],
- [2, 2],
- [3, 3],
- [4, 4],
- [5, 5],
- ],
+ options: defaultOptions,
value: 1,
disabledInput: false,
showToggle: true,
@@ -110,15 +112,25 @@ describe('Project Feature Settings', () => {
},
);
- it('should emit the change when a new option is selected', () => {
+ it('should emit the change when a new option is selected', async () => {
wrapper = mountComponent();
expect(wrapper.emitted('change')).toBeUndefined();
- wrapper.findAll('option').at(1).trigger('change');
+ await wrapper.findAll('option').at(1).setSelected();
expect(wrapper.emitted('change')).toHaveLength(1);
expect(wrapper.emitted('change')[0]).toEqual([2]);
});
+
+ it('value of select matches prop `value` if options are modified', async () => {
+ wrapper = mountComponent();
+
+ await wrapper.setProps({ value: 0, options: [[0, 0]] });
+ expect(wrapper.find('select').element.selectedIndex).toBe(0);
+
+ await wrapper.setProps({ value: 2, options: defaultOptions });
+ expect(wrapper.find('select').element.selectedIndex).toBe(1);
+ });
});
});
diff --git a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js
index 305dce51971..30d5f89d2f6 100644
--- a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js
+++ b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js
@@ -1,6 +1,6 @@
import { GlSprintf, GlToggle } from '@gitlab/ui';
import { shallowMount, mount } from '@vue/test-utils';
-import projectFeatureSetting from '~/pages/projects/shared/permissions/components/project_feature_setting.vue';
+import ProjectFeatureSetting from '~/pages/projects/shared/permissions/components/project_feature_setting.vue';
import settingsPanel from '~/pages/projects/shared/permissions/components/settings_panel.vue';
import {
featureAccessLevel,
@@ -21,6 +21,7 @@ const defaultProps = {
wikiAccessLevel: 20,
snippetsAccessLevel: 20,
operationsAccessLevel: 20,
+ metricsDashboardAccessLevel: 20,
pagesAccessLevel: 10,
analyticsAccessLevel: 20,
containerRegistryAccessLevel: 20,
@@ -75,7 +76,7 @@ describe('Settings Panel', () => {
const findLFSFeatureToggle = () => findLFSSettingsRow().find(GlToggle);
const findRepositoryFeatureProjectRow = () => wrapper.find({ ref: 'repository-settings' });
const findRepositoryFeatureSetting = () =>
- findRepositoryFeatureProjectRow().find(projectFeatureSetting);
+ findRepositoryFeatureProjectRow().find(ProjectFeatureSetting);
const findProjectVisibilitySettings = () => wrapper.find({ ref: 'project-visibility-settings' });
const findIssuesSettingsRow = () => wrapper.find({ ref: 'issues-settings' });
const findAnalyticsRow = () => wrapper.find({ ref: 'analytics-settings' });
@@ -106,7 +107,11 @@ describe('Settings Panel', () => {
'input[name="project[project_setting_attributes][warn_about_potentially_unwanted_characters]"]',
);
const findMetricsVisibilitySettings = () => wrapper.find({ ref: 'metrics-visibility-settings' });
+ const findMetricsVisibilityInput = () =>
+ findMetricsVisibilitySettings().findComponent(ProjectFeatureSetting);
const findOperationsSettings = () => wrapper.find({ ref: 'operations-settings' });
+ const findOperationsVisibilityInput = () =>
+ findOperationsSettings().findComponent(ProjectFeatureSetting);
const findConfirmDangerButton = () => wrapper.findComponent(ConfirmDanger);
afterEach(() => {
@@ -595,7 +600,7 @@ describe('Settings Panel', () => {
});
describe('Metrics dashboard', () => {
- it('should show the metrics dashboard access toggle', () => {
+ it('should show the metrics dashboard access select', () => {
wrapper = mountComponent();
expect(findMetricsVisibilitySettings().exists()).toBe(true);
@@ -610,23 +615,51 @@ describe('Settings Panel', () => {
});
it.each`
- scenario | selectedOption | selectedOptionLabel
- ${{ currentSettings: { visibilityLevel: visibilityOptions.PRIVATE } }} | ${String(featureAccessLevel.PROJECT_MEMBERS)} | ${'Only Project Members'}
- ${{ currentSettings: { operationsAccessLevel: featureAccessLevel.NOT_ENABLED } }} | ${String(featureAccessLevel.NOT_ENABLED)} | ${'Enable feature to choose access level'}
+ before | after
+ ${featureAccessLevel.NOT_ENABLED} | ${featureAccessLevel.EVERYONE}
+ ${featureAccessLevel.NOT_ENABLED} | ${featureAccessLevel.PROJECT_MEMBERS}
+ ${featureAccessLevel.EVERYONE} | ${featureAccessLevel.PROJECT_MEMBERS}
+ ${featureAccessLevel.EVERYONE} | ${featureAccessLevel.NOT_ENABLED}
+ ${featureAccessLevel.PROJECT_MEMBERS} | ${featureAccessLevel.NOT_ENABLED}
`(
- 'should disable the metrics visibility dropdown when #scenario',
- ({ scenario, selectedOption, selectedOptionLabel }) => {
- wrapper = mountComponent(scenario, mount);
+ 'when updating Operations Settings access level from `$before` to `$after`, Metric Dashboard access is updated to `$after` as well',
+ async ({ before, after }) => {
+ wrapper = mountComponent({
+ currentSettings: { operationsAccessLevel: before, metricsDashboardAccessLevel: before },
+ });
- const select = findMetricsVisibilitySettings().find('select');
- const option = select.find('option');
+ await findOperationsVisibilityInput().vm.$emit('change', after);
- expect(select.attributes('disabled')).toBe('disabled');
- expect(select.element.value).toBe(selectedOption);
- expect(option.attributes('value')).toBe(selectedOption);
- expect(option.text()).toBe(selectedOptionLabel);
+ expect(findMetricsVisibilityInput().props('value')).toBe(after);
},
);
+
+ it('when updating Operations Settings access level from `10` to `20`, Metric Dashboard access is not increased', async () => {
+ wrapper = mountComponent({
+ currentSettings: {
+ operationsAccessLevel: featureAccessLevel.PROJECT_MEMBERS,
+ metricsDashboardAccessLevel: featureAccessLevel.PROJECT_MEMBERS,
+ },
+ });
+
+ await findOperationsVisibilityInput().vm.$emit('change', featureAccessLevel.EVERYONE);
+
+ expect(findMetricsVisibilityInput().props('value')).toBe(featureAccessLevel.PROJECT_MEMBERS);
+ });
+
+ it('should reduce Metrics visibility level when visibility is set to private', async () => {
+ wrapper = mountComponent({
+ currentSettings: {
+ visibilityLevel: visibilityOptions.PUBLIC,
+ operationsAccessLevel: featureAccessLevel.EVERYONE,
+ metricsDashboardAccessLevel: featureAccessLevel.EVERYONE,
+ },
+ });
+
+ await findProjectVisibilityLevelInput().setValue(visibilityOptions.PRIVATE);
+
+ expect(findMetricsVisibilityInput().props('value')).toBe(featureAccessLevel.PROJECT_MEMBERS);
+ });
});
describe('Analytics', () => {
diff --git a/spec/frontend/pages/shared/wikis/components/wiki_content_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_content_spec.js
new file mode 100644
index 00000000000..365bb878485
--- /dev/null
+++ b/spec/frontend/pages/shared/wikis/components/wiki_content_spec.js
@@ -0,0 +1,97 @@
+import { GlSkeletonLoader, GlAlert } from '@gitlab/ui';
+import { nextTick } from 'vue';
+import { shallowMount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import WikiContent from '~/pages/shared/wikis/components/wiki_content.vue';
+import { renderGFM } from '~/pages/shared/wikis/render_gfm_facade';
+import axios from '~/lib/utils/axios_utils';
+import httpStatus from '~/lib/utils/http_status';
+import waitForPromises from 'helpers/wait_for_promises';
+
+jest.mock('~/pages/shared/wikis/render_gfm_facade');
+
+describe('pages/shared/wikis/components/wiki_content', () => {
+ const PATH = '/test';
+ let wrapper;
+ let mock;
+
+ function buildWrapper(propsData = {}) {
+ wrapper = shallowMount(WikiContent, {
+ propsData: { getWikiContentUrl: PATH, ...propsData },
+ stubs: {
+ GlSkeletonLoader,
+ GlAlert,
+ },
+ });
+ }
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findGlAlert = () => wrapper.findComponent(GlAlert);
+ const findGlSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
+ const findContent = () => wrapper.find('[data-testid="wiki_page_content"]');
+
+ describe('when loading content', () => {
+ beforeEach(() => {
+ buildWrapper();
+ });
+
+ it('renders skeleton loader', () => {
+ expect(findGlSkeletonLoader().exists()).toBe(true);
+ });
+
+ it('does not render content container or error alert', () => {
+ expect(findGlAlert().exists()).toBe(false);
+ expect(findContent().exists()).toBe(false);
+ });
+ });
+
+ describe('when content loads successfully', () => {
+ const content = 'content';
+
+ beforeEach(() => {
+ mock.onGet(PATH, { params: { render_html: true } }).replyOnce(httpStatus.OK, { content });
+ buildWrapper();
+ return waitForPromises();
+ });
+
+ it('renders content container', () => {
+ expect(findContent().text()).toBe(content);
+ });
+
+ it('does not render skeleton loader or error alert', () => {
+ expect(findGlAlert().exists()).toBe(false);
+ expect(findGlSkeletonLoader().exists()).toBe(false);
+ });
+
+ it('calls renderGFM after nextTick', async () => {
+ await nextTick();
+
+ expect(renderGFM).toHaveBeenCalledWith(wrapper.element);
+ });
+ });
+
+ describe('when loading content fails', () => {
+ beforeEach(() => {
+ mock.onGet(PATH).replyOnce(httpStatus.INTERNAL_SERVER_ERROR, '');
+ buildWrapper();
+ return waitForPromises();
+ });
+
+ it('renders error alert', () => {
+ expect(findGlAlert().exists()).toBe(true);
+ });
+
+ it('does not render skeleton loader or content container', () => {
+ expect(findContent().exists()).toBe(false);
+ expect(findGlSkeletonLoader().exists()).toBe(false);
+ });
+ });
+});
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 e118a35804f..d7f8dc3c98e 100644
--- a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js
+++ b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js
@@ -1,5 +1,5 @@
import { nextTick } from 'vue';
-import { GlLoadingIcon, GlModal, GlAlert, GlButton } from '@gitlab/ui';
+import { GlAlert, GlButton } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
@@ -32,11 +32,7 @@ describe('WikiForm', () => {
const findMessage = () => wrapper.find('#wiki_message');
const findSubmitButton = () => wrapper.findByTestId('wiki-submit-button');
const findCancelButton = () => wrapper.findByTestId('wiki-cancel-button');
- const findUseNewEditorButton = () => wrapper.findByText('Use the new editor');
const findToggleEditingModeButton = () => wrapper.findByTestId('toggle-editing-mode-button');
- const findDismissContentEditorAlertButton = () => wrapper.findByText('Try this later');
- const findSwitchToOldEditorButton = () =>
- wrapper.findByRole('button', { name: 'Switch me back to the classic editor.' });
const findTitleHelpLink = () => wrapper.findByText('Learn more.');
const findMarkdownHelpLink = () => wrapper.findByTestId('wiki-markdown-help-link');
const findContentEditor = () => wrapper.findComponent(ContentEditor);
@@ -293,27 +289,21 @@ describe('WikiForm', () => {
);
});
- describe('when wikiSwitchBetweenContentEditorRawMarkdown feature flag is not enabled', () => {
+ describe('toggle editing mode control', () => {
beforeEach(() => {
- createWrapper({
- glFeatures: { wikiSwitchBetweenContentEditorRawMarkdown: false },
- });
- });
-
- it('hides toggle editing mode button', () => {
- expect(findToggleEditingModeButton().exists()).toBe(false);
- });
- });
-
- describe('when wikiSwitchBetweenContentEditorRawMarkdown feature flag is enabled', () => {
- beforeEach(() => {
- createWrapper({
- glFeatures: { wikiSwitchBetweenContentEditorRawMarkdown: true },
- });
+ createWrapper();
});
- it('hides gl-alert containing "use new editor" button', () => {
- expect(findUseNewEditorButton().exists()).toBe(false);
+ it.each`
+ format | enabled | action
+ ${'markdown'} | ${true} | ${'displays'}
+ ${'rdoc'} | ${false} | ${'hides'}
+ ${'asciidoc'} | ${false} | ${'hides'}
+ ${'org'} | ${false} | ${'hides'}
+ `('$action toggle editing mode button when format is $format', async ({ format, enabled }) => {
+ await setFormat(format);
+
+ expect(findToggleEditingModeButton().exists()).toBe(enabled);
});
it('displays toggle editing mode button', () => {
@@ -326,8 +316,8 @@ describe('WikiForm', () => {
});
describe('when clicking the toggle editing mode button', () => {
- beforeEach(() => {
- findToggleEditingModeButton().vm.$emit('click');
+ beforeEach(async () => {
+ await findToggleEditingModeButton().trigger('click');
});
it('hides the classic editor', () => {
@@ -343,17 +333,13 @@ describe('WikiForm', () => {
describe('when content editor is active', () => {
let mockContentEditor;
- beforeEach(() => {
+ beforeEach(async () => {
mockContentEditor = {
getSerializedContent: jest.fn(),
setSerializedContent: jest.fn(),
};
- findToggleEditingModeButton().vm.$emit('click');
- });
-
- it('hides switch to old editor button', () => {
- expect(findSwitchToOldEditorButton().exists()).toBe(false);
+ await findToggleEditingModeButton().trigger('click');
});
it('displays "Edit source" label in the toggle editing mode button', () => {
@@ -363,13 +349,13 @@ describe('WikiForm', () => {
describe('when clicking the toggle editing mode button', () => {
const contentEditorFakeSerializedContent = 'fake content';
- beforeEach(() => {
+ beforeEach(async () => {
mockContentEditor.getSerializedContent.mockReturnValueOnce(
contentEditorFakeSerializedContent,
);
findContentEditor().vm.$emit('initialized', mockContentEditor);
- findToggleEditingModeButton().vm.$emit('click');
+ await findToggleEditingModeButton().trigger('click');
});
it('hides the content editor', () => {
@@ -388,75 +374,12 @@ describe('WikiForm', () => {
});
describe('wiki content editor', () => {
- it.each`
- format | buttonExists
- ${'markdown'} | ${true}
- ${'rdoc'} | ${false}
- `(
- 'gl-alert containing "use new editor" button exists: $buttonExists if format is $format',
- async ({ format, buttonExists }) => {
- createWrapper();
-
- await setFormat(format);
-
- expect(findUseNewEditorButton().exists()).toBe(buttonExists);
- },
- );
-
- it('gl-alert containing "use new editor" button is dismissed on clicking dismiss button', async () => {
- createWrapper();
-
- await findDismissContentEditorAlertButton().trigger('click');
-
- expect(findUseNewEditorButton().exists()).toBe(false);
- });
-
- const assertOldEditorIsVisible = () => {
- expect(findContentEditor().exists()).toBe(false);
- expect(findClassicEditor().exists()).toBe(true);
- expect(findSubmitButton().props('disabled')).toBe(false);
-
- expect(wrapper.text()).not.toContain(
- "Switching will discard any changes you've made in the new editor.",
- );
- expect(wrapper.text()).not.toContain(
- "This editor is in beta and may not display the page's contents properly.",
- );
- };
-
- it('shows classic editor by default', () => {
- createWrapper({ persisted: true });
-
- assertOldEditorIsVisible();
- });
-
- describe('switch format to rdoc', () => {
- beforeEach(async () => {
- createWrapper({ persisted: true });
-
- await setFormat('rdoc');
- });
-
- it('continues to show the classic editor', assertOldEditorIsVisible);
-
- describe('switch format back to markdown', () => {
- beforeEach(async () => {
- await setFormat('markdown');
- });
-
- it(
- 'still shows the classic editor and does not automatically switch to the content editor ',
- assertOldEditorIsVisible,
- );
- });
- });
-
describe('clicking "use new editor": editor fails to load', () => {
beforeEach(async () => {
createWrapper({ mountFn: mount });
mock.onPost(/preview-markdown/).reply(400);
- await findUseNewEditorButton().trigger('click');
+ await findToggleEditingModeButton().trigger('click');
// try waiting for content editor to load (but it will never actually load)
await waitForPromises();
@@ -466,14 +389,14 @@ describe('WikiForm', () => {
expect(findSubmitButton().props('disabled')).toBe(true);
});
- describe('clicking "switch to classic editor"', () => {
+ describe('toggling editing modes to the classic editor', () => {
beforeEach(() => {
- return findSwitchToOldEditorButton().trigger('click');
+ return findToggleEditingModeButton().trigger('click');
});
- it('switches to classic editor directly without showing a modal', () => {
- expect(wrapper.findComponent(ContentEditor).exists()).toBe(false);
- expect(wrapper.findComponent(MarkdownField).exists()).toBe(true);
+ it('switches to classic editor', () => {
+ expect(findContentEditor().exists()).toBe(false);
+ expect(findClassicEditor().exists()).toBe(true);
});
});
});
@@ -484,31 +407,15 @@ describe('WikiForm', () => {
mock.onPost(/preview-markdown/).reply(200, { body: '<p>hello <strong>world</strong></p>' });
- await findUseNewEditorButton().trigger('click');
- });
-
- it('shows a tip to send feedback', () => {
- expect(wrapper.text()).toContain('Tell us your experiences with the new Markdown editor');
- });
-
- it('shows warnings that the rich text editor is in beta and may not work properly', () => {
- expect(wrapper.text()).toContain(
- "This editor is in beta and may not display the page's contents properly.",
- );
+ await findToggleEditingModeButton().trigger('click');
+ await waitForPromises();
});
it('shows the rich text editor when loading finishes', async () => {
- // wait for content editor to load
- await waitForPromises();
-
- expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(false);
- expect(wrapper.findComponent(ContentEditor).exists()).toBe(true);
+ expect(findContentEditor().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,
});
@@ -564,49 +471,6 @@ describe('WikiForm', () => {
expect(findContent().element.value).toBe('hello **world**');
});
});
-
- describe('clicking "switch to classic editor"', () => {
- let modal;
-
- beforeEach(async () => {
- modal = wrapper.findComponent(GlModal);
- jest.spyOn(modal.vm, 'show');
-
- findSwitchToOldEditorButton().trigger('click');
- });
-
- it('shows a modal confirming the change', () => {
- expect(modal.vm.show).toHaveBeenCalled();
- });
-
- describe('confirming "switch to classic editor" in the modal', () => {
- beforeEach(async () => {
- wrapper.vm.contentEditor.tiptapEditor.commands.setContent(
- '<p>hello __world__ from content editor</p>',
- true,
- );
-
- wrapper.findComponent(GlModal).vm.$emit('primary');
-
- await nextTick();
- });
-
- it('switches to classic editor', () => {
- expect(wrapper.findComponent(ContentEditor).exists()).toBe(false);
- expect(wrapper.findComponent(MarkdownField).exists()).toBe(true);
- });
-
- it('does not show a warning about content editor', () => {
- expect(wrapper.text()).not.toContain(
- "This editor is in beta and may not display the page's contents properly.",
- );
- });
-
- it('the classic editor retains its old value and does not use the content from the content editor', () => {
- expect(findContent().element.value).toBe(' My page content ');
- });
- });
- });
});
});
});
diff --git a/spec/frontend/pdf/page_spec.js b/spec/frontend/pdf/page_spec.js
index 4e0a6f78b63..07a7f1bb2ff 100644
--- a/spec/frontend/pdf/page_spec.js
+++ b/spec/frontend/pdf/page_spec.js
@@ -1,4 +1,4 @@
-import Vue from 'vue';
+import Vue, { nextTick } from 'vue';
import mountComponent from 'helpers/vue_mount_component_helper';
import PageComponent from '~/pdf/page/index.vue';
@@ -14,8 +14,7 @@ describe('Page component', () => {
vm.$destroy();
});
- it('renders the page when mounting', (done) => {
- const promise = Promise.resolve();
+ it('renders the page when mounting', async () => {
const testPage = {
render: jest.fn().mockReturnValue({ promise: Promise.resolve() }),
getViewport: jest.fn().mockReturnValue({}),
@@ -28,12 +27,9 @@ describe('Page component', () => {
expect(vm.rendering).toBe(true);
- promise
- .then(() => {
- expect(testPage.render).toHaveBeenCalledWith(vm.renderContext);
- expect(vm.rendering).toBe(false);
- })
- .then(done)
- .catch(done.fail);
+ await nextTick();
+
+ expect(testPage.render).toHaveBeenCalledWith(vm.renderContext);
+ expect(vm.rendering).toBe(false);
});
});
diff --git a/spec/frontend/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js b/spec/frontend/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js
index ba06f113120..33b53bf6a56 100644
--- a/spec/frontend/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js
+++ b/spec/frontend/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js
@@ -1,146 +1,27 @@
-import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
-import { nextTick } from 'vue';
-import { useLocalStorageSpy } from 'helpers/local_storage_helper';
-import FirstPipelineCard from '~/pipeline_editor/components/drawer/cards/first_pipeline_card.vue';
-import GettingStartedCard from '~/pipeline_editor/components/drawer/cards/getting_started_card.vue';
-import PipelineConfigReferenceCard from '~/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue';
-import VisualizeAndLintCard from '~/pipeline_editor/components/drawer/cards/visualize_and_lint_card.vue';
+import { GlDrawer } from '@gitlab/ui';
import PipelineEditorDrawer from '~/pipeline_editor/components/drawer/pipeline_editor_drawer.vue';
-import { DRAWER_EXPANDED_KEY } from '~/pipeline_editor/constants';
-import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
describe('Pipeline editor drawer', () => {
- useLocalStorageSpy();
-
let wrapper;
+ const findDrawer = () => wrapper.findComponent(GlDrawer);
+
const createComponent = () => {
- wrapper = shallowMount(PipelineEditorDrawer, {
- stubs: { LocalStorageSync },
- });
+ wrapper = shallowMount(PipelineEditorDrawer);
};
- const findFirstPipelineCard = () => wrapper.findComponent(FirstPipelineCard);
- const findGettingStartedCard = () => wrapper.findComponent(GettingStartedCard);
- const findPipelineConfigReferenceCard = () => wrapper.findComponent(PipelineConfigReferenceCard);
- const findToggleBtn = () => wrapper.findComponent(GlButton);
- const findVisualizeAndLintCard = () => wrapper.findComponent(VisualizeAndLintCard);
-
- const findArrowIcon = () => wrapper.find('[data-testid="toggle-icon"]');
- const findCollapseText = () => wrapper.find('[data-testid="collapse-text"]');
- const findDrawerContent = () => wrapper.find('[data-testid="drawer-content"]');
-
- const clickToggleBtn = async () => findToggleBtn().vm.$emit('click');
-
- const originalObjects = [];
-
- beforeEach(() => {
- originalObjects.push(window.gon, window.gl);
- });
-
afterEach(() => {
wrapper.destroy();
- localStorage.clear();
- [window.gon, window.gl] = originalObjects;
- });
-
- describe('default expanded state', () => {
- it('sets the drawer to be closed by default', async () => {
- createComponent();
- expect(findDrawerContent().exists()).toBe(false);
- });
- });
-
- describe('when the drawer is collapsed', () => {
- beforeEach(async () => {
- createComponent();
- });
-
- it('shows the left facing arrow icon', () => {
- expect(findArrowIcon().props('name')).toBe('chevron-double-lg-left');
- });
-
- it('does not show the collapse text', () => {
- expect(findCollapseText().exists()).toBe(false);
- });
-
- it('does not show the drawer content', () => {
- expect(findDrawerContent().exists()).toBe(false);
- });
-
- it('can open the drawer by clicking on the toggle button', async () => {
- expect(findDrawerContent().exists()).toBe(false);
-
- await clickToggleBtn();
-
- expect(findDrawerContent().exists()).toBe(true);
- });
- });
-
- describe('when the drawer is expanded', () => {
- beforeEach(async () => {
- createComponent();
- await clickToggleBtn();
- });
-
- it('shows the right facing arrow icon', () => {
- expect(findArrowIcon().props('name')).toBe('chevron-double-lg-right');
- });
-
- it('shows the collapse text', () => {
- expect(findCollapseText().exists()).toBe(true);
- });
-
- it('shows the drawer content', () => {
- expect(findDrawerContent().exists()).toBe(true);
- });
-
- it('shows all the introduction cards', () => {
- expect(findFirstPipelineCard().exists()).toBe(true);
- expect(findGettingStartedCard().exists()).toBe(true);
- expect(findPipelineConfigReferenceCard().exists()).toBe(true);
- expect(findVisualizeAndLintCard().exists()).toBe(true);
- });
-
- it('can close the drawer by clicking on the toggle button', async () => {
- expect(findDrawerContent().exists()).toBe(true);
-
- await clickToggleBtn();
-
- expect(findDrawerContent().exists()).toBe(false);
- });
});
- describe('local storage', () => {
- it('saves the drawer expanded value to local storage', async () => {
- localStorage.setItem(DRAWER_EXPANDED_KEY, 'false');
-
- createComponent();
- await clickToggleBtn();
-
- expect(localStorage.setItem.mock.calls).toEqual([
- [DRAWER_EXPANDED_KEY, 'false'],
- [DRAWER_EXPANDED_KEY, 'true'],
- ]);
- });
-
- it('loads the drawer collapsed when local storage is set to `false`, ', async () => {
- localStorage.setItem(DRAWER_EXPANDED_KEY, false);
- createComponent();
-
- await nextTick();
-
- expect(findDrawerContent().exists()).toBe(false);
- });
+ it('emits close event when closing the drawer', () => {
+ createComponent();
- it('loads the drawer expanded when local storage is set to `true`, ', async () => {
- localStorage.setItem(DRAWER_EXPANDED_KEY, true);
- createComponent();
+ expect(wrapper.emitted('close-drawer')).toBeUndefined();
- await nextTick();
+ findDrawer().vm.$emit('close');
- expect(findDrawerContent().exists()).toBe(true);
- });
+ expect(wrapper.emitted('close-drawer')).toHaveLength(1);
});
});
diff --git a/spec/frontend/pipeline_editor/components/editor/ci_editor_header_spec.js b/spec/frontend/pipeline_editor/components/editor/ci_editor_header_spec.js
index 3ee53d4a055..8f50325295e 100644
--- a/spec/frontend/pipeline_editor/components/editor/ci_editor_header_spec.js
+++ b/spec/frontend/pipeline_editor/components/editor/ci_editor_header_spec.js
@@ -1,5 +1,5 @@
-import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import CiEditorHeader from '~/pipeline_editor/components/editor/ci_editor_header.vue';
import {
@@ -11,11 +11,18 @@ describe('CI Editor Header', () => {
let wrapper;
let trackingSpy = null;
- const createComponent = () => {
- wrapper = shallowMount(CiEditorHeader, {});
+ const createComponent = ({ showDrawer = false } = {}) => {
+ wrapper = extendedWrapper(
+ shallowMount(CiEditorHeader, {
+ propsData: {
+ showDrawer,
+ },
+ }),
+ );
};
- const findLinkBtn = () => wrapper.findComponent(GlButton);
+ const findLinkBtn = () => wrapper.findByTestId('template-repo-link');
+ const findHelpBtn = () => wrapper.findByTestId('drawer-toggle');
afterEach(() => {
wrapper.destroy();
@@ -50,4 +57,42 @@ describe('CI Editor Header', () => {
});
});
});
+
+ describe('help button', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('finds the help button', () => {
+ expect(findHelpBtn().exists()).toBe(true);
+ });
+
+ it('has the information-o icon', () => {
+ expect(findHelpBtn().props('icon')).toBe('information-o');
+ });
+
+ describe('when pipeline editor drawer is closed', () => {
+ it('emits open drawer event when clicked', () => {
+ createComponent({ showDrawer: false });
+
+ expect(wrapper.emitted('open-drawer')).toBeUndefined();
+
+ findHelpBtn().vm.$emit('click');
+
+ expect(wrapper.emitted('open-drawer')).toHaveLength(1);
+ });
+ });
+
+ describe('when pipeline editor drawer is open', () => {
+ it('emits close drawer event when clicked', () => {
+ createComponent({ showDrawer: true });
+
+ expect(wrapper.emitted('close-drawer')).toBeUndefined();
+
+ findHelpBtn().vm.$emit('click');
+
+ expect(wrapper.emitted('close-drawer')).toHaveLength(1);
+ });
+ });
+ });
});
diff --git a/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js b/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js
index fee52db9b64..6dffb7e5470 100644
--- a/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js
+++ b/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js
@@ -40,6 +40,7 @@ describe('Pipeline editor tabs component', () => {
ciConfigData: mockLintResponse,
ciFileContent: mockCiYml,
isNewCiConfigFile: true,
+ showDrawer: false,
...props,
},
data() {
diff --git a/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js
index 6f969546171..98e2c17967c 100644
--- a/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js
+++ b/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js
@@ -1,6 +1,8 @@
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
-import { GlModal } from '@gitlab/ui';
+import { GlButton, GlDrawer, GlModal } from '@gitlab/ui';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import CiEditorHeader from '~/pipeline_editor/components/editor/ci_editor_header.vue';
import CommitSection from '~/pipeline_editor/components/commit/commit_section.vue';
import PipelineEditorDrawer from '~/pipeline_editor/components/drawer/pipeline_editor_drawer.vue';
import PipelineEditorFileNav from '~/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue';
@@ -18,24 +20,26 @@ describe('Pipeline editor home wrapper', () => {
let wrapper;
const createComponent = ({ props = {}, glFeatures = {}, data = {}, stubs = {} } = {}) => {
- wrapper = shallowMount(PipelineEditorHome, {
- data: () => data,
- propsData: {
- ciConfigData: mockLintResponse,
- ciFileContent: mockCiYml,
- isCiConfigDataLoading: false,
- isNewCiConfigFile: false,
- ...props,
- },
- provide: {
- projectFullPath: '',
- totalBranches: 19,
- glFeatures: {
- ...glFeatures,
+ wrapper = extendedWrapper(
+ shallowMount(PipelineEditorHome, {
+ data: () => data,
+ propsData: {
+ ciConfigData: mockLintResponse,
+ ciFileContent: mockCiYml,
+ isCiConfigDataLoading: false,
+ isNewCiConfigFile: false,
+ ...props,
},
- },
- stubs,
- });
+ provide: {
+ projectFullPath: '',
+ totalBranches: 19,
+ glFeatures: {
+ ...glFeatures,
+ },
+ },
+ stubs,
+ }),
+ );
};
const findBranchSwitcher = () => wrapper.findComponent(BranchSwitcher);
@@ -45,6 +49,7 @@ describe('Pipeline editor home wrapper', () => {
const findPipelineEditorDrawer = () => wrapper.findComponent(PipelineEditorDrawer);
const findPipelineEditorHeader = () => wrapper.findComponent(PipelineEditorHeader);
const findPipelineEditorTabs = () => wrapper.findComponent(PipelineEditorTabs);
+ const findHelpBtn = () => wrapper.findByTestId('drawer-toggle');
afterEach(() => {
wrapper.destroy();
@@ -70,10 +75,6 @@ describe('Pipeline editor home wrapper', () => {
it('shows the commit section by default', () => {
expect(findCommitSection().exists()).toBe(true);
});
-
- it('show the pipeline drawer', () => {
- expect(findPipelineEditorDrawer().exists()).toBe(true);
- });
});
describe('modal when switching branch', () => {
@@ -175,4 +176,58 @@ describe('Pipeline editor home wrapper', () => {
});
});
});
+
+ describe('help drawer', () => {
+ const clickHelpBtn = async () => {
+ findHelpBtn().vm.$emit('click');
+ await nextTick();
+ };
+
+ it('hides the drawer by default', () => {
+ createComponent();
+
+ expect(findPipelineEditorDrawer().props('isVisible')).toBe(false);
+ });
+
+ it('toggles the drawer on button click', async () => {
+ createComponent({
+ stubs: {
+ CiEditorHeader,
+ GlButton,
+ GlDrawer,
+ PipelineEditorTabs,
+ PipelineEditorDrawer,
+ },
+ });
+
+ await clickHelpBtn();
+
+ expect(findPipelineEditorDrawer().props('isVisible')).toBe(true);
+
+ await clickHelpBtn();
+
+ expect(findPipelineEditorDrawer().props('isVisible')).toBe(false);
+ });
+
+ it("closes the drawer through the drawer's close button", async () => {
+ createComponent({
+ stubs: {
+ CiEditorHeader,
+ GlButton,
+ GlDrawer,
+ PipelineEditorTabs,
+ PipelineEditorDrawer,
+ },
+ });
+
+ await clickHelpBtn();
+
+ expect(findPipelineEditorDrawer().props('isVisible')).toBe(true);
+
+ findPipelineEditorDrawer().find(GlDrawer).vm.$emit('close');
+ await nextTick();
+
+ expect(findPipelineEditorDrawer().props('isVisible')).toBe(false);
+ });
+ });
});
diff --git a/spec/frontend/pipeline_wizard/components/wrapper_spec.js b/spec/frontend/pipeline_wizard/components/wrapper_spec.js
index bd1679baf48..357a9d21723 100644
--- a/spec/frontend/pipeline_wizard/components/wrapper_spec.js
+++ b/spec/frontend/pipeline_wizard/components/wrapper_spec.js
@@ -1,21 +1,26 @@
import { Document, parseDocument } from 'yaml';
import { GlProgressBar } from '@gitlab/ui';
import { nextTick } from 'vue';
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper';
import PipelineWizardWrapper, { i18n } from '~/pipeline_wizard/components/wrapper.vue';
import WizardStep from '~/pipeline_wizard/components/step.vue';
import CommitStep from '~/pipeline_wizard/components/commit.vue';
import YamlEditor from '~/pipeline_wizard/components/editor.vue';
import { sprintf } from '~/locale';
-import { steps as stepsYaml } from '../mock/yaml';
+import {
+ steps as stepsYaml,
+ compiledScenario1,
+ compiledScenario2,
+ compiledScenario3,
+} from '../mock/yaml';
describe('Pipeline Wizard - wrapper.vue', () => {
let wrapper;
const steps = parseDocument(stepsYaml).toJS();
const getAsYamlNode = (value) => new Document(value).contents;
- const createComponent = (props = {}) => {
- wrapper = shallowMountExtended(PipelineWizardWrapper, {
+ const createComponent = (props = {}, mountFn = shallowMountExtended) => {
+ wrapper = mountFn(PipelineWizardWrapper, {
propsData: {
projectPath: '/user/repo',
defaultBranch: 'main',
@@ -23,13 +28,21 @@ describe('Pipeline Wizard - wrapper.vue', () => {
steps: getAsYamlNode(steps),
...props,
},
+ stubs: {
+ CommitStep: true,
+ },
});
};
const getEditorContent = () => {
- return wrapper.getComponent(YamlEditor).attributes().doc.toString();
+ return wrapper.getComponent(YamlEditor).props().doc.toString();
};
- const getStepWrapper = () => wrapper.getComponent(WizardStep);
+ const getStepWrapper = () =>
+ wrapper.findAllComponents(WizardStep).wrappers.find((w) => w.isVisible());
const getGlProgressBarWrapper = () => wrapper.getComponent(GlProgressBar);
+ const findFirstVisibleStep = () =>
+ wrapper.findAllComponents('[data-testid="step"]').wrappers.find((w) => w.isVisible());
+ const findFirstInputFieldForTarget = (target) =>
+ wrapper.find(`[data-input-target="${target}"]`).find('input');
describe('display', () => {
afterEach(() => {
@@ -118,8 +131,9 @@ describe('Pipeline Wizard - wrapper.vue', () => {
}) => {
beforeAll(async () => {
createComponent();
+
for (const emittedValue of navigationEventChain) {
- wrapper.findComponent({ ref: 'step' }).vm.$emit(emittedValue);
+ findFirstVisibleStep().vm.$emit(emittedValue);
// We have to wait for the next step to be mounted
// before we can emit the next event, so we have to await
// inside the loop.
@@ -134,11 +148,11 @@ describe('Pipeline Wizard - wrapper.vue', () => {
if (expectCommitStepShown) {
it('does not show the step wrapper', async () => {
- expect(wrapper.findComponent(WizardStep).exists()).toBe(false);
+ expect(wrapper.findComponent(WizardStep).isVisible()).toBe(false);
});
it('shows the commit step page', () => {
- expect(wrapper.findComponent(CommitStep).exists()).toBe(true);
+ expect(wrapper.findComponent(CommitStep).isVisible()).toBe(true);
});
} else {
it('passes the correct step config to the step component', async () => {
@@ -146,7 +160,7 @@ describe('Pipeline Wizard - wrapper.vue', () => {
});
it('does not show the commit step page', () => {
- expect(wrapper.findComponent(CommitStep).exists()).toBe(false);
+ expect(wrapper.findComponent(CommitStep).isVisible()).toBe(false);
});
}
@@ -247,4 +261,54 @@ describe('Pipeline Wizard - wrapper.vue', () => {
expect(wrapper.getComponent(YamlEditor).props('highlight')).toBe(null);
});
});
+
+ describe('integration test', () => {
+ beforeAll(async () => {
+ createComponent({}, mountExtended);
+ });
+
+ it('updates the editor content after input on step 1', async () => {
+ findFirstInputFieldForTarget('$FOO').setValue('fooVal');
+ await nextTick();
+
+ expect(getEditorContent()).toBe(compiledScenario1);
+ });
+
+ it('updates the editor content after input on step 2', async () => {
+ findFirstVisibleStep().vm.$emit('next');
+ await nextTick();
+
+ findFirstInputFieldForTarget('$BAR').setValue('barVal');
+ await nextTick();
+
+ expect(getEditorContent()).toBe(compiledScenario2);
+ });
+
+ describe('navigating back', () => {
+ let inputField;
+
+ beforeAll(async () => {
+ findFirstVisibleStep().vm.$emit('back');
+ await nextTick();
+
+ inputField = findFirstInputFieldForTarget('$FOO');
+ });
+
+ afterAll(() => {
+ wrapper.destroy();
+ inputField = undefined;
+ });
+
+ it('still shows the input values from the former visit', () => {
+ expect(inputField.element.value).toBe('fooVal');
+ });
+
+ it('updates the editor content without modifying input that came from a later step', async () => {
+ inputField.setValue('newFooVal');
+ await nextTick();
+
+ expect(getEditorContent()).toBe(compiledScenario3);
+ });
+ });
+ });
});
diff --git a/spec/frontend/pipeline_wizard/mock/yaml.js b/spec/frontend/pipeline_wizard/mock/yaml.js
index 5eaeaa32a8c..e7087b59ce7 100644
--- a/spec/frontend/pipeline_wizard/mock/yaml.js
+++ b/spec/frontend/pipeline_wizard/mock/yaml.js
@@ -59,6 +59,17 @@ export const steps = `
bar: $BAR
`;
+export const compiledScenario1 = `foo: fooVal
+`;
+
+export const compiledScenario2 = `foo: fooVal
+bar: barVal
+`;
+
+export const compiledScenario3 = `foo: newFooVal
+bar: barVal
+`;
+
export const fullTemplate = `
title: some title
description: some description
diff --git a/spec/frontend/pipelines/components/pipeline_tabs_spec.js b/spec/frontend/pipelines/components/pipeline_tabs_spec.js
new file mode 100644
index 00000000000..e18c3edbad9
--- /dev/null
+++ b/spec/frontend/pipelines/components/pipeline_tabs_spec.js
@@ -0,0 +1,61 @@
+import { shallowMount } from '@vue/test-utils';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import PipelineTabs from '~/pipelines/components/pipeline_tabs.vue';
+import PipelineGraphWrapper from '~/pipelines/components/graph/graph_component_wrapper.vue';
+import Dag from '~/pipelines/components/dag/dag.vue';
+import JobsApp from '~/pipelines/components/jobs/jobs_app.vue';
+import TestReports from '~/pipelines/components/test_reports/test_reports.vue';
+
+describe('The Pipeline Tabs', () => {
+ let wrapper;
+
+ const findDagTab = () => wrapper.findByTestId('dag-tab');
+ const findFailedJobsTab = () => wrapper.findByTestId('failed-jobs-tab');
+ const findJobsTab = () => wrapper.findByTestId('jobs-tab');
+ const findPipelineTab = () => wrapper.findByTestId('pipeline-tab');
+ const findTestsTab = () => wrapper.findByTestId('tests-tab');
+
+ const findDagApp = () => wrapper.findComponent(Dag);
+ const findFailedJobsApp = () => wrapper.findComponent(JobsApp);
+ const findJobsApp = () => wrapper.findComponent(JobsApp);
+ const findPipelineApp = () => wrapper.findComponent(PipelineGraphWrapper);
+ const findTestsApp = () => wrapper.findComponent(TestReports);
+
+ const createComponent = (propsData = {}) => {
+ wrapper = extendedWrapper(
+ shallowMount(PipelineTabs, {
+ propsData,
+ stubs: {
+ Dag: { template: '<div id="dag"/>' },
+ JobsApp: { template: '<div class="jobs" />' },
+ PipelineGraph: { template: '<div id="graph" />' },
+ TestReports: { template: '<div id="tests" />' },
+ },
+ }),
+ );
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ // The failed jobs MUST be removed from here and tested individually once
+ // the logic for the tab is implemented.
+ describe('Tabs', () => {
+ it.each`
+ tabName | tabComponent | appComponent
+ ${'Pipeline'} | ${findPipelineTab} | ${findPipelineApp}
+ ${'Dag'} | ${findDagTab} | ${findDagApp}
+ ${'Jobs'} | ${findJobsTab} | ${findJobsApp}
+ ${'Failed Jobs'} | ${findFailedJobsTab} | ${findFailedJobsApp}
+ ${'Tests'} | ${findTestsTab} | ${findTestsApp}
+ `('shows $tabName tab and its associated component', ({ appComponent, tabComponent }) => {
+ expect(tabComponent().exists()).toBe(true);
+ expect(appComponent().exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js
index 0822b293f75..6c743f92116 100644
--- a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js
+++ b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js
@@ -173,7 +173,7 @@ describe('Pipelines filtered search', () => {
{ type: 'filtered-search-term', value: { data: '' } },
];
- expect(findFilteredSearch().props('value')).toEqual(expectedValueProp);
+ expect(findFilteredSearch().props('value')).toMatchObject(expectedValueProp);
expect(findFilteredSearch().props('value')).toHaveLength(expectedValueProp.length);
});
});
diff --git a/spec/frontend/pipelines/empty_state/ci_templates_spec.js b/spec/frontend/pipelines/empty_state/ci_templates_spec.js
new file mode 100644
index 00000000000..606fdc9cac1
--- /dev/null
+++ b/spec/frontend/pipelines/empty_state/ci_templates_spec.js
@@ -0,0 +1,85 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
+import CiTemplates from '~/pipelines/components/pipelines_list/empty_state/ci_templates.vue';
+
+const pipelineEditorPath = '/-/ci/editor';
+const suggestedCiTemplates = [
+ { name: 'Android', logo: '/assets/illustrations/logos/android.svg' },
+ { name: 'Bash', logo: '/assets/illustrations/logos/bash.svg' },
+ { name: 'C++', logo: '/assets/illustrations/logos/c_plus_plus.svg' },
+];
+
+describe('CI Templates', () => {
+ let wrapper;
+ let trackingSpy;
+
+ const createWrapper = () => {
+ return shallowMountExtended(CiTemplates, {
+ provide: {
+ pipelineEditorPath,
+ suggestedCiTemplates,
+ },
+ });
+ };
+
+ const findTemplateDescription = () => wrapper.findByTestId('template-description');
+ const findTemplateLink = () => wrapper.findByTestId('template-link');
+ const findTemplateName = () => wrapper.findByTestId('template-name');
+ const findTemplateLogo = () => wrapper.findByTestId('template-logo');
+
+ beforeEach(() => {
+ wrapper = createWrapper();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('renders template list', () => {
+ it('renders all suggested templates', () => {
+ const content = wrapper.text();
+
+ expect(content).toContain('Android', 'Bash', 'C++');
+ });
+
+ it('has the correct template name', () => {
+ expect(findTemplateName().text()).toBe('Android');
+ });
+
+ it('links to the correct template', () => {
+ expect(findTemplateLink().attributes('href')).toBe(
+ pipelineEditorPath.concat('?template=Android'),
+ );
+ });
+
+ it('has the description of the template', () => {
+ expect(findTemplateDescription().text()).toBe(
+ 'CI/CD template to test and deploy your Android project.',
+ );
+ });
+
+ it('has the right logo of the template', () => {
+ expect(findTemplateLogo().attributes('src')).toBe('/assets/illustrations/logos/android.svg');
+ });
+ });
+
+ describe('tracking', () => {
+ beforeEach(() => {
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ });
+
+ afterEach(() => {
+ unmockTracking();
+ });
+
+ it('sends an event when template is clicked', () => {
+ findTemplateLink().vm.$emit('click');
+
+ expect(trackingSpy).toHaveBeenCalledTimes(1);
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'template_clicked', {
+ label: 'Android',
+ });
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/pipelines_ci_templates_spec.js b/spec/frontend/pipelines/empty_state/pipelines_ci_templates_spec.js
index 7064f7448ec..14860f20317 100644
--- a/spec/frontend/pipelines/pipelines_ci_templates_spec.js
+++ b/spec/frontend/pipelines/empty_state/pipelines_ci_templates_spec.js
@@ -1,12 +1,12 @@
import '~/commons';
import { GlButton, GlSprintf } from '@gitlab/ui';
-import { sprintf } from '~/locale';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import { mockTracking } from 'helpers/tracking_helper';
+import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
import { stubExperiments } from 'helpers/experimentation_helper';
import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue';
import ExperimentTracking from '~/experimentation/experiment_tracking';
-import PipelinesCiTemplate from '~/pipelines/components/pipelines_list/pipelines_ci_templates.vue';
+import PipelinesCiTemplates from '~/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue';
+import CiTemplates from '~/pipelines/components/pipelines_list/empty_state/ci_templates.vue';
import {
RUNNERS_AVAILABILITY_SECTION_EXPERIMENT_NAME,
RUNNERS_SETTINGS_LINK_CLICKED_EVENT,
@@ -16,11 +16,6 @@ import {
} from '~/pipeline_editor/constants';
const pipelineEditorPath = '/-/ci/editor';
-const suggestedCiTemplates = [
- { name: 'Android', logo: '/assets/illustrations/logos/android.svg' },
- { name: 'Bash', logo: '/assets/illustrations/logos/bash.svg' },
- { name: 'C++', logo: '/assets/illustrations/logos/c_plus_plus.svg' },
-];
jest.mock('~/experimentation/experiment_tracking');
@@ -29,21 +24,17 @@ describe('Pipelines CI Templates', () => {
let trackingSpy;
const createWrapper = (propsData = {}, stubs = {}) => {
- return shallowMountExtended(PipelinesCiTemplate, {
+ return shallowMountExtended(PipelinesCiTemplates, {
provide: {
pipelineEditorPath,
- suggestedCiTemplates,
},
propsData,
stubs,
});
};
- const findTestTemplateLinks = () => wrapper.findAll('[data-testid="test-template-link"]');
- const findTemplateDescriptions = () => wrapper.findAll('[data-testid="template-description"]');
- const findTemplateLinks = () => wrapper.findAll('[data-testid="template-link"]');
- const findTemplateNames = () => wrapper.findAll('[data-testid="template-name"]');
- const findTemplateLogos = () => wrapper.findAll('[data-testid="template-logo"]');
+ const findTestTemplateLink = () => wrapper.findByTestId('test-template-link');
+ const findCiTemplates = () => wrapper.findComponent(CiTemplates);
const findSettingsLink = () => wrapper.findByTestId('settings-link');
const findDocumentationLink = () => wrapper.findByTestId('documentation-link');
const findSettingsButton = () => wrapper.findByTestId('settings-button');
@@ -59,63 +50,24 @@ describe('Pipelines CI Templates', () => {
});
it('links to the getting started template', () => {
- expect(findTestTemplateLinks().at(0).attributes('href')).toBe(
+ expect(findTestTemplateLink().attributes('href')).toBe(
pipelineEditorPath.concat('?template=Getting-Started'),
);
});
});
- describe('renders template list', () => {
- beforeEach(() => {
- wrapper = createWrapper();
- });
-
- it('renders all suggested templates', () => {
- const content = wrapper.text();
-
- expect(content).toContain('Android', 'Bash', 'C++');
- });
-
- it('has the correct template name', () => {
- expect(findTemplateNames().at(0).text()).toBe('Android');
- });
-
- it('links to the correct template', () => {
- expect(findTemplateLinks().at(0).attributes('href')).toBe(
- pipelineEditorPath.concat('?template=Android'),
- );
- });
-
- it('has the description of the template', () => {
- expect(findTemplateDescriptions().at(0).text()).toBe(
- sprintf(I18N.templates.description, { name: 'Android' }),
- );
- });
-
- it('has the right logo of the template', () => {
- expect(findTemplateLogos().at(0).attributes('src')).toBe(
- '/assets/illustrations/logos/android.svg',
- );
- });
- });
-
describe('tracking', () => {
beforeEach(() => {
wrapper = createWrapper();
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
});
- it('sends an event when template is clicked', () => {
- findTemplateLinks().at(0).vm.$emit('click');
-
- expect(trackingSpy).toHaveBeenCalledTimes(1);
- expect(trackingSpy).toHaveBeenCalledWith(undefined, 'template_clicked', {
- label: 'Android',
- });
+ afterEach(() => {
+ unmockTracking();
});
it('sends an event when Getting-Started template is clicked', () => {
- findTestTemplateLinks().at(0).vm.$emit('click');
+ findTestTemplateLink().vm.$emit('click');
expect(trackingSpy).toHaveBeenCalledTimes(1);
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'template_clicked', {
@@ -198,8 +150,8 @@ describe('Pipelines CI Templates', () => {
});
it(`renders the templates: ${templatesRendered}`, () => {
- expect(findTestTemplateLinks().exists()).toBe(templatesRendered);
- expect(findTemplateLinks().exists()).toBe(templatesRendered);
+ expect(findTestTemplateLink().exists()).toBe(templatesRendered);
+ expect(findCiTemplates().exists()).toBe(templatesRendered);
});
},
);
diff --git a/spec/frontend/pipelines/empty_state_spec.js b/spec/frontend/pipelines/empty_state_spec.js
index 31b74a06efd..46dad4a035c 100644
--- a/spec/frontend/pipelines/empty_state_spec.js
+++ b/spec/frontend/pipelines/empty_state_spec.js
@@ -1,7 +1,7 @@
import '~/commons';
import { mount } from '@vue/test-utils';
import EmptyState from '~/pipelines/components/pipelines_list/empty_state.vue';
-import PipelinesCiTemplates from '~/pipelines/components/pipelines_list/pipelines_ci_templates.vue';
+import PipelinesCiTemplates from '~/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue';
describe('Pipelines Empty State', () => {
let wrapper;
diff --git a/spec/frontend/pipelines/graph/action_component_spec.js b/spec/frontend/pipelines/graph/action_component_spec.js
index fab6e6887b7..6e5aa572ec0 100644
--- a/spec/frontend/pipelines/graph/action_component_spec.js
+++ b/spec/frontend/pipelines/graph/action_component_spec.js
@@ -48,17 +48,14 @@ describe('pipeline graph action component', () => {
});
describe('on click', () => {
- it('emits `pipelineActionRequestComplete` after a successful request', (done) => {
+ it('emits `pipelineActionRequestComplete` after a successful request', async () => {
jest.spyOn(wrapper.vm, '$emit');
findButton().trigger('click');
- waitForPromises()
- .then(() => {
- expect(wrapper.vm.$emit).toHaveBeenCalledWith('pipelineActionRequestComplete');
- done();
- })
- .catch(done.fail);
+ await waitForPromises();
+
+ expect(wrapper.vm.$emit).toHaveBeenCalledWith('pipelineActionRequestComplete');
});
it('renders a loading icon while waiting for request', async () => {
diff --git a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js
index 8bc6c086b9d..cb7073fb5f5 100644
--- a/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js
+++ b/spec/frontend/pipelines/graph/graph_component_wrapper_spec.js
@@ -30,6 +30,7 @@ import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue';
import * as parsingUtils from '~/pipelines/components/parsing_utils';
import getPipelineHeaderData from '~/pipelines/graphql/queries/get_pipeline_header_data.query.graphql';
import * as sentryUtils from '~/pipelines/utils';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import { mockRunningPipelineHeaderData } from '../mock_data';
import { mapCallouts, mockCalloutsResponse, mockPipelineResponse } from './mock_data';
@@ -55,6 +56,7 @@ describe('Pipeline graph wrapper', () => {
wrapper.find(StageColumnComponent).findAll('[data-testid="stage-column-group"]');
const getViewSelector = () => wrapper.find(GraphViewSelector);
const getViewSelectorTrip = () => getViewSelector().findComponent(GlAlert);
+ const getLocalStorageSync = () => wrapper.findComponent(LocalStorageSync);
const createComponent = ({
apolloProvider,
@@ -376,6 +378,10 @@ describe('Pipeline graph wrapper', () => {
localStorage.clear();
});
+ it('sets the asString prop on the LocalStorageSync component', () => {
+ expect(getLocalStorageSync().props('asString')).toBe(true);
+ });
+
it('reads the view type from localStorage when available', () => {
const viewSelectorNeedsSegment = wrapper
.find(GlButtonGroup)
diff --git a/spec/frontend/pipelines/pipeline_triggerer_spec.js b/spec/frontend/pipelines/pipeline_triggerer_spec.js
index 701b1691c7b..58bfb68e85c 100644
--- a/spec/frontend/pipelines/pipeline_triggerer_spec.js
+++ b/spec/frontend/pipelines/pipeline_triggerer_spec.js
@@ -1,17 +1,11 @@
-import { shallowMount } from '@vue/test-utils';
-import { nextTick } from 'vue';
+import { GlAvatar, GlAvatarLink } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import pipelineTriggerer from '~/pipelines/components/pipelines_list/pipeline_triggerer.vue';
-import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
describe('Pipelines Triggerer', () => {
let wrapper;
- const expectComponentWithProps = (Component, props = {}) => {
- const componentWrapper = wrapper.find(Component);
- expect(componentWrapper.isVisible()).toBe(true);
- expect(componentWrapper.props()).toEqual(expect.objectContaining(props));
- };
-
const mockData = {
pipeline: {
user: {
@@ -22,40 +16,65 @@ describe('Pipelines Triggerer', () => {
},
};
- const createComponent = () => {
- wrapper = shallowMount(pipelineTriggerer, {
- propsData: mockData,
+ const createComponent = (props) => {
+ wrapper = shallowMountExtended(pipelineTriggerer, {
+ propsData: {
+ ...props,
+ },
+ directives: {
+ GlTooltip: createMockDirective(),
+ },
});
};
- beforeEach(() => {
- createComponent();
- });
-
afterEach(() => {
wrapper.destroy();
});
- it('should render pipeline triggerer table cell', () => {
- expect(wrapper.find('[data-testid="pipeline-triggerer"]').exists()).toBe(true);
- });
+ const findAvatarLink = () => wrapper.findComponent(GlAvatarLink);
+ const findAvatar = () => wrapper.findComponent(GlAvatar);
+ const findTriggerer = () => wrapper.findByText('API');
+
+ describe('when user was a triggerer', () => {
+ beforeEach(() => {
+ createComponent(mockData);
+ });
+
+ it('should render pipeline triggerer table cell', () => {
+ expect(wrapper.find('[data-testid="pipeline-triggerer"]').exists()).toBe(true);
+ });
+
+ it('should render only user avatar', () => {
+ expect(findAvatarLink().exists()).toBe(true);
+ expect(findTriggerer().exists()).toBe(false);
+ });
+
+ it('should set correct props on avatar link component', () => {
+ expect(findAvatarLink().attributes()).toMatchObject({
+ title: mockData.pipeline.user.name,
+ href: mockData.pipeline.user.path,
+ });
+ });
- it('should pass triggerer information when triggerer is provided', () => {
- expectComponentWithProps(UserAvatarLink, {
- linkHref: mockData.pipeline.user.path,
- tooltipText: mockData.pipeline.user.name,
- imgSrc: mockData.pipeline.user.avatar_url,
+ it('should add tooltip to avatar link', () => {
+ const tooltip = getBinding(findAvatarLink().element, 'gl-tooltip');
+
+ expect(tooltip).toBeDefined();
+ });
+
+ it('should set correct props on avatar component', () => {
+ expect(findAvatar().attributes().src).toBe(mockData.pipeline.user.avatar_url);
});
});
- it('should render "API" when no triggerer is provided', async () => {
- wrapper.setProps({
- pipeline: {
- user: null,
- },
+ describe('when API was a triggerer', () => {
+ beforeEach(() => {
+ createComponent({ pipeline: {} });
});
- await nextTick();
- expect(wrapper.find('.js-pipeline-url-api').text()).toEqual('API');
+ it('should render label only', () => {
+ expect(findAvatarLink().exists()).toBe(false);
+ expect(findTriggerer().exists()).toBe(true);
+ });
});
});
diff --git a/spec/frontend/pipelines/pipeline_url_spec.js b/spec/frontend/pipelines/pipeline_url_spec.js
index 2a0aeed917c..c6104a13216 100644
--- a/spec/frontend/pipelines/pipeline_url_spec.js
+++ b/spec/frontend/pipelines/pipeline_url_spec.js
@@ -1,5 +1,6 @@
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import PipelineUrlComponent from '~/pipelines/components/pipelines_list/pipeline_url.vue';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import { mockPipeline, mockPipelineBranch, mockPipelineTag } from './mock_data';
const projectPath = 'test/test';
@@ -57,6 +58,30 @@ describe('Pipeline Url Component', () => {
expect(findCommitShortSha().exists()).toBe(true);
});
+ describe('commit user avatar', () => {
+ it('renders when commit author exists', () => {
+ const pipelineBranch = mockPipelineBranch();
+ const { avatar_url, name, path } = pipelineBranch.pipeline.commit.author;
+ createComponent(pipelineBranch);
+
+ const component = wrapper.findComponent(UserAvatarLink);
+ expect(component.exists()).toBe(true);
+ expect(component.props()).toMatchObject({
+ imgSize: 16,
+ imgSrc: avatar_url,
+ imgAlt: name,
+ linkHref: path,
+ tooltipText: name,
+ });
+ });
+
+ it('does not render when commit author does not exist', () => {
+ createComponent();
+
+ expect(wrapper.findComponent(UserAvatarLink).exists()).toBe(false);
+ });
+ });
+
it('should render commit icon tooltip', () => {
createComponent({}, true);
diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js
index 20ed12cd1f5..d2b30c93746 100644
--- a/spec/frontend/pipelines/pipelines_spec.js
+++ b/spec/frontend/pipelines/pipelines_spec.js
@@ -14,7 +14,7 @@ import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import NavigationControls from '~/pipelines/components/pipelines_list/nav_controls.vue';
import PipelinesComponent from '~/pipelines/components/pipelines_list/pipelines.vue';
-import PipelinesCiTemplates from '~/pipelines/components/pipelines_list/pipelines_ci_templates.vue';
+import PipelinesCiTemplates from '~/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue';
import PipelinesTableComponent from '~/pipelines/components/pipelines_list/pipelines_table.vue';
import { RAW_TEXT_WARNING } from '~/pipelines/constants';
import Store from '~/pipelines/stores/pipelines_store';
diff --git a/spec/frontend/pipelines/test_reports/stores/actions_spec.js b/spec/frontend/pipelines/test_reports/stores/actions_spec.js
index 84a9f4776b9..d5acb115bc1 100644
--- a/spec/frontend/pipelines/test_reports/stores/actions_spec.js
+++ b/spec/frontend/pipelines/test_reports/stores/actions_spec.js
@@ -38,29 +38,25 @@ describe('Actions TestReports Store', () => {
mock.onGet(summaryEndpoint).replyOnce(200, summary, {});
});
- it('sets testReports and shows tests', (done) => {
- testAction(
+ it('sets testReports and shows tests', () => {
+ return testAction(
actions.fetchSummary,
null,
state,
[{ type: types.SET_SUMMARY, payload: summary }],
[{ type: 'toggleLoading' }, { type: 'toggleLoading' }],
- done,
);
});
- it('should create flash on API error', (done) => {
- testAction(
+ it('should create flash on API error', async () => {
+ await testAction(
actions.fetchSummary,
null,
{ summaryEndpoint: null },
[],
[{ type: 'toggleLoading' }, { type: 'toggleLoading' }],
- () => {
- expect(createFlash).toHaveBeenCalled();
- done();
- },
);
+ expect(createFlash).toHaveBeenCalled();
});
});
@@ -73,87 +69,80 @@ describe('Actions TestReports Store', () => {
.replyOnce(200, testReports.test_suites[0], {});
});
- it('sets test suite and shows tests', (done) => {
+ it('sets test suite and shows tests', () => {
const suite = testReports.test_suites[0];
const index = 0;
- testAction(
+ return testAction(
actions.fetchTestSuite,
index,
{ ...state, testReports },
[{ type: types.SET_SUITE, payload: { suite, index } }],
[{ type: 'toggleLoading' }, { type: 'toggleLoading' }],
- done,
);
});
- it('should create flash on API error', (done) => {
+ it('should create flash on API error', async () => {
const index = 0;
- testAction(
+ await testAction(
actions.fetchTestSuite,
index,
{ ...state, testReports, suiteEndpoint: null },
[],
[{ type: 'toggleLoading' }, { type: 'toggleLoading' }],
- () => {
- expect(createFlash).toHaveBeenCalled();
- done();
- },
);
+ expect(createFlash).toHaveBeenCalled();
});
describe('when we already have the suite data', () => {
- it('should not fetch suite', (done) => {
+ it('should not fetch suite', () => {
const index = 0;
testReports.test_suites[0].hasFullSuite = true;
- testAction(actions.fetchTestSuite, index, { ...state, testReports }, [], [], done);
+ return testAction(actions.fetchTestSuite, index, { ...state, testReports }, [], []);
});
});
});
describe('set selected suite index', () => {
- it('sets selectedSuiteIndex', (done) => {
+ it('sets selectedSuiteIndex', () => {
const selectedSuiteIndex = 0;
- testAction(
+ return testAction(
actions.setSelectedSuiteIndex,
selectedSuiteIndex,
{ ...state, hasFullReport: true },
[{ type: types.SET_SELECTED_SUITE_INDEX, payload: selectedSuiteIndex }],
[],
- done,
);
});
});
describe('remove selected suite index', () => {
- it('sets selectedSuiteIndex to null', (done) => {
- testAction(
+ it('sets selectedSuiteIndex to null', () => {
+ return testAction(
actions.removeSelectedSuiteIndex,
{},
state,
[{ type: types.SET_SELECTED_SUITE_INDEX, payload: null }],
[],
- done,
);
});
});
describe('toggles loading', () => {
- it('sets isLoading to true', (done) => {
- testAction(actions.toggleLoading, {}, state, [{ type: types.TOGGLE_LOADING }], [], done);
+ it('sets isLoading to true', () => {
+ return testAction(actions.toggleLoading, {}, state, [{ type: types.TOGGLE_LOADING }], []);
});
- it('toggles isLoading to false', (done) => {
- testAction(
+ it('toggles isLoading to false', () => {
+ return testAction(
actions.toggleLoading,
{},
{ ...state, isLoading: true },
[{ type: types.TOGGLE_LOADING }],
[],
- done,
);
});
});
diff --git a/spec/frontend/profile/add_ssh_key_validation_spec.js b/spec/frontend/profile/add_ssh_key_validation_spec.js
index a6bcca0ccb3..730a94592a7 100644
--- a/spec/frontend/profile/add_ssh_key_validation_spec.js
+++ b/spec/frontend/profile/add_ssh_key_validation_spec.js
@@ -1,4 +1,4 @@
-import AddSshKeyValidation from '../../../app/assets/javascripts/profile/add_ssh_key_validation';
+import AddSshKeyValidation from '~/profile/add_ssh_key_validation';
describe('AddSshKeyValidation', () => {
describe('submit', () => {
diff --git a/spec/frontend/profile/preferences/components/__snapshots__/diffs_colors_preview_spec.js.snap b/spec/frontend/profile/preferences/components/__snapshots__/diffs_colors_preview_spec.js.snap
new file mode 100644
index 00000000000..3025a2f87ae
--- /dev/null
+++ b/spec/frontend/profile/preferences/components/__snapshots__/diffs_colors_preview_spec.js.snap
@@ -0,0 +1,915 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`DiffsColorsPreview component renders diff colors preview 1`] = `
+<div
+ class="form-group"
+>
+ <label>
+ Preview
+ </label>
+
+ <table
+ class="code"
+ >
+ <tbody>
+ <tr
+ class="line_holder parallel"
+ >
+ <td
+ class="old_line diff-line-num old"
+ >
+ <a
+ data-linenumber="1"
+ />
+ </td>
+
+ <td
+ class="line_content parallel left-side old"
+ >
+ <span>
+ <span
+ class="c1"
+ >
+ #
+ <span
+ class="idiff deletion"
+ >
+ Removed
+ </span>
+ content
+ </span>
+ </span>
+ </td>
+
+ <td
+ class="new_line diff-line-num new"
+ >
+ <a
+ data-linenumber="1"
+ />
+ </td>
+
+ <td
+ class="line_content parallel right-side new"
+ >
+ <span>
+ <span
+ class="c1"
+ >
+ #
+ <span
+ class="idiff addition"
+ >
+ Added
+ </span>
+ content
+ </span>
+ </span>
+ </td>
+ </tr>
+
+ <tr
+ class="line_holder parallel"
+ >
+ <td
+ class="old_line diff-line-num old"
+ >
+ <a
+ data-linenumber="2"
+ />
+ </td>
+
+ <td
+ class="line_content parallel left-side old"
+ >
+ <span>
+ <span
+ class="n"
+ >
+ v
+ </span>
+
+ <span
+ class="o"
+ >
+ =
+ </span>
+
+ <span
+ class="mi"
+ >
+ 1
+ </span>
+ </span>
+ </td>
+
+ <td
+ class="new_line diff-line-num new"
+ >
+ <a
+ data-linenumber="2"
+ />
+ </td>
+
+ <td
+ class="line_content parallel right-side new"
+ >
+ <span>
+ <span
+ class="n"
+ >
+ v
+ </span>
+
+ <span
+ class="o"
+ >
+ =
+ </span>
+
+ <span
+ class="mi"
+ >
+ 1
+ </span>
+ </span>
+ </td>
+ </tr>
+
+ <tr
+ class="line_holder parallel"
+ >
+ <td
+ class="old_line diff-line-num old"
+ >
+ <a
+ data-linenumber="3"
+ />
+ </td>
+
+ <td
+ class="line_content parallel left-side old"
+ >
+ <span>
+ <span
+ class="n"
+ >
+ s
+ </span>
+
+ <span
+ class="o"
+ >
+ =
+ </span>
+
+ <span
+ class="s"
+ >
+ "string"
+ </span>
+ </span>
+ </td>
+
+ <td
+ class="new_line diff-line-num new"
+ >
+ <a
+ data-linenumber="3"
+ />
+ </td>
+
+ <td
+ class="line_content parallel right-side new"
+ >
+ <span>
+ <span
+ class="n"
+ >
+ s
+ </span>
+
+ <span
+ class="o"
+ >
+ =
+ </span>
+
+ <span
+ class="s"
+ >
+ "string"
+ </span>
+ </span>
+ </td>
+ </tr>
+
+ <tr
+ class="line_holder parallel"
+ >
+ <td
+ class="old_line diff-line-num old"
+ >
+ <a
+ data-linenumber="4"
+ />
+ </td>
+
+ <td
+ class="line_content parallel left-side old"
+ >
+ <span />
+ </td>
+
+ <td
+ class="new_line diff-line-num new"
+ >
+ <a
+ data-linenumber="4"
+ />
+ </td>
+
+ <td
+ class="line_content parallel right-side new"
+ >
+ <span />
+ </td>
+ </tr>
+
+ <tr
+ class="line_holder parallel"
+ >
+ <td
+ class="old_line diff-line-num old"
+ >
+ <a
+ data-linenumber="5"
+ />
+ </td>
+
+ <td
+ class="line_content parallel left-side old"
+ >
+ <span>
+ <span
+ class="k"
+ >
+ for
+ </span>
+
+ <span
+ class="n"
+ >
+ i
+ </span>
+
+ <span
+ class="ow"
+ >
+ in
+ </span>
+
+ <span
+ class="nb"
+ >
+ range
+ </span>
+ <span
+ class="p"
+ >
+ (
+ </span>
+ <span
+ class="o"
+ >
+ -
+ </span>
+ <span
+ class="mi"
+ >
+ 10
+ </span>
+ <span
+ class="p"
+ >
+ ,
+ </span>
+
+ <span
+ class="mi"
+ >
+ 10
+ </span>
+ <span
+ class="p"
+ >
+ ):
+ </span>
+ </span>
+ </td>
+
+ <td
+ class="new_line diff-line-num new"
+ >
+ <a
+ data-linenumber="5"
+ />
+ </td>
+
+ <td
+ class="line_content parallel right-side new"
+ >
+ <span>
+ <span
+ class="k"
+ >
+ for
+ </span>
+
+ <span
+ class="n"
+ >
+ i
+ </span>
+
+ <span
+ class="ow"
+ >
+ in
+ </span>
+
+ <span
+ class="nb"
+ >
+ range
+ </span>
+ <span
+ class="p"
+ >
+ (
+ </span>
+ <span
+ class="o"
+ >
+ -
+ </span>
+ <span
+ class="mi"
+ >
+ 10
+ </span>
+ <span
+ class="p"
+ >
+ ,
+ </span>
+
+ <span
+ class="mi"
+ >
+ 10
+ </span>
+ <span
+ class="p"
+ >
+ ):
+ </span>
+ </span>
+ </td>
+ </tr>
+
+ <tr
+ class="line_holder parallel"
+ >
+ <td
+ class="old_line diff-line-num old"
+ >
+ <a
+ data-linenumber="6"
+ />
+ </td>
+
+ <td
+ class="line_content parallel left-side old"
+ >
+ <span>
+ <span>
+
+ </span>
+
+ <span
+ class="k"
+ >
+ print
+ </span>
+ <span
+ class="p"
+ >
+ (
+ </span>
+ <span
+ class="n"
+ >
+ i
+ </span>
+
+ <span
+ class="o"
+ >
+ +
+ </span>
+
+ <span
+ class="mi"
+ >
+ 1
+ </span>
+ <span
+ class="p"
+ >
+ )
+ </span>
+ </span>
+ </td>
+
+ <td
+ class="new_line diff-line-num new"
+ >
+ <a
+ data-linenumber="6"
+ />
+ </td>
+
+ <td
+ class="line_content parallel right-side new"
+ >
+ <span>
+ <span>
+
+ </span>
+
+ <span
+ class="k"
+ >
+ print
+ </span>
+ <span
+ class="p"
+ >
+ (
+ </span>
+ <span
+ class="n"
+ >
+ i
+ </span>
+
+ <span
+ class="o"
+ >
+ +
+ </span>
+
+ <span
+ class="mi"
+ >
+ 1
+ </span>
+ <span
+ class="p"
+ >
+ )
+ </span>
+ </span>
+ </td>
+ </tr>
+
+ <tr
+ class="line_holder parallel"
+ >
+ <td
+ class="old_line diff-line-num old"
+ >
+ <a
+ data-linenumber="7"
+ />
+ </td>
+
+ <td
+ class="line_content parallel left-side old"
+ >
+ <span />
+ </td>
+
+ <td
+ class="new_line diff-line-num new"
+ >
+ <a
+ data-linenumber="7"
+ />
+ </td>
+
+ <td
+ class="line_content parallel right-side new"
+ >
+ <span />
+ </td>
+ </tr>
+
+ <tr
+ class="line_holder parallel"
+ >
+ <td
+ class="old_line diff-line-num old"
+ >
+ <a
+ data-linenumber="8"
+ />
+ </td>
+
+ <td
+ class="line_content parallel left-side old"
+ >
+ <span>
+ <span
+ class="k"
+ >
+ class
+ </span>
+
+ <span
+ class="nc"
+ >
+ LinkedList
+ </span>
+ <span
+ class="p"
+ >
+ (
+ </span>
+ <span
+ class="nb"
+ >
+ object
+ </span>
+ <span
+ class="p"
+ >
+ ):
+ </span>
+ </span>
+ </td>
+
+ <td
+ class="new_line diff-line-num new"
+ >
+ <a
+ data-linenumber="8"
+ />
+ </td>
+
+ <td
+ class="line_content parallel right-side new"
+ >
+ <span>
+ <span
+ class="k"
+ >
+ class
+ </span>
+
+ <span
+ class="nc"
+ >
+ LinkedList
+ </span>
+ <span
+ class="p"
+ >
+ (
+ </span>
+ <span
+ class="nb"
+ >
+ object
+ </span>
+ <span
+ class="p"
+ >
+ ):
+ </span>
+ </span>
+ </td>
+ </tr>
+
+ <tr
+ class="line_holder parallel"
+ >
+ <td
+ class="old_line diff-line-num old"
+ >
+ <a
+ data-linenumber="9"
+ />
+ </td>
+
+ <td
+ class="line_content parallel left-side old"
+ >
+ <span>
+ <span>
+
+ </span>
+
+ <span
+ class="k"
+ >
+ def
+ </span>
+
+ <span
+ class="nf"
+ >
+ __init__
+ </span>
+ <span
+ class="p"
+ >
+ (
+ </span>
+ <span
+ class="bp"
+ >
+ self
+ </span>
+ <span
+ class="p"
+ >
+ ,
+ </span>
+
+ <span
+ class="n"
+ >
+ x
+ </span>
+ <span
+ class="p"
+ >
+ ):
+ </span>
+ </span>
+ </td>
+
+ <td
+ class="new_line diff-line-num new"
+ >
+ <a
+ data-linenumber="9"
+ />
+ </td>
+
+ <td
+ class="line_content parallel right-side new"
+ >
+ <span>
+ <span>
+
+ </span>
+
+ <span
+ class="k"
+ >
+ def
+ </span>
+
+ <span
+ class="nf"
+ >
+ __init__
+ </span>
+ <span
+ class="p"
+ >
+ (
+ </span>
+ <span
+ class="bp"
+ >
+ self
+ </span>
+ <span
+ class="p"
+ >
+ ,
+ </span>
+
+ <span
+ class="n"
+ >
+ x
+ </span>
+ <span
+ class="p"
+ >
+ ):
+ </span>
+ </span>
+ </td>
+ </tr>
+
+ <tr
+ class="line_holder parallel"
+ >
+ <td
+ class="old_line diff-line-num old"
+ >
+ <a
+ data-linenumber="10"
+ />
+ </td>
+
+ <td
+ class="line_content parallel left-side old"
+ >
+ <span>
+ <span>
+
+ </span>
+
+ <span
+ class="bp"
+ >
+ self
+ </span>
+ <span
+ class="p"
+ >
+ .
+ </span>
+ <span
+ class="n"
+ >
+ val
+ </span>
+
+ <span
+ class="o"
+ >
+ =
+ </span>
+
+ <span
+ class="n"
+ >
+ x
+ </span>
+ </span>
+ </td>
+
+ <td
+ class="new_line diff-line-num new"
+ >
+ <a
+ data-linenumber="10"
+ />
+ </td>
+
+ <td
+ class="line_content parallel right-side new"
+ >
+ <span>
+ <span>
+
+ </span>
+
+ <span
+ class="bp"
+ >
+ self
+ </span>
+ <span
+ class="p"
+ >
+ .
+ </span>
+ <span
+ class="n"
+ >
+ val
+ </span>
+
+ <span
+ class="o"
+ >
+ =
+ </span>
+
+ <span
+ class="n"
+ >
+ x
+ </span>
+ </span>
+ </td>
+ </tr>
+
+ <tr
+ class="line_holder parallel"
+ >
+ <td
+ class="old_line diff-line-num old"
+ >
+ <a
+ data-linenumber="11"
+ />
+ </td>
+
+ <td
+ class="line_content parallel left-side old"
+ >
+ <span>
+ <span>
+
+ </span>
+
+ <span
+ class="bp"
+ >
+ self
+ </span>
+ <span
+ class="p"
+ >
+ .
+ </span>
+ <span
+ class="nb"
+ >
+ next
+ </span>
+
+ <span
+ class="o"
+ >
+ =
+ </span>
+
+ <span
+ class="bp"
+ >
+ None
+ </span>
+ </span>
+ </td>
+
+ <td
+ class="new_line diff-line-num new"
+ >
+ <a
+ data-linenumber="11"
+ />
+ </td>
+
+ <td
+ class="line_content parallel right-side new"
+ >
+ <span>
+ <span>
+
+ </span>
+
+ <span
+ class="bp"
+ >
+ self
+ </span>
+ <span
+ class="p"
+ >
+ .
+ </span>
+ <span
+ class="nb"
+ >
+ next
+ </span>
+
+ <span
+ class="o"
+ >
+ =
+ </span>
+
+ <span
+ class="bp"
+ >
+ None
+ </span>
+ </span>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+</div>
+`;
diff --git a/spec/frontend/profile/preferences/components/diffs_colors_preview_spec.js b/spec/frontend/profile/preferences/components/diffs_colors_preview_spec.js
new file mode 100644
index 00000000000..e60602ab336
--- /dev/null
+++ b/spec/frontend/profile/preferences/components/diffs_colors_preview_spec.js
@@ -0,0 +1,23 @@
+import { shallowMount } from '@vue/test-utils';
+import DiffsColorsPreview from '~/profile/preferences/components/diffs_colors_preview.vue';
+
+describe('DiffsColorsPreview component', () => {
+ let wrapper;
+
+ function createComponent() {
+ wrapper = shallowMount(DiffsColorsPreview);
+ }
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('renders diff colors preview', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+});
diff --git a/spec/frontend/profile/preferences/components/diffs_colors_spec.js b/spec/frontend/profile/preferences/components/diffs_colors_spec.js
new file mode 100644
index 00000000000..02f501a0b06
--- /dev/null
+++ b/spec/frontend/profile/preferences/components/diffs_colors_spec.js
@@ -0,0 +1,153 @@
+import { shallowMount } from '@vue/test-utils';
+import { s__ } from '~/locale';
+import ColorPicker from '~/vue_shared/components/color_picker/color_picker.vue';
+import DiffsColors from '~/profile/preferences/components/diffs_colors.vue';
+import DiffsColorsPreview from '~/profile/preferences/components/diffs_colors_preview.vue';
+import * as CssUtils from '~/lib/utils/css_utils';
+
+describe('DiffsColors component', () => {
+ let wrapper;
+
+ const defaultInjectedProps = {
+ addition: '#00ff00',
+ deletion: '#ff0000',
+ };
+
+ const initialSuggestedColors = {
+ '#d99530': s__('SuggestedColors|Orange'),
+ '#1f75cb': s__('SuggestedColors|Blue'),
+ };
+
+ const findColorPickers = () => wrapper.findAllComponents(ColorPicker);
+
+ function createComponent(provide = {}) {
+ wrapper = shallowMount(DiffsColors, {
+ provide: {
+ ...defaultInjectedProps,
+ ...provide,
+ },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('mounts', () => {
+ createComponent();
+
+ expect(wrapper.exists()).toBe(true);
+ });
+
+ describe('preview', () => {
+ it('should render preview', () => {
+ createComponent();
+
+ expect(wrapper.findComponent(DiffsColorsPreview).exists()).toBe(true);
+ });
+
+ it('should set preview classes', () => {
+ createComponent();
+
+ expect(wrapper.attributes('class')).toBe(
+ 'diff-custom-addition-color diff-custom-deletion-color',
+ );
+ });
+
+ it.each([
+ [{ addition: null }, 'diff-custom-deletion-color'],
+ [{ deletion: null }, 'diff-custom-addition-color'],
+ ])('should not set preview class if color not set', (provide, expectedClass) => {
+ createComponent(provide);
+
+ expect(wrapper.attributes('class')).toBe(expectedClass);
+ });
+
+ it.each([
+ [{}, '--diff-deletion-color: rgba(255,0,0,0.2); --diff-addition-color: rgba(0,255,0,0.2);'],
+ [{ addition: null }, '--diff-deletion-color: rgba(255,0,0,0.2);'],
+ [{ deletion: null }, '--diff-addition-color: rgba(0,255,0,0.2);'],
+ ])('should set correct CSS variables', (provide, expectedStyle) => {
+ createComponent(provide);
+
+ expect(wrapper.attributes('style')).toBe(expectedStyle);
+ });
+ });
+
+ describe('color pickers', () => {
+ it('should render both color pickers', () => {
+ createComponent();
+
+ const colorPickers = findColorPickers();
+
+ expect(colorPickers.length).toBe(2);
+ expect(colorPickers.at(0).props()).toMatchObject({
+ label: s__('Preferences|Color for removed lines'),
+ value: '#ff0000',
+ state: true,
+ });
+ expect(colorPickers.at(1).props()).toMatchObject({
+ label: s__('Preferences|Color for added lines'),
+ value: '#00ff00',
+ state: true,
+ });
+ });
+
+ describe('suggested colors', () => {
+ const suggestedColors = () => findColorPickers().at(0).props('suggestedColors');
+
+ it('contains initial suggested colors', () => {
+ createComponent();
+
+ expect(suggestedColors()).toMatchObject(initialSuggestedColors);
+ });
+
+ it('contains default diff colors of theme', () => {
+ jest.spyOn(CssUtils, 'getCssVariable').mockImplementation((variable) => {
+ if (variable === '--default-diff-color-addition') return '#111111';
+ if (variable === '--default-diff-color-deletion') return '#222222';
+ return '#000000';
+ });
+
+ createComponent();
+
+ expect(suggestedColors()).toMatchObject({
+ '#111111': s__('SuggestedColors|Default addition color'),
+ '#222222': s__('SuggestedColors|Default removal color'),
+ });
+ });
+
+ it('contains current diff colors if set', () => {
+ createComponent();
+
+ expect(suggestedColors()).toMatchObject({
+ [defaultInjectedProps.addition]: s__('SuggestedColors|Current addition color'),
+ [defaultInjectedProps.deletion]: s__('SuggestedColors|Current removal color'),
+ });
+ });
+
+ it.each([
+ [
+ { addition: null },
+ s__('SuggestedColors|Current removal color'),
+ s__('SuggestedColors|Current addition color'),
+ ],
+ [
+ { deletion: null },
+ s__('SuggestedColors|Current addition color'),
+ s__('SuggestedColors|Current removal color'),
+ ],
+ ])(
+ 'does not contain current diff color if not set %p',
+ (provide, expectedToContain, expectNotToContain) => {
+ createComponent(provide);
+
+ const suggestedColorsLabels = Object.values(suggestedColors());
+ expect(suggestedColorsLabels).toContain(expectedToContain);
+ expect(suggestedColorsLabels).not.toContain(expectNotToContain);
+ },
+ );
+ });
+ });
+});
diff --git a/spec/frontend/profile/preferences/components/integration_view_spec.js b/spec/frontend/profile/preferences/components/integration_view_spec.js
index 6ab0c70298c..92c53b8c91b 100644
--- a/spec/frontend/profile/preferences/components/integration_view_spec.js
+++ b/spec/frontend/profile/preferences/components/integration_view_spec.js
@@ -1,5 +1,5 @@
-import { GlFormText } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
+import { GlFormGroup } from '@gitlab/ui';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import IntegrationView from '~/profile/preferences/components/integration_view.vue';
@@ -21,7 +21,7 @@ describe('IntegrationView component', () => {
function createComponent(options = {}) {
const { props = {}, provide = {} } = options;
- return shallowMount(IntegrationView, {
+ return mountExtended(IntegrationView, {
provide: {
userFields,
...provide,
@@ -33,28 +33,20 @@ describe('IntegrationView component', () => {
});
}
- function findCheckbox() {
- return wrapper.find('[data-testid="profile-preferences-integration-checkbox"]');
- }
- function findFormGroup() {
- return wrapper.find('[data-testid="profile-preferences-integration-form-group"]');
- }
- function findHiddenField() {
- return wrapper.find('[data-testid="profile-preferences-integration-hidden-field"]');
- }
- function findFormGroupLabel() {
- return wrapper.find('[data-testid="profile-preferences-integration-form-group"] label');
- }
+ const findCheckbox = () => wrapper.findByLabelText(new RegExp(defaultProps.config.label));
+ const findFormGroup = () => wrapper.findComponent(GlFormGroup);
+ const findHiddenField = () =>
+ wrapper.findByTestId('profile-preferences-integration-hidden-field');
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
- it('should render the title correctly', () => {
+ it('should render the form group legend correctly', () => {
wrapper = createComponent();
- expect(wrapper.find('label.label-bold').text()).toBe('Foo');
+ expect(wrapper.findByText(defaultProps.config.title).exists()).toBe(true);
});
it('should render the form correctly', () => {
@@ -106,13 +98,6 @@ describe('IntegrationView component', () => {
it('should render the help text', () => {
wrapper = createComponent();
- expect(wrapper.find(GlFormText).exists()).toBe(true);
expect(wrapper.find(IntegrationHelpText).exists()).toBe(true);
});
-
- it('should render the label correctly', () => {
- wrapper = createComponent();
-
- expect(findFormGroupLabel().text()).toBe('Enable foo');
- });
});
diff --git a/spec/frontend/projects/commit/store/actions_spec.js b/spec/frontend/projects/commit/store/actions_spec.js
index 305257c9ca5..56dffcbd48e 100644
--- a/spec/frontend/projects/commit/store/actions_spec.js
+++ b/spec/frontend/projects/commit/store/actions_spec.js
@@ -44,12 +44,12 @@ describe('Commit form modal store actions', () => {
});
describe('fetchBranches', () => {
- it('dispatch correct actions on fetchBranches', (done) => {
+ it('dispatch correct actions on fetchBranches', () => {
jest
.spyOn(axios, 'get')
.mockImplementation(() => Promise.resolve({ data: { Branches: mockData.mockBranches } }));
- testAction(
+ return testAction(
actions.fetchBranches,
{},
state,
@@ -60,19 +60,15 @@ describe('Commit form modal store actions', () => {
},
],
[{ type: 'requestBranches' }],
- () => {
- done();
- },
);
});
- it('should show flash error and set error in state on fetchBranches failure', (done) => {
+ it('should show flash error and set error in state on fetchBranches failure', async () => {
jest.spyOn(axios, 'get').mockRejectedValue();
- testAction(actions.fetchBranches, {}, state, [], [{ type: 'requestBranches' }], () => {
- expect(createFlash).toHaveBeenCalledWith({ message: PROJECT_BRANCHES_ERROR });
- done();
- });
+ await testAction(actions.fetchBranches, {}, state, [], [{ type: 'requestBranches' }]);
+
+ expect(createFlash).toHaveBeenCalledWith({ message: PROJECT_BRANCHES_ERROR });
});
});
diff --git a/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap b/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap
index b8f9951bbfc..26a3b27d958 100644
--- a/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap
+++ b/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap
@@ -36,7 +36,7 @@ exports[`Project remove modal initialized matches the snapshot 1`] = `
modalclass=""
modalid="fakeUniqueId"
ok-variant="danger"
- size="sm"
+ size="md"
title-class="gl-text-red-500"
titletag="h4"
>
diff --git a/spec/frontend/projects/new/components/deployment_target_select_spec.js b/spec/frontend/projects/new/components/deployment_target_select_spec.js
index 8fe4c5f1230..1c443879dc3 100644
--- a/spec/frontend/projects/new/components/deployment_target_select_spec.js
+++ b/spec/frontend/projects/new/components/deployment_target_select_spec.js
@@ -1,4 +1,5 @@
-import { GlFormGroup, GlFormSelect } from '@gitlab/ui';
+import { GlFormGroup, GlFormSelect, GlFormText, GlLink, GlSprintf } from '@gitlab/ui';
+import { nextTick } from 'vue';
import { shallowMount } from '@vue/test-utils';
import { mockTracking } from 'helpers/tracking_helper';
import DeploymentTargetSelect from '~/projects/new/components/deployment_target_select.vue';
@@ -6,7 +7,9 @@ import {
DEPLOYMENT_TARGET_SELECTIONS,
DEPLOYMENT_TARGET_LABEL,
DEPLOYMENT_TARGET_EVENT,
+ VISIT_DOCS_EVENT,
NEW_PROJECT_FORM,
+ K8S_OPTION,
} from '~/projects/new/constants';
describe('Deployment target select', () => {
@@ -15,11 +18,15 @@ describe('Deployment target select', () => {
const findFormGroup = () => wrapper.findComponent(GlFormGroup);
const findSelect = () => wrapper.findComponent(GlFormSelect);
+ const findText = () => wrapper.findComponent(GlFormText);
+ const findLink = () => wrapper.findComponent(GlLink);
const createdWrapper = () => {
wrapper = shallowMount(DeploymentTargetSelect, {
stubs: {
GlFormSelect,
+ GlFormText,
+ GlSprintf,
},
});
};
@@ -79,4 +86,34 @@ describe('Deployment target select', () => {
});
}
});
+
+ describe.each`
+ selectedTarget | isTextShown
+ ${null} | ${false}
+ ${DEPLOYMENT_TARGET_SELECTIONS[0]} | ${true}
+ ${DEPLOYMENT_TARGET_SELECTIONS[1]} | ${false}
+ `('K8s education text', ({ selectedTarget, isTextShown }) => {
+ beforeEach(() => {
+ findSelect().vm.$emit('input', selectedTarget);
+ });
+
+ it(`is ${!isTextShown ? 'not ' : ''}shown when selected option is ${selectedTarget}`, () => {
+ expect(findText().exists()).toBe(isTextShown);
+ });
+ });
+
+ describe('when user clicks on the docs link', () => {
+ beforeEach(async () => {
+ findSelect().vm.$emit('input', K8S_OPTION);
+ await nextTick();
+
+ findLink().trigger('click');
+ });
+
+ it('sends the snowplow tracking event', () => {
+ expect(trackingSpy).toHaveBeenCalledWith('_category_', VISIT_DOCS_EVENT, {
+ label: DEPLOYMENT_TARGET_LABEL,
+ });
+ });
+ });
});
diff --git a/spec/frontend/projects/new/components/new_project_url_select_spec.js b/spec/frontend/projects/new/components/new_project_url_select_spec.js
index 921f5b74278..ba22622e1f7 100644
--- a/spec/frontend/projects/new/components/new_project_url_select_spec.js
+++ b/spec/frontend/projects/new/components/new_project_url_select_spec.js
@@ -15,6 +15,7 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import eventHub from '~/projects/new/event_hub';
import NewProjectUrlSelect from '~/projects/new/components/new_project_url_select.vue';
import searchQuery from '~/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql';
+import { s__ } from '~/locale';
describe('NewProjectUrlSelect component', () => {
let wrapper;
@@ -61,7 +62,6 @@ describe('NewProjectUrlSelect component', () => {
namespaceId: '28',
rootUrl: 'https://gitlab.com/',
trackLabel: 'blank_project',
- userNamespaceFullPath: 'root',
userNamespaceId: '1',
};
@@ -91,7 +91,10 @@ describe('NewProjectUrlSelect component', () => {
const findButtonLabel = () => wrapper.findComponent(GlButton);
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findInput = () => wrapper.findComponent(GlSearchBoxByType);
- const findHiddenInput = () => wrapper.find('[name="project[namespace_id]"]');
+ const findHiddenNamespaceInput = () => wrapper.find('[name="project[namespace_id]"]');
+
+ const findHiddenSelectedNamespaceInput = () =>
+ wrapper.find('[name="project[selected_namespace_id]"]');
const clickDropdownItem = async () => {
wrapper.findComponent(GlDropdownItem).vm.$emit('click');
@@ -122,11 +125,20 @@ describe('NewProjectUrlSelect component', () => {
});
it('renders a dropdown with the given namespace full path as the text', () => {
- expect(findDropdown().props('text')).toBe(defaultProvide.namespaceFullPath);
+ const dropdownProps = findDropdown().props();
+
+ expect(dropdownProps.text).toBe(defaultProvide.namespaceFullPath);
+ expect(dropdownProps.toggleClass).not.toContain('gl-text-gray-500!');
+ });
+
+ it('renders a hidden input with the given namespace id', () => {
+ expect(findHiddenNamespaceInput().attributes('value')).toBe(defaultProvide.namespaceId);
});
- it('renders a dropdown with the given namespace id in the hidden input', () => {
- expect(findHiddenInput().attributes('value')).toBe(defaultProvide.namespaceId);
+ it('renders a hidden input with the selected namespace id', () => {
+ expect(findHiddenSelectedNamespaceInput().attributes('value')).toBe(
+ defaultProvide.namespaceId,
+ );
});
});
@@ -142,11 +154,18 @@ describe('NewProjectUrlSelect component', () => {
});
it("renders a dropdown with the user's namespace full path as the text", () => {
- expect(findDropdown().props('text')).toBe(defaultProvide.userNamespaceFullPath);
+ const dropdownProps = findDropdown().props();
+
+ expect(dropdownProps.text).toBe(s__('ProjectsNew|Pick a group or namespace'));
+ expect(dropdownProps.toggleClass).toContain('gl-text-gray-500!');
+ });
+
+ it("renders a hidden input with the user's namespace id", () => {
+ expect(findHiddenNamespaceInput().attributes('value')).toBe(defaultProvide.userNamespaceId);
});
- it("renders a dropdown with the user's namespace id in the hidden input", () => {
- expect(findHiddenInput().attributes('value')).toBe(defaultProvide.userNamespaceId);
+ it('renders a hidden input with the selected namespace id', () => {
+ expect(findHiddenSelectedNamespaceInput().attributes('value')).toBe(undefined);
});
});
@@ -270,7 +289,7 @@ describe('NewProjectUrlSelect component', () => {
await clickDropdownItem();
- expect(findHiddenInput().attributes('value')).toBe(
+ expect(findHiddenNamespaceInput().attributes('value')).toBe(
getIdFromGraphQLId(data.currentUser.groups.nodes[0].id).toString(),
);
});
diff --git a/spec/frontend/releases/components/app_index_apollo_client_spec.js b/spec/frontend/releases/components/app_index_apollo_client_spec.js
deleted file mode 100644
index 9881ef9bc9f..00000000000
--- a/spec/frontend/releases/components/app_index_apollo_client_spec.js
+++ /dev/null
@@ -1,398 +0,0 @@
-import { cloneDeep } from 'lodash';
-import Vue, { nextTick } from 'vue';
-import VueApollo from 'vue-apollo';
-import originalAllReleasesQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/all_releases.query.graphql.json';
-import createMockApollo from 'helpers/mock_apollo_helper';
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import waitForPromises from 'helpers/wait_for_promises';
-import allReleasesQuery from 'shared_queries/releases/all_releases.query.graphql';
-import createFlash from '~/flash';
-import { historyPushState } from '~/lib/utils/common_utils';
-import ReleasesIndexApolloClientApp from '~/releases/components/app_index_apollo_client.vue';
-import ReleaseBlock from '~/releases/components/release_block.vue';
-import ReleaseSkeletonLoader from '~/releases/components/release_skeleton_loader.vue';
-import ReleasesEmptyState from '~/releases/components/releases_empty_state.vue';
-import ReleasesPaginationApolloClient from '~/releases/components/releases_pagination_apollo_client.vue';
-import ReleasesSortApolloClient from '~/releases/components/releases_sort_apollo_client.vue';
-import { PAGE_SIZE, CREATED_ASC, DEFAULT_SORT } from '~/releases/constants';
-
-Vue.use(VueApollo);
-
-jest.mock('~/flash');
-
-let mockQueryParams;
-jest.mock('~/lib/utils/common_utils', () => ({
- ...jest.requireActual('~/lib/utils/common_utils'),
- historyPushState: jest.fn(),
-}));
-
-jest.mock('~/lib/utils/url_utility', () => ({
- ...jest.requireActual('~/lib/utils/url_utility'),
- getParameterByName: jest
- .fn()
- .mockImplementation((parameterName) => mockQueryParams[parameterName]),
-}));
-
-describe('app_index_apollo_client.vue', () => {
- const projectPath = 'project/path';
- const newReleasePath = 'path/to/new/release/page';
- const before = 'beforeCursor';
- const after = 'afterCursor';
-
- let wrapper;
- let allReleases;
- let singleRelease;
- let noReleases;
- let queryMock;
-
- const createComponent = ({
- singleResponse = Promise.resolve(singleRelease),
- fullResponse = Promise.resolve(allReleases),
- } = {}) => {
- const apolloProvider = createMockApollo([
- [
- allReleasesQuery,
- queryMock.mockImplementation((vars) => {
- return vars.first === 1 ? singleResponse : fullResponse;
- }),
- ],
- ]);
-
- wrapper = shallowMountExtended(ReleasesIndexApolloClientApp, {
- apolloProvider,
- provide: {
- newReleasePath,
- projectPath,
- },
- });
- };
-
- beforeEach(() => {
- mockQueryParams = {};
-
- allReleases = cloneDeep(originalAllReleasesQueryResponse);
-
- singleRelease = cloneDeep(originalAllReleasesQueryResponse);
- singleRelease.data.project.releases.nodes.splice(
- 1,
- singleRelease.data.project.releases.nodes.length,
- );
-
- noReleases = cloneDeep(originalAllReleasesQueryResponse);
- noReleases.data.project.releases.nodes = [];
-
- queryMock = jest.fn();
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- // Finders
- const findLoadingIndicator = () => wrapper.findComponent(ReleaseSkeletonLoader);
- const findEmptyState = () => wrapper.findComponent(ReleasesEmptyState);
- const findNewReleaseButton = () =>
- wrapper.findByText(ReleasesIndexApolloClientApp.i18n.newRelease);
- const findAllReleaseBlocks = () => wrapper.findAllComponents(ReleaseBlock);
- const findPagination = () => wrapper.findComponent(ReleasesPaginationApolloClient);
- const findSort = () => wrapper.findComponent(ReleasesSortApolloClient);
-
- // Tests
- describe('component states', () => {
- // These need to be defined as functions, since `singleRelease` and
- // `allReleases` are generated in a `beforeEach`, and therefore
- // aren't available at test definition time.
- const getInProgressResponse = () => new Promise(() => {});
- const getErrorResponse = () => Promise.reject(new Error('Oops!'));
- const getSingleRequestLoadedResponse = () => Promise.resolve(singleRelease);
- const getFullRequestLoadedResponse = () => Promise.resolve(allReleases);
- const getLoadedEmptyResponse = () => Promise.resolve(noReleases);
-
- const toDescription = (bool) => (bool ? 'does' : 'does not');
-
- describe.each`
- description | singleResponseFn | fullResponseFn | loadingIndicator | emptyState | flashMessage | releaseCount | pagination
- ${'both requests loading'} | ${getInProgressResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false}
- ${'both requests failed'} | ${getErrorResponse} | ${getErrorResponse} | ${false} | ${false} | ${true} | ${0} | ${false}
- ${'both requests loaded'} | ${getSingleRequestLoadedResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true}
- ${'both requests loaded with no results'} | ${getLoadedEmptyResponse} | ${getLoadedEmptyResponse} | ${false} | ${true} | ${false} | ${0} | ${false}
- ${'single request loading, full request loaded'} | ${getInProgressResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true}
- ${'single request loading, full request failed'} | ${getInProgressResponse} | ${getErrorResponse} | ${true} | ${false} | ${true} | ${0} | ${false}
- ${'single request loaded, full request loading'} | ${getSingleRequestLoadedResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${1} | ${false}
- ${'single request loaded, full request failed'} | ${getSingleRequestLoadedResponse} | ${getErrorResponse} | ${false} | ${false} | ${true} | ${1} | ${false}
- ${'single request failed, full request loading'} | ${getErrorResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false}
- ${'single request failed, full request loaded'} | ${getErrorResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true}
- ${'single request loaded with no results, full request loading'} | ${getLoadedEmptyResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false}
- ${'single request loading, full request loadied with no results'} | ${getInProgressResponse} | ${getLoadedEmptyResponse} | ${false} | ${true} | ${false} | ${0} | ${false}
- `(
- '$description',
- ({
- singleResponseFn,
- fullResponseFn,
- loadingIndicator,
- emptyState,
- flashMessage,
- releaseCount,
- pagination,
- }) => {
- beforeEach(() => {
- createComponent({
- singleResponse: singleResponseFn(),
- fullResponse: fullResponseFn(),
- });
- });
-
- it(`${toDescription(loadingIndicator)} render a loading indicator`, async () => {
- await waitForPromises();
- expect(findLoadingIndicator().exists()).toBe(loadingIndicator);
- });
-
- it(`${toDescription(emptyState)} render an empty state`, () => {
- expect(findEmptyState().exists()).toBe(emptyState);
- });
-
- it(`${toDescription(flashMessage)} show a flash message`, () => {
- if (flashMessage) {
- expect(createFlash).toHaveBeenCalledWith({
- message: ReleasesIndexApolloClientApp.i18n.errorMessage,
- captureError: true,
- error: expect.any(Error),
- });
- } else {
- expect(createFlash).not.toHaveBeenCalled();
- }
- });
-
- it(`renders ${releaseCount} release(s)`, () => {
- expect(findAllReleaseBlocks()).toHaveLength(releaseCount);
- });
-
- it(`${toDescription(pagination)} render the pagination controls`, () => {
- expect(findPagination().exists()).toBe(pagination);
- });
-
- it('does render the "New release" button', () => {
- expect(findNewReleaseButton().exists()).toBe(true);
- });
-
- it('does render the sort controls', () => {
- expect(findSort().exists()).toBe(true);
- });
- },
- );
- });
-
- describe('URL parameters', () => {
- describe('when the URL contains no query parameters', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('makes a request with the correct GraphQL query parameters', () => {
- expect(queryMock).toHaveBeenCalledTimes(2);
-
- expect(queryMock).toHaveBeenCalledWith({
- first: 1,
- fullPath: projectPath,
- sort: DEFAULT_SORT,
- });
-
- expect(queryMock).toHaveBeenCalledWith({
- first: PAGE_SIZE,
- fullPath: projectPath,
- sort: DEFAULT_SORT,
- });
- });
- });
-
- describe('when the URL contains a "before" query parameter', () => {
- beforeEach(() => {
- mockQueryParams = { before };
- createComponent();
- });
-
- it('makes a request with the correct GraphQL query parameters', () => {
- expect(queryMock).toHaveBeenCalledTimes(1);
-
- expect(queryMock).toHaveBeenCalledWith({
- before,
- last: PAGE_SIZE,
- fullPath: projectPath,
- sort: DEFAULT_SORT,
- });
- });
- });
-
- describe('when the URL contains an "after" query parameter', () => {
- beforeEach(() => {
- mockQueryParams = { after };
- createComponent();
- });
-
- it('makes a request with the correct GraphQL query parameters', () => {
- expect(queryMock).toHaveBeenCalledTimes(2);
-
- expect(queryMock).toHaveBeenCalledWith({
- after,
- first: 1,
- fullPath: projectPath,
- sort: DEFAULT_SORT,
- });
-
- expect(queryMock).toHaveBeenCalledWith({
- after,
- first: PAGE_SIZE,
- fullPath: projectPath,
- sort: DEFAULT_SORT,
- });
- });
- });
-
- describe('when the URL contains both "before" and "after" query parameters', () => {
- beforeEach(() => {
- mockQueryParams = { before, after };
- createComponent();
- });
-
- it('ignores the "before" parameter and behaves as if only the "after" parameter was provided', () => {
- expect(queryMock).toHaveBeenCalledTimes(2);
-
- expect(queryMock).toHaveBeenCalledWith({
- after,
- first: 1,
- fullPath: projectPath,
- sort: DEFAULT_SORT,
- });
-
- expect(queryMock).toHaveBeenCalledWith({
- after,
- first: PAGE_SIZE,
- fullPath: projectPath,
- sort: DEFAULT_SORT,
- });
- });
- });
- });
-
- describe('New release button', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it('renders the new release button with the correct href', () => {
- expect(findNewReleaseButton().attributes().href).toBe(newReleasePath);
- });
- });
-
- describe('pagination', () => {
- beforeEach(() => {
- mockQueryParams = { before };
- createComponent();
- });
-
- it('requeries the GraphQL endpoint when a pagination button is clicked', async () => {
- expect(queryMock.mock.calls).toEqual([[expect.objectContaining({ before })]]);
-
- mockQueryParams = { after };
- findPagination().vm.$emit('next', after);
-
- await nextTick();
-
- expect(queryMock.mock.calls).toEqual([
- [expect.objectContaining({ before })],
- [expect.objectContaining({ after })],
- [expect.objectContaining({ after })],
- ]);
- });
- });
-
- describe('sorting', () => {
- beforeEach(() => {
- createComponent();
- });
-
- it(`sorts by ${DEFAULT_SORT} by default`, () => {
- expect(queryMock.mock.calls).toEqual([
- [expect.objectContaining({ sort: DEFAULT_SORT })],
- [expect.objectContaining({ sort: DEFAULT_SORT })],
- ]);
- });
-
- it('requeries the GraphQL endpoint and updates the URL when the sort is changed', async () => {
- findSort().vm.$emit('input', CREATED_ASC);
-
- await nextTick();
-
- expect(queryMock.mock.calls).toEqual([
- [expect.objectContaining({ sort: DEFAULT_SORT })],
- [expect.objectContaining({ sort: DEFAULT_SORT })],
- [expect.objectContaining({ sort: CREATED_ASC })],
- [expect.objectContaining({ sort: CREATED_ASC })],
- ]);
-
- // URL manipulation is tested in more detail in the `describe` block below
- expect(historyPushState).toHaveBeenCalled();
- });
-
- it('does not requery the GraphQL endpoint or update the URL if the sort is updated to the same value', async () => {
- findSort().vm.$emit('input', DEFAULT_SORT);
-
- await nextTick();
-
- expect(queryMock.mock.calls).toEqual([
- [expect.objectContaining({ sort: DEFAULT_SORT })],
- [expect.objectContaining({ sort: DEFAULT_SORT })],
- ]);
-
- expect(historyPushState).not.toHaveBeenCalled();
- });
- });
-
- describe('sorting + pagination interaction', () => {
- const nonPaginationQueryParam = 'nonPaginationQueryParam';
-
- beforeEach(() => {
- historyPushState.mockImplementation((newUrl) => {
- mockQueryParams = Object.fromEntries(new URL(newUrl).searchParams);
- });
- });
-
- describe.each`
- queryParamsBefore | paramName | paramInitialValue
- ${{ before, nonPaginationQueryParam }} | ${'before'} | ${before}
- ${{ after, nonPaginationQueryParam }} | ${'after'} | ${after}
- `(
- 'when the URL contains a "$paramName" pagination cursor',
- ({ queryParamsBefore, paramName, paramInitialValue }) => {
- beforeEach(async () => {
- mockQueryParams = queryParamsBefore;
- createComponent();
-
- findSort().vm.$emit('input', CREATED_ASC);
-
- await nextTick();
- });
-
- it(`resets the page's "${paramName}" pagination cursor when the sort is changed`, () => {
- const firstRequestVariables = queryMock.mock.calls[0][0];
- // Might be request #2 or #3, depending on the pagination direction
- const mostRecentRequestVariables =
- queryMock.mock.calls[queryMock.mock.calls.length - 1][0];
-
- expect(firstRequestVariables[paramName]).toBe(paramInitialValue);
- expect(mostRecentRequestVariables[paramName]).toBeUndefined();
- });
-
- it(`updates the URL to not include the "${paramName}" URL query parameter`, () => {
- expect(historyPushState).toHaveBeenCalledTimes(1);
-
- const updatedUrlQueryParams = Object.fromEntries(
- new URL(historyPushState.mock.calls[0][0]).searchParams,
- );
-
- expect(updatedUrlQueryParams[paramName]).toBeUndefined();
- });
- },
- );
- });
-});
diff --git a/spec/frontend/releases/components/app_index_spec.js b/spec/frontend/releases/components/app_index_spec.js
index 43e88650ae3..63ce4c8bb17 100644
--- a/spec/frontend/releases/components/app_index_spec.js
+++ b/spec/frontend/releases/components/app_index_spec.js
@@ -1,50 +1,87 @@
-import { shallowMount } from '@vue/test-utils';
-import { merge } from 'lodash';
-import Vue from 'vue';
-import Vuex from 'vuex';
-import { getParameterByName } from '~/lib/utils/url_utility';
-import AppIndex from '~/releases/components/app_index.vue';
+import { cloneDeep } from 'lodash';
+import Vue, { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import originalAllReleasesQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/all_releases.query.graphql.json';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql';
+import createFlash from '~/flash';
+import { historyPushState } from '~/lib/utils/common_utils';
+import ReleasesIndexApp from '~/releases/components/app_index.vue';
+import ReleaseBlock from '~/releases/components/release_block.vue';
import ReleaseSkeletonLoader from '~/releases/components/release_skeleton_loader.vue';
+import ReleasesEmptyState from '~/releases/components/releases_empty_state.vue';
import ReleasesPagination from '~/releases/components/releases_pagination.vue';
import ReleasesSort from '~/releases/components/releases_sort.vue';
+import { PAGE_SIZE, CREATED_ASC, DEFAULT_SORT } from '~/releases/constants';
+
+Vue.use(VueApollo);
+
+jest.mock('~/flash');
+
+let mockQueryParams;
+jest.mock('~/lib/utils/common_utils', () => ({
+ ...jest.requireActual('~/lib/utils/common_utils'),
+ historyPushState: jest.fn(),
+}));
jest.mock('~/lib/utils/url_utility', () => ({
...jest.requireActual('~/lib/utils/url_utility'),
- getParameterByName: jest.fn(),
+ getParameterByName: jest
+ .fn()
+ .mockImplementation((parameterName) => mockQueryParams[parameterName]),
}));
-Vue.use(Vuex);
-
describe('app_index.vue', () => {
+ const projectPath = 'project/path';
+ const newReleasePath = 'path/to/new/release/page';
+ const before = 'beforeCursor';
+ const after = 'afterCursor';
+
let wrapper;
- let fetchReleasesSpy;
- let urlParams;
-
- const createComponent = (storeUpdates) => {
- wrapper = shallowMount(AppIndex, {
- store: new Vuex.Store({
- modules: {
- index: merge(
- {
- namespaced: true,
- actions: {
- fetchReleases: fetchReleasesSpy,
- },
- state: {
- isLoading: true,
- releases: [],
- },
- },
- storeUpdates,
- ),
- },
- }),
+ let allReleases;
+ let singleRelease;
+ let noReleases;
+ let queryMock;
+
+ const createComponent = ({
+ singleResponse = Promise.resolve(singleRelease),
+ fullResponse = Promise.resolve(allReleases),
+ } = {}) => {
+ const apolloProvider = createMockApollo([
+ [
+ allReleasesQuery,
+ queryMock.mockImplementation((vars) => {
+ return vars.first === 1 ? singleResponse : fullResponse;
+ }),
+ ],
+ ]);
+
+ wrapper = shallowMountExtended(ReleasesIndexApp, {
+ apolloProvider,
+ provide: {
+ newReleasePath,
+ projectPath,
+ },
});
};
beforeEach(() => {
- fetchReleasesSpy = jest.fn();
- getParameterByName.mockImplementation((paramName) => urlParams[paramName]);
+ mockQueryParams = {};
+
+ allReleases = cloneDeep(originalAllReleasesQueryResponse);
+
+ singleRelease = cloneDeep(originalAllReleasesQueryResponse);
+ singleRelease.data.project.releases.nodes.splice(
+ 1,
+ singleRelease.data.project.releases.nodes.length,
+ );
+
+ noReleases = cloneDeep(originalAllReleasesQueryResponse);
+ noReleases.data.project.releases.nodes = [];
+
+ queryMock = jest.fn();
});
afterEach(() => {
@@ -52,120 +89,221 @@ describe('app_index.vue', () => {
});
// Finders
- const findLoadingIndicator = () => wrapper.find(ReleaseSkeletonLoader);
- const findEmptyState = () => wrapper.find('[data-testid="empty-state"]');
- const findSuccessState = () => wrapper.find('[data-testid="success-state"]');
- const findPagination = () => wrapper.find(ReleasesPagination);
- const findSortControls = () => wrapper.find(ReleasesSort);
- const findNewReleaseButton = () => wrapper.find('[data-testid="new-release-button"]');
-
- // Expectations
- const expectLoadingIndicator = (shouldExist) => {
- it(`${shouldExist ? 'renders' : 'does not render'} a loading indicator`, () => {
- expect(findLoadingIndicator().exists()).toBe(shouldExist);
- });
- };
+ const findLoadingIndicator = () => wrapper.findComponent(ReleaseSkeletonLoader);
+ const findEmptyState = () => wrapper.findComponent(ReleasesEmptyState);
+ const findNewReleaseButton = () => wrapper.findByText(ReleasesIndexApp.i18n.newRelease);
+ const findAllReleaseBlocks = () => wrapper.findAllComponents(ReleaseBlock);
+ const findPagination = () => wrapper.findComponent(ReleasesPagination);
+ const findSort = () => wrapper.findComponent(ReleasesSort);
- const expectEmptyState = (shouldExist) => {
- it(`${shouldExist ? 'renders' : 'does not render'} an empty state`, () => {
- expect(findEmptyState().exists()).toBe(shouldExist);
- });
- };
+ // Tests
+ describe('component states', () => {
+ // These need to be defined as functions, since `singleRelease` and
+ // `allReleases` are generated in a `beforeEach`, and therefore
+ // aren't available at test definition time.
+ const getInProgressResponse = () => new Promise(() => {});
+ const getErrorResponse = () => Promise.reject(new Error('Oops!'));
+ const getSingleRequestLoadedResponse = () => Promise.resolve(singleRelease);
+ const getFullRequestLoadedResponse = () => Promise.resolve(allReleases);
+ const getLoadedEmptyResponse = () => Promise.resolve(noReleases);
+
+ const toDescription = (bool) => (bool ? 'does' : 'does not');
+
+ describe.each`
+ description | singleResponseFn | fullResponseFn | loadingIndicator | emptyState | flashMessage | releaseCount | pagination
+ ${'both requests loading'} | ${getInProgressResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false}
+ ${'both requests failed'} | ${getErrorResponse} | ${getErrorResponse} | ${false} | ${false} | ${true} | ${0} | ${false}
+ ${'both requests loaded'} | ${getSingleRequestLoadedResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true}
+ ${'both requests loaded with no results'} | ${getLoadedEmptyResponse} | ${getLoadedEmptyResponse} | ${false} | ${true} | ${false} | ${0} | ${false}
+ ${'single request loading, full request loaded'} | ${getInProgressResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true}
+ ${'single request loading, full request failed'} | ${getInProgressResponse} | ${getErrorResponse} | ${true} | ${false} | ${true} | ${0} | ${false}
+ ${'single request loaded, full request loading'} | ${getSingleRequestLoadedResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${1} | ${false}
+ ${'single request loaded, full request failed'} | ${getSingleRequestLoadedResponse} | ${getErrorResponse} | ${false} | ${false} | ${true} | ${1} | ${false}
+ ${'single request failed, full request loading'} | ${getErrorResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false}
+ ${'single request failed, full request loaded'} | ${getErrorResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true}
+ ${'single request loaded with no results, full request loading'} | ${getLoadedEmptyResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false}
+ ${'single request loading, full request loadied with no results'} | ${getInProgressResponse} | ${getLoadedEmptyResponse} | ${false} | ${true} | ${false} | ${0} | ${false}
+ `(
+ '$description',
+ ({
+ singleResponseFn,
+ fullResponseFn,
+ loadingIndicator,
+ emptyState,
+ flashMessage,
+ releaseCount,
+ pagination,
+ }) => {
+ beforeEach(() => {
+ createComponent({
+ singleResponse: singleResponseFn(),
+ fullResponse: fullResponseFn(),
+ });
+ });
+
+ it(`${toDescription(loadingIndicator)} render a loading indicator`, async () => {
+ await waitForPromises();
+ expect(findLoadingIndicator().exists()).toBe(loadingIndicator);
+ });
+
+ it(`${toDescription(emptyState)} render an empty state`, () => {
+ expect(findEmptyState().exists()).toBe(emptyState);
+ });
+
+ it(`${toDescription(flashMessage)} show a flash message`, async () => {
+ await waitForPromises();
+ if (flashMessage) {
+ expect(createFlash).toHaveBeenCalledWith({
+ message: ReleasesIndexApp.i18n.errorMessage,
+ captureError: true,
+ error: expect.any(Error),
+ });
+ } else {
+ expect(createFlash).not.toHaveBeenCalled();
+ }
+ });
+
+ it(`renders ${releaseCount} release(s)`, () => {
+ expect(findAllReleaseBlocks()).toHaveLength(releaseCount);
+ });
+
+ it(`${toDescription(pagination)} render the pagination controls`, () => {
+ expect(findPagination().exists()).toBe(pagination);
+ });
+
+ it('does render the "New release" button', () => {
+ expect(findNewReleaseButton().exists()).toBe(true);
+ });
+
+ it('does render the sort controls', () => {
+ expect(findSort().exists()).toBe(true);
+ });
+ },
+ );
+ });
- const expectSuccessState = (shouldExist) => {
- it(`${shouldExist ? 'renders' : 'does not render'} the success state`, () => {
- expect(findSuccessState().exists()).toBe(shouldExist);
- });
- };
+ describe('URL parameters', () => {
+ describe('when the URL contains no query parameters', () => {
+ beforeEach(() => {
+ createComponent();
+ });
- const expectPagination = (shouldExist) => {
- it(`${shouldExist ? 'renders' : 'does not render'} the pagination controls`, () => {
- expect(findPagination().exists()).toBe(shouldExist);
- });
- };
+ it('makes a request with the correct GraphQL query parameters', () => {
+ expect(queryMock).toHaveBeenCalledTimes(2);
- const expectNewReleaseButton = (shouldExist) => {
- it(`${shouldExist ? 'renders' : 'does not render'} the "New release" button`, () => {
- expect(findNewReleaseButton().exists()).toBe(shouldExist);
- });
- };
+ expect(queryMock).toHaveBeenCalledWith({
+ first: 1,
+ fullPath: projectPath,
+ sort: DEFAULT_SORT,
+ });
- // Tests
- describe('on startup', () => {
- it.each`
- before | after
- ${null} | ${null}
- ${'before_param_value'} | ${null}
- ${null} | ${'after_param_value'}
- `(
- 'calls fetchRelease with the correct parameters based on the curent query parameters: before: $before, after: $after',
- ({ before, after }) => {
- urlParams = { before, after };
+ expect(queryMock).toHaveBeenCalledWith({
+ first: PAGE_SIZE,
+ fullPath: projectPath,
+ sort: DEFAULT_SORT,
+ });
+ });
+ });
+ describe('when the URL contains a "before" query parameter', () => {
+ beforeEach(() => {
+ mockQueryParams = { before };
createComponent();
+ });
- expect(fetchReleasesSpy).toHaveBeenCalledTimes(1);
- expect(fetchReleasesSpy).toHaveBeenCalledWith(expect.anything(), urlParams);
- },
- );
- });
+ it('makes a request with the correct GraphQL query parameters', () => {
+ expect(queryMock).toHaveBeenCalledTimes(1);
- describe('when the request to fetch releases has not yet completed', () => {
- beforeEach(() => {
- createComponent();
+ expect(queryMock).toHaveBeenCalledWith({
+ before,
+ last: PAGE_SIZE,
+ fullPath: projectPath,
+ sort: DEFAULT_SORT,
+ });
+ });
});
- expectLoadingIndicator(true);
- expectEmptyState(false);
- expectSuccessState(false);
- expectPagination(false);
- });
+ describe('when the URL contains an "after" query parameter', () => {
+ beforeEach(() => {
+ mockQueryParams = { after };
+ createComponent();
+ });
- describe('when the request fails', () => {
- beforeEach(() => {
- createComponent({
- state: {
- isLoading: false,
- hasError: true,
- },
+ it('makes a request with the correct GraphQL query parameters', () => {
+ expect(queryMock).toHaveBeenCalledTimes(2);
+
+ expect(queryMock).toHaveBeenCalledWith({
+ after,
+ first: 1,
+ fullPath: projectPath,
+ sort: DEFAULT_SORT,
+ });
+
+ expect(queryMock).toHaveBeenCalledWith({
+ after,
+ first: PAGE_SIZE,
+ fullPath: projectPath,
+ sort: DEFAULT_SORT,
+ });
});
});
- expectLoadingIndicator(false);
- expectEmptyState(false);
- expectSuccessState(false);
- expectPagination(true);
+ describe('when the URL contains both "before" and "after" query parameters', () => {
+ beforeEach(() => {
+ mockQueryParams = { before, after };
+ createComponent();
+ });
+
+ it('ignores the "before" parameter and behaves as if only the "after" parameter was provided', () => {
+ expect(queryMock).toHaveBeenCalledTimes(2);
+
+ expect(queryMock).toHaveBeenCalledWith({
+ after,
+ first: 1,
+ fullPath: projectPath,
+ sort: DEFAULT_SORT,
+ });
+
+ expect(queryMock).toHaveBeenCalledWith({
+ after,
+ first: PAGE_SIZE,
+ fullPath: projectPath,
+ sort: DEFAULT_SORT,
+ });
+ });
+ });
});
- describe('when the request succeeds but returns no releases', () => {
+ describe('New release button', () => {
beforeEach(() => {
- createComponent({
- state: {
- isLoading: false,
- },
- });
+ createComponent();
});
- expectLoadingIndicator(false);
- expectEmptyState(true);
- expectSuccessState(false);
- expectPagination(true);
+ it('renders the new release button with the correct href', () => {
+ expect(findNewReleaseButton().attributes().href).toBe(newReleasePath);
+ });
});
- describe('when the request succeeds and includes at least one release', () => {
+ describe('pagination', () => {
beforeEach(() => {
- createComponent({
- state: {
- isLoading: false,
- releases: [{}],
- },
- });
+ mockQueryParams = { before };
+ createComponent();
});
- expectLoadingIndicator(false);
- expectEmptyState(false);
- expectSuccessState(true);
- expectPagination(true);
+ it('requeries the GraphQL endpoint when a pagination button is clicked', async () => {
+ expect(queryMock.mock.calls).toEqual([[expect.objectContaining({ before })]]);
+
+ mockQueryParams = { after };
+ findPagination().vm.$emit('next', after);
+
+ await nextTick();
+
+ expect(queryMock.mock.calls).toEqual([
+ [expect.objectContaining({ before })],
+ [expect.objectContaining({ after })],
+ [expect.objectContaining({ after })],
+ ]);
+ });
});
describe('sorting', () => {
@@ -173,59 +311,88 @@ describe('app_index.vue', () => {
createComponent();
});
- it('renders the sort controls', () => {
- expect(findSortControls().exists()).toBe(true);
+ it(`sorts by ${DEFAULT_SORT} by default`, () => {
+ expect(queryMock.mock.calls).toEqual([
+ [expect.objectContaining({ sort: DEFAULT_SORT })],
+ [expect.objectContaining({ sort: DEFAULT_SORT })],
+ ]);
});
- it('calls the fetchReleases store method when the sort is updated', () => {
- fetchReleasesSpy.mockClear();
+ it('requeries the GraphQL endpoint and updates the URL when the sort is changed', async () => {
+ findSort().vm.$emit('input', CREATED_ASC);
+
+ await nextTick();
- findSortControls().vm.$emit('sort:changed');
+ expect(queryMock.mock.calls).toEqual([
+ [expect.objectContaining({ sort: DEFAULT_SORT })],
+ [expect.objectContaining({ sort: DEFAULT_SORT })],
+ [expect.objectContaining({ sort: CREATED_ASC })],
+ [expect.objectContaining({ sort: CREATED_ASC })],
+ ]);
- expect(fetchReleasesSpy).toHaveBeenCalledTimes(1);
+ // URL manipulation is tested in more detail in the `describe` block below
+ expect(historyPushState).toHaveBeenCalled();
});
- });
- describe('"New release" button', () => {
- describe('when the user is allowed to create releases', () => {
- const newReleasePath = 'path/to/new/release/page';
+ it('does not requery the GraphQL endpoint or update the URL if the sort is updated to the same value', async () => {
+ findSort().vm.$emit('input', DEFAULT_SORT);
- beforeEach(() => {
- createComponent({ state: { newReleasePath } });
- });
+ await nextTick();
- expectNewReleaseButton(true);
+ expect(queryMock.mock.calls).toEqual([
+ [expect.objectContaining({ sort: DEFAULT_SORT })],
+ [expect.objectContaining({ sort: DEFAULT_SORT })],
+ ]);
- it('renders the button with the correct href', () => {
- expect(findNewReleaseButton().attributes('href')).toBe(newReleasePath);
- });
+ expect(historyPushState).not.toHaveBeenCalled();
});
+ });
- describe('when the user is not allowed to create releases', () => {
- beforeEach(() => {
- createComponent();
- });
+ describe('sorting + pagination interaction', () => {
+ const nonPaginationQueryParam = 'nonPaginationQueryParam';
- expectNewReleaseButton(false);
+ beforeEach(() => {
+ historyPushState.mockImplementation((newUrl) => {
+ mockQueryParams = Object.fromEntries(new URL(newUrl).searchParams);
+ });
});
- });
- describe("when the browser's back button is pressed", () => {
- beforeEach(() => {
- urlParams = {
- before: 'before_param_value',
- };
+ describe.each`
+ queryParamsBefore | paramName | paramInitialValue
+ ${{ before, nonPaginationQueryParam }} | ${'before'} | ${before}
+ ${{ after, nonPaginationQueryParam }} | ${'after'} | ${after}
+ `(
+ 'when the URL contains a "$paramName" pagination cursor',
+ ({ queryParamsBefore, paramName, paramInitialValue }) => {
+ beforeEach(async () => {
+ mockQueryParams = queryParamsBefore;
+ createComponent();
- createComponent();
+ findSort().vm.$emit('input', CREATED_ASC);
- fetchReleasesSpy.mockClear();
+ await nextTick();
+ });
- window.dispatchEvent(new PopStateEvent('popstate'));
- });
+ it(`resets the page's "${paramName}" pagination cursor when the sort is changed`, () => {
+ const firstRequestVariables = queryMock.mock.calls[0][0];
+ // Might be request #2 or #3, depending on the pagination direction
+ const mostRecentRequestVariables =
+ queryMock.mock.calls[queryMock.mock.calls.length - 1][0];
- it('calls the fetchRelease store method with the parameters from the URL query', () => {
- expect(fetchReleasesSpy).toHaveBeenCalledTimes(1);
- expect(fetchReleasesSpy).toHaveBeenCalledWith(expect.anything(), urlParams);
- });
+ expect(firstRequestVariables[paramName]).toBe(paramInitialValue);
+ expect(mostRecentRequestVariables[paramName]).toBeUndefined();
+ });
+
+ it(`updates the URL to not include the "${paramName}" URL query parameter`, () => {
+ expect(historyPushState).toHaveBeenCalledTimes(1);
+
+ const updatedUrlQueryParams = Object.fromEntries(
+ new URL(historyPushState.mock.calls[0][0]).searchParams,
+ );
+
+ expect(updatedUrlQueryParams[paramName]).toBeUndefined();
+ });
+ },
+ );
});
});
diff --git a/spec/frontend/releases/components/app_show_spec.js b/spec/frontend/releases/components/app_show_spec.js
index 41c9746a363..c2ea6900d6e 100644
--- a/spec/frontend/releases/components/app_show_spec.js
+++ b/spec/frontend/releases/components/app_show_spec.js
@@ -143,6 +143,12 @@ describe('Release show component', () => {
describe('when the request succeeded, but the returned "project.release" key was null', () => {
beforeEach(async () => {
+ // As we return a release as `null`, Apollo also throws an error to the console
+ // about the missing field. We need to suppress console.error in order to check
+ // that flash message was called
+
+ // eslint-disable-next-line no-console
+ console.error = jest.fn();
const apolloProvider = createMockApollo([
[
oneReleaseQuery,
diff --git a/spec/frontend/releases/components/releases_pagination_apollo_client_spec.js b/spec/frontend/releases/components/releases_pagination_apollo_client_spec.js
deleted file mode 100644
index a538afd5d38..00000000000
--- a/spec/frontend/releases/components/releases_pagination_apollo_client_spec.js
+++ /dev/null
@@ -1,126 +0,0 @@
-import { mountExtended } from 'helpers/vue_test_utils_helper';
-import { historyPushState } from '~/lib/utils/common_utils';
-import ReleasesPaginationApolloClient from '~/releases/components/releases_pagination_apollo_client.vue';
-
-jest.mock('~/lib/utils/common_utils', () => ({
- ...jest.requireActual('~/lib/utils/common_utils'),
- historyPushState: jest.fn(),
-}));
-
-describe('releases_pagination_apollo_client.vue', () => {
- const startCursor = 'startCursor';
- const endCursor = 'endCursor';
- let wrapper;
- let onPrev;
- let onNext;
-
- const createComponent = (pageInfo) => {
- onPrev = jest.fn();
- onNext = jest.fn();
-
- wrapper = mountExtended(ReleasesPaginationApolloClient, {
- propsData: {
- pageInfo,
- },
- listeners: {
- prev: onPrev,
- next: onNext,
- },
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- const singlePageInfo = {
- hasPreviousPage: false,
- hasNextPage: false,
- startCursor,
- endCursor,
- };
-
- const onlyNextPageInfo = {
- hasPreviousPage: false,
- hasNextPage: true,
- startCursor,
- endCursor,
- };
-
- const onlyPrevPageInfo = {
- hasPreviousPage: true,
- hasNextPage: false,
- startCursor,
- endCursor,
- };
-
- const prevAndNextPageInfo = {
- hasPreviousPage: true,
- hasNextPage: true,
- startCursor,
- endCursor,
- };
-
- const findPrevButton = () => wrapper.findByTestId('prevButton');
- const findNextButton = () => wrapper.findByTestId('nextButton');
-
- describe.each`
- description | pageInfo | prevEnabled | nextEnabled
- ${'when there is only one page of results'} | ${singlePageInfo} | ${false} | ${false}
- ${'when there is a next page, but not a previous page'} | ${onlyNextPageInfo} | ${false} | ${true}
- ${'when there is a previous page, but not a next page'} | ${onlyPrevPageInfo} | ${true} | ${false}
- ${'when there is both a previous and next page'} | ${prevAndNextPageInfo} | ${true} | ${true}
- `('component states', ({ description, pageInfo, prevEnabled, nextEnabled }) => {
- describe(description, () => {
- beforeEach(() => {
- createComponent(pageInfo);
- });
-
- it(`renders the "Prev" button as ${prevEnabled ? 'enabled' : 'disabled'}`, () => {
- expect(findPrevButton().attributes().disabled).toBe(prevEnabled ? undefined : 'disabled');
- });
-
- it(`renders the "Next" button as ${nextEnabled ? 'enabled' : 'disabled'}`, () => {
- expect(findNextButton().attributes().disabled).toBe(nextEnabled ? undefined : 'disabled');
- });
- });
- });
-
- describe('button behavior', () => {
- beforeEach(() => {
- createComponent(prevAndNextPageInfo);
- });
-
- describe('next button behavior', () => {
- beforeEach(() => {
- findNextButton().trigger('click');
- });
-
- it('emits an "next" event with the "after" cursor', () => {
- expect(onNext.mock.calls).toEqual([[endCursor]]);
- });
-
- it('calls historyPushState with the new URL', () => {
- expect(historyPushState.mock.calls).toEqual([
- [expect.stringContaining(`?after=${endCursor}`)],
- ]);
- });
- });
-
- describe('prev button behavior', () => {
- beforeEach(() => {
- findPrevButton().trigger('click');
- });
-
- it('emits an "prev" event with the "before" cursor', () => {
- expect(onPrev.mock.calls).toEqual([[startCursor]]);
- });
-
- it('calls historyPushState with the new URL', () => {
- expect(historyPushState.mock.calls).toEqual([
- [expect.stringContaining(`?before=${startCursor}`)],
- ]);
- });
- });
- });
-});
diff --git a/spec/frontend/releases/components/releases_pagination_spec.js b/spec/frontend/releases/components/releases_pagination_spec.js
index b8c69b0ea70..59be808c802 100644
--- a/spec/frontend/releases/components/releases_pagination_spec.js
+++ b/spec/frontend/releases/components/releases_pagination_spec.js
@@ -1,140 +1,94 @@
-import { GlKeysetPagination } from '@gitlab/ui';
-import { mount } from '@vue/test-utils';
-import Vue from 'vue';
-import Vuex from 'vuex';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
import { historyPushState } from '~/lib/utils/common_utils';
import ReleasesPagination from '~/releases/components/releases_pagination.vue';
-import createStore from '~/releases/stores';
-import createIndexModule from '~/releases/stores/modules/index';
jest.mock('~/lib/utils/common_utils', () => ({
...jest.requireActual('~/lib/utils/common_utils'),
historyPushState: jest.fn(),
}));
-Vue.use(Vuex);
-
-describe('~/releases/components/releases_pagination.vue', () => {
+describe('releases_pagination.vue', () => {
+ const startCursor = 'startCursor';
+ const endCursor = 'endCursor';
let wrapper;
- let indexModule;
-
- const cursors = {
- startCursor: 'startCursor',
- endCursor: 'endCursor',
- };
-
- const projectPath = 'my/project';
+ let onPrev;
+ let onNext;
const createComponent = (pageInfo) => {
- indexModule = createIndexModule({ projectPath });
-
- indexModule.state.pageInfo = pageInfo;
-
- indexModule.actions.fetchReleases = jest.fn();
-
- wrapper = mount(ReleasesPagination, {
- store: createStore({
- modules: {
- index: indexModule,
- },
- featureFlags: {},
- }),
+ onPrev = jest.fn();
+ onNext = jest.fn();
+
+ wrapper = mountExtended(ReleasesPagination, {
+ propsData: {
+ pageInfo,
+ },
+ listeners: {
+ prev: onPrev,
+ next: onNext,
+ },
});
};
afterEach(() => {
wrapper.destroy();
- wrapper = null;
});
- const findGlKeysetPagination = () => wrapper.findComponent(GlKeysetPagination);
- const findPrevButton = () => findGlKeysetPagination().find('[data-testid="prevButton"]');
- const findNextButton = () => findGlKeysetPagination().find('[data-testid="nextButton"]');
-
- const expectDisabledPrev = () => {
- expect(findPrevButton().attributes().disabled).toBe('disabled');
+ const singlePageInfo = {
+ hasPreviousPage: false,
+ hasNextPage: false,
+ startCursor,
+ endCursor,
};
- const expectEnabledPrev = () => {
- expect(findPrevButton().attributes().disabled).toBe(undefined);
+
+ const onlyNextPageInfo = {
+ hasPreviousPage: false,
+ hasNextPage: true,
+ startCursor,
+ endCursor,
};
- const expectDisabledNext = () => {
- expect(findNextButton().attributes().disabled).toBe('disabled');
+
+ const onlyPrevPageInfo = {
+ hasPreviousPage: true,
+ hasNextPage: false,
+ startCursor,
+ endCursor,
};
- const expectEnabledNext = () => {
- expect(findNextButton().attributes().disabled).toBe(undefined);
+
+ const prevAndNextPageInfo = {
+ hasPreviousPage: true,
+ hasNextPage: true,
+ startCursor,
+ endCursor,
};
- describe('when there is only one page of results', () => {
- beforeEach(() => {
- createComponent({
- hasPreviousPage: false,
- hasNextPage: false,
+ const findPrevButton = () => wrapper.findByTestId('prevButton');
+ const findNextButton = () => wrapper.findByTestId('nextButton');
+
+ describe.each`
+ description | pageInfo | prevEnabled | nextEnabled
+ ${'when there is only one page of results'} | ${singlePageInfo} | ${false} | ${false}
+ ${'when there is a next page, but not a previous page'} | ${onlyNextPageInfo} | ${false} | ${true}
+ ${'when there is a previous page, but not a next page'} | ${onlyPrevPageInfo} | ${true} | ${false}
+ ${'when there is both a previous and next page'} | ${prevAndNextPageInfo} | ${true} | ${true}
+ `('component states', ({ description, pageInfo, prevEnabled, nextEnabled }) => {
+ describe(description, () => {
+ beforeEach(() => {
+ createComponent(pageInfo);
});
- });
-
- it('does not render a GlKeysetPagination', () => {
- expect(findGlKeysetPagination().exists()).toBe(false);
- });
- });
- describe('when there is a next page, but not a previous page', () => {
- beforeEach(() => {
- createComponent({
- hasPreviousPage: false,
- hasNextPage: true,
+ it(`renders the "Prev" button as ${prevEnabled ? 'enabled' : 'disabled'}`, () => {
+ expect(findPrevButton().attributes().disabled).toBe(prevEnabled ? undefined : 'disabled');
});
- });
-
- it('renders a disabled "Prev" button', () => {
- expectDisabledPrev();
- });
- it('renders an enabled "Next" button', () => {
- expectEnabledNext();
- });
- });
-
- describe('when there is a previous page, but not a next page', () => {
- beforeEach(() => {
- createComponent({
- hasPreviousPage: true,
- hasNextPage: false,
- });
- });
-
- it('renders a enabled "Prev" button', () => {
- expectEnabledPrev();
- });
-
- it('renders an disabled "Next" button', () => {
- expectDisabledNext();
- });
- });
-
- describe('when there is both a previous page and a next page', () => {
- beforeEach(() => {
- createComponent({
- hasPreviousPage: true,
- hasNextPage: true,
+ it(`renders the "Next" button as ${nextEnabled ? 'enabled' : 'disabled'}`, () => {
+ expect(findNextButton().attributes().disabled).toBe(nextEnabled ? undefined : 'disabled');
});
});
-
- it('renders a enabled "Prev" button', () => {
- expectEnabledPrev();
- });
-
- it('renders an enabled "Next" button', () => {
- expectEnabledNext();
- });
});
describe('button behavior', () => {
beforeEach(() => {
- createComponent({
- hasPreviousPage: true,
- hasNextPage: true,
- ...cursors,
- });
+ createComponent(prevAndNextPageInfo);
});
describe('next button behavior', () => {
@@ -142,33 +96,29 @@ describe('~/releases/components/releases_pagination.vue', () => {
findNextButton().trigger('click');
});
- it('calls fetchReleases with the correct after cursor', () => {
- expect(indexModule.actions.fetchReleases.mock.calls).toEqual([
- [expect.anything(), { after: cursors.endCursor }],
- ]);
+ it('emits an "next" event with the "after" cursor', () => {
+ expect(onNext.mock.calls).toEqual([[endCursor]]);
});
it('calls historyPushState with the new URL', () => {
expect(historyPushState.mock.calls).toEqual([
- [expect.stringContaining(`?after=${cursors.endCursor}`)],
+ [expect.stringContaining(`?after=${endCursor}`)],
]);
});
});
- describe('previous button behavior', () => {
+ describe('prev button behavior', () => {
beforeEach(() => {
findPrevButton().trigger('click');
});
- it('calls fetchReleases with the correct before cursor', () => {
- expect(indexModule.actions.fetchReleases.mock.calls).toEqual([
- [expect.anything(), { before: cursors.startCursor }],
- ]);
+ it('emits an "prev" event with the "before" cursor', () => {
+ expect(onPrev.mock.calls).toEqual([[startCursor]]);
});
it('calls historyPushState with the new URL', () => {
expect(historyPushState.mock.calls).toEqual([
- [expect.stringContaining(`?before=${cursors.startCursor}`)],
+ [expect.stringContaining(`?before=${startCursor}`)],
]);
});
});
diff --git a/spec/frontend/releases/components/releases_sort_apollo_client_spec.js b/spec/frontend/releases/components/releases_sort_apollo_client_spec.js
deleted file mode 100644
index d93a932af01..00000000000
--- a/spec/frontend/releases/components/releases_sort_apollo_client_spec.js
+++ /dev/null
@@ -1,103 +0,0 @@
-import { GlSorting, GlSortingItem } from '@gitlab/ui';
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import ReleasesSortApolloClient from '~/releases/components/releases_sort_apollo_client.vue';
-import { RELEASED_AT_ASC, RELEASED_AT_DESC, CREATED_ASC, CREATED_DESC } from '~/releases/constants';
-
-describe('releases_sort_apollo_client.vue', () => {
- let wrapper;
-
- const createComponent = (valueProp = RELEASED_AT_ASC) => {
- wrapper = shallowMountExtended(ReleasesSortApolloClient, {
- propsData: {
- value: valueProp,
- },
- stubs: {
- GlSortingItem,
- },
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- const findSorting = () => wrapper.findComponent(GlSorting);
- const findSortingItems = () => wrapper.findAllComponents(GlSortingItem);
- const findReleasedDateItem = () =>
- findSortingItems().wrappers.find((item) => item.text() === 'Released date');
- const findCreatedDateItem = () =>
- findSortingItems().wrappers.find((item) => item.text() === 'Created date');
- const getSortingItemsInfo = () =>
- findSortingItems().wrappers.map((item) => ({
- label: item.text(),
- active: item.attributes().active === 'true',
- }));
-
- describe.each`
- valueProp | text | isAscending | items
- ${RELEASED_AT_ASC} | ${'Released date'} | ${true} | ${[{ label: 'Released date', active: true }, { label: 'Created date', active: false }]}
- ${RELEASED_AT_DESC} | ${'Released date'} | ${false} | ${[{ label: 'Released date', active: true }, { label: 'Created date', active: false }]}
- ${CREATED_ASC} | ${'Created date'} | ${true} | ${[{ label: 'Released date', active: false }, { label: 'Created date', active: true }]}
- ${CREATED_DESC} | ${'Created date'} | ${false} | ${[{ label: 'Released date', active: false }, { label: 'Created date', active: true }]}
- `('component states', ({ valueProp, text, isAscending, items }) => {
- beforeEach(() => {
- createComponent(valueProp);
- });
-
- it(`when the sort is ${valueProp}, provides the GlSorting with the props text="${text}" and isAscending=${isAscending}`, () => {
- expect(findSorting().props()).toEqual(
- expect.objectContaining({
- text,
- isAscending,
- }),
- );
- });
-
- it(`when the sort is ${valueProp}, renders the expected dropdown items`, () => {
- expect(getSortingItemsInfo()).toEqual(items);
- });
- });
-
- const clickReleasedDateItem = () => findReleasedDateItem().vm.$emit('click');
- const clickCreatedDateItem = () => findCreatedDateItem().vm.$emit('click');
- const clickSortDirectionButton = () => findSorting().vm.$emit('sortDirectionChange');
-
- const releasedAtDropdownItemDescription = 'released at dropdown item';
- const createdAtDropdownItemDescription = 'created at dropdown item';
- const sortDirectionButtonDescription = 'sort direction button';
-
- describe.each`
- initialValueProp | itemClickFn | itemToClickDescription | emittedEvent
- ${RELEASED_AT_ASC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${undefined}
- ${RELEASED_AT_ASC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${CREATED_ASC}
- ${RELEASED_AT_ASC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${RELEASED_AT_DESC}
- ${RELEASED_AT_DESC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${undefined}
- ${RELEASED_AT_DESC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${CREATED_DESC}
- ${RELEASED_AT_DESC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${RELEASED_AT_ASC}
- ${CREATED_ASC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${RELEASED_AT_ASC}
- ${CREATED_ASC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${undefined}
- ${CREATED_ASC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${CREATED_DESC}
- ${CREATED_DESC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${RELEASED_AT_DESC}
- ${CREATED_DESC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${undefined}
- ${CREATED_DESC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${CREATED_ASC}
- `('input event', ({ initialValueProp, itemClickFn, itemToClickDescription, emittedEvent }) => {
- beforeEach(() => {
- createComponent(initialValueProp);
- itemClickFn();
- });
-
- it(`emits ${
- emittedEvent || 'nothing'
- } when value prop is ${initialValueProp} and the ${itemToClickDescription} is clicked`, () => {
- expect(wrapper.emitted().input?.[0]?.[0]).toEqual(emittedEvent);
- });
- });
-
- describe('prop validation', () => {
- it('validates that the `value` prop is one of the expected sort strings', () => {
- expect(() => {
- createComponent('not a valid value');
- }).toThrow('Invalid prop: custom validator check failed');
- });
- });
-});
diff --git a/spec/frontend/releases/components/releases_sort_spec.js b/spec/frontend/releases/components/releases_sort_spec.js
index 7774532bc12..c6e1846d252 100644
--- a/spec/frontend/releases/components/releases_sort_spec.js
+++ b/spec/frontend/releases/components/releases_sort_spec.js
@@ -1,65 +1,103 @@
import { GlSorting, GlSortingItem } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import Vue from 'vue';
-import Vuex from 'vuex';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ReleasesSort from '~/releases/components/releases_sort.vue';
-import createStore from '~/releases/stores';
-import createIndexModule from '~/releases/stores/modules/index';
+import { RELEASED_AT_ASC, RELEASED_AT_DESC, CREATED_ASC, CREATED_DESC } from '~/releases/constants';
-Vue.use(Vuex);
-
-describe('~/releases/components/releases_sort.vue', () => {
+describe('releases_sort.vue', () => {
let wrapper;
- let store;
- let indexModule;
- const projectId = 8;
-
- const createComponent = () => {
- indexModule = createIndexModule({ projectId });
- store = createStore({
- modules: {
- index: indexModule,
+ const createComponent = (valueProp = RELEASED_AT_ASC) => {
+ wrapper = shallowMountExtended(ReleasesSort, {
+ propsData: {
+ value: valueProp,
},
- });
-
- store.dispatch = jest.fn();
-
- wrapper = shallowMount(ReleasesSort, {
- store,
stubs: {
GlSortingItem,
},
});
};
- const findReleasesSorting = () => wrapper.find(GlSorting);
- const findSortingItems = () => wrapper.findAll(GlSortingItem);
-
afterEach(() => {
wrapper.destroy();
- wrapper = null;
});
- beforeEach(() => {
- createComponent();
- });
+ const findSorting = () => wrapper.findComponent(GlSorting);
+ const findSortingItems = () => wrapper.findAllComponents(GlSortingItem);
+ const findReleasedDateItem = () =>
+ findSortingItems().wrappers.find((item) => item.text() === 'Released date');
+ const findCreatedDateItem = () =>
+ findSortingItems().wrappers.find((item) => item.text() === 'Created date');
+ const getSortingItemsInfo = () =>
+ findSortingItems().wrappers.map((item) => ({
+ label: item.text(),
+ active: item.attributes().active === 'true',
+ }));
+
+ describe.each`
+ valueProp | text | isAscending | items
+ ${RELEASED_AT_ASC} | ${'Released date'} | ${true} | ${[{ label: 'Released date', active: true }, { label: 'Created date', active: false }]}
+ ${RELEASED_AT_DESC} | ${'Released date'} | ${false} | ${[{ label: 'Released date', active: true }, { label: 'Created date', active: false }]}
+ ${CREATED_ASC} | ${'Created date'} | ${true} | ${[{ label: 'Released date', active: false }, { label: 'Created date', active: true }]}
+ ${CREATED_DESC} | ${'Created date'} | ${false} | ${[{ label: 'Released date', active: false }, { label: 'Created date', active: true }]}
+ `('component states', ({ valueProp, text, isAscending, items }) => {
+ beforeEach(() => {
+ createComponent(valueProp);
+ });
- it('has all the sortable items', () => {
- expect(findSortingItems()).toHaveLength(wrapper.vm.sortOptions.length);
+ it(`when the sort is ${valueProp}, provides the GlSorting with the props text="${text}" and isAscending=${isAscending}`, () => {
+ expect(findSorting().props()).toEqual(
+ expect.objectContaining({
+ text,
+ isAscending,
+ }),
+ );
+ });
+
+ it(`when the sort is ${valueProp}, renders the expected dropdown items`, () => {
+ expect(getSortingItemsInfo()).toEqual(items);
+ });
});
- it('on sort change set sorting in vuex and emit event', () => {
- findReleasesSorting().vm.$emit('sortDirectionChange');
- expect(store.dispatch).toHaveBeenCalledWith('index/setSorting', { sort: 'asc' });
- expect(wrapper.emitted('sort:changed')).toBeTruthy();
+ const clickReleasedDateItem = () => findReleasedDateItem().vm.$emit('click');
+ const clickCreatedDateItem = () => findCreatedDateItem().vm.$emit('click');
+ const clickSortDirectionButton = () => findSorting().vm.$emit('sortDirectionChange');
+
+ const releasedAtDropdownItemDescription = 'released at dropdown item';
+ const createdAtDropdownItemDescription = 'created at dropdown item';
+ const sortDirectionButtonDescription = 'sort direction button';
+
+ describe.each`
+ initialValueProp | itemClickFn | itemToClickDescription | emittedEvent
+ ${RELEASED_AT_ASC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${undefined}
+ ${RELEASED_AT_ASC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${CREATED_ASC}
+ ${RELEASED_AT_ASC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${RELEASED_AT_DESC}
+ ${RELEASED_AT_DESC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${undefined}
+ ${RELEASED_AT_DESC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${CREATED_DESC}
+ ${RELEASED_AT_DESC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${RELEASED_AT_ASC}
+ ${CREATED_ASC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${RELEASED_AT_ASC}
+ ${CREATED_ASC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${undefined}
+ ${CREATED_ASC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${CREATED_DESC}
+ ${CREATED_DESC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${RELEASED_AT_DESC}
+ ${CREATED_DESC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${undefined}
+ ${CREATED_DESC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${CREATED_ASC}
+ `('input event', ({ initialValueProp, itemClickFn, itemToClickDescription, emittedEvent }) => {
+ beforeEach(() => {
+ createComponent(initialValueProp);
+ itemClickFn();
+ });
+
+ it(`emits ${
+ emittedEvent || 'nothing'
+ } when value prop is ${initialValueProp} and the ${itemToClickDescription} is clicked`, () => {
+ expect(wrapper.emitted().input?.[0]?.[0]).toEqual(emittedEvent);
+ });
});
- it('on sort item click set sorting and emit event', () => {
- const item = findSortingItems().at(0);
- const { orderBy } = wrapper.vm.sortOptions[0];
- item.vm.$emit('click');
- expect(store.dispatch).toHaveBeenCalledWith('index/setSorting', { orderBy });
- expect(wrapper.emitted('sort:changed')).toBeTruthy();
+ describe('prop validation', () => {
+ it('validates that the `value` prop is one of the expected sort strings', () => {
+ expect(() => {
+ createComponent('not a valid value');
+ }).toThrow('Invalid prop: custom validator check failed');
+ });
});
});
diff --git a/spec/frontend/releases/stores/modules/list/actions_spec.js b/spec/frontend/releases/stores/modules/list/actions_spec.js
deleted file mode 100644
index 91406f7e2f4..00000000000
--- a/spec/frontend/releases/stores/modules/list/actions_spec.js
+++ /dev/null
@@ -1,197 +0,0 @@
-import { cloneDeep } from 'lodash';
-import originalGraphqlReleasesResponse from 'test_fixtures/graphql/releases/graphql/queries/all_releases.query.graphql.json';
-import testAction from 'helpers/vuex_action_helper';
-import { PAGE_SIZE } from '~/releases/constants';
-import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql';
-import {
- fetchReleases,
- receiveReleasesError,
- setSorting,
-} from '~/releases/stores/modules/index/actions';
-import * as types from '~/releases/stores/modules/index/mutation_types';
-import createState from '~/releases/stores/modules/index/state';
-import { gqClient, convertAllReleasesGraphQLResponse } from '~/releases/util';
-
-describe('Releases State actions', () => {
- let mockedState;
- let graphqlReleasesResponse;
-
- const projectPath = 'root/test-project';
- const projectId = 19;
- const before = 'testBeforeCursor';
- const after = 'testAfterCursor';
-
- beforeEach(() => {
- mockedState = {
- ...createState({
- projectId,
- projectPath,
- }),
- };
-
- graphqlReleasesResponse = cloneDeep(originalGraphqlReleasesResponse);
- });
-
- describe('fetchReleases', () => {
- describe('GraphQL query variables', () => {
- let vuexParams;
-
- beforeEach(() => {
- jest.spyOn(gqClient, 'query');
-
- vuexParams = { dispatch: jest.fn(), commit: jest.fn(), state: mockedState };
- });
-
- describe('when neither a before nor an after parameter is provided', () => {
- beforeEach(() => {
- fetchReleases(vuexParams, { before: undefined, after: undefined });
- });
-
- it('makes a GraphQl query with a first variable', () => {
- expect(gqClient.query).toHaveBeenCalledWith({
- query: allReleasesQuery,
- variables: { fullPath: projectPath, first: PAGE_SIZE, sort: 'RELEASED_AT_DESC' },
- });
- });
- });
-
- describe('when only a before parameter is provided', () => {
- beforeEach(() => {
- fetchReleases(vuexParams, { before, after: undefined });
- });
-
- it('makes a GraphQl query with last and before variables', () => {
- expect(gqClient.query).toHaveBeenCalledWith({
- query: allReleasesQuery,
- variables: { fullPath: projectPath, last: PAGE_SIZE, before, sort: 'RELEASED_AT_DESC' },
- });
- });
- });
-
- describe('when only an after parameter is provided', () => {
- beforeEach(() => {
- fetchReleases(vuexParams, { before: undefined, after });
- });
-
- it('makes a GraphQl query with first and after variables', () => {
- expect(gqClient.query).toHaveBeenCalledWith({
- query: allReleasesQuery,
- variables: { fullPath: projectPath, first: PAGE_SIZE, after, sort: 'RELEASED_AT_DESC' },
- });
- });
- });
-
- describe('when both before and after parameters are provided', () => {
- it('throws an error', () => {
- const callFetchReleases = () => {
- fetchReleases(vuexParams, { before, after });
- };
-
- expect(callFetchReleases).toThrowError(
- 'Both a `before` and an `after` parameter were provided to fetchReleases. These parameters cannot be used together.',
- );
- });
- });
-
- describe('when the sort parameters are provided', () => {
- it.each`
- sort | orderBy | ReleaseSort
- ${'asc'} | ${'released_at'} | ${'RELEASED_AT_ASC'}
- ${'desc'} | ${'released_at'} | ${'RELEASED_AT_DESC'}
- ${'asc'} | ${'created_at'} | ${'CREATED_ASC'}
- ${'desc'} | ${'created_at'} | ${'CREATED_DESC'}
- `(
- 'correctly sets $ReleaseSort based on $sort and $orderBy',
- ({ sort, orderBy, ReleaseSort }) => {
- mockedState.sorting.sort = sort;
- mockedState.sorting.orderBy = orderBy;
-
- fetchReleases(vuexParams, { before: undefined, after: undefined });
-
- expect(gqClient.query).toHaveBeenCalledWith({
- query: allReleasesQuery,
- variables: { fullPath: projectPath, first: PAGE_SIZE, sort: ReleaseSort },
- });
- },
- );
- });
- });
-
- describe('when the request is successful', () => {
- beforeEach(() => {
- jest.spyOn(gqClient, 'query').mockResolvedValue(graphqlReleasesResponse);
- });
-
- it(`commits ${types.REQUEST_RELEASES} and ${types.RECEIVE_RELEASES_SUCCESS}`, () => {
- const convertedResponse = convertAllReleasesGraphQLResponse(graphqlReleasesResponse);
-
- return testAction(
- fetchReleases,
- {},
- mockedState,
- [
- {
- type: types.REQUEST_RELEASES,
- },
- {
- type: types.RECEIVE_RELEASES_SUCCESS,
- payload: {
- data: convertedResponse.data,
- pageInfo: convertedResponse.paginationInfo,
- },
- },
- ],
- [],
- );
- });
- });
-
- describe('when the request fails', () => {
- beforeEach(() => {
- jest.spyOn(gqClient, 'query').mockRejectedValue(new Error('Something went wrong!'));
- });
-
- it(`commits ${types.REQUEST_RELEASES} and dispatch receiveReleasesError`, () => {
- return testAction(
- fetchReleases,
- {},
- mockedState,
- [
- {
- type: types.REQUEST_RELEASES,
- },
- ],
- [
- {
- type: 'receiveReleasesError',
- },
- ],
- );
- });
- });
- });
-
- describe('receiveReleasesError', () => {
- it('should commit RECEIVE_RELEASES_ERROR mutation', () => {
- return testAction(
- receiveReleasesError,
- null,
- mockedState,
- [{ type: types.RECEIVE_RELEASES_ERROR }],
- [],
- );
- });
- });
-
- describe('setSorting', () => {
- it('should commit SET_SORTING', () => {
- return testAction(
- setSorting,
- { orderBy: 'released_at', sort: 'asc' },
- null,
- [{ type: types.SET_SORTING, payload: { orderBy: 'released_at', sort: 'asc' } }],
- [],
- );
- });
- });
-});
diff --git a/spec/frontend/releases/stores/modules/list/helpers.js b/spec/frontend/releases/stores/modules/list/helpers.js
deleted file mode 100644
index 6669f44aa95..00000000000
--- a/spec/frontend/releases/stores/modules/list/helpers.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import state from '~/releases/stores/modules/index/state';
-
-export const resetStore = (store) => {
- store.replaceState(state());
-};
diff --git a/spec/frontend/releases/stores/modules/list/mutations_spec.js b/spec/frontend/releases/stores/modules/list/mutations_spec.js
deleted file mode 100644
index 49e324c28a5..00000000000
--- a/spec/frontend/releases/stores/modules/list/mutations_spec.js
+++ /dev/null
@@ -1,81 +0,0 @@
-import originalRelease from 'test_fixtures/api/releases/release.json';
-import graphqlReleasesResponse from 'test_fixtures/graphql/releases/graphql/queries/all_releases.query.graphql.json';
-import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-import * as types from '~/releases/stores/modules/index/mutation_types';
-import mutations from '~/releases/stores/modules/index/mutations';
-import createState from '~/releases/stores/modules/index/state';
-import { convertAllReleasesGraphQLResponse } from '~/releases/util';
-
-const originalReleases = [originalRelease];
-
-describe('Releases Store Mutations', () => {
- let stateCopy;
- let pageInfo;
- let releases;
-
- beforeEach(() => {
- stateCopy = createState({});
- pageInfo = convertAllReleasesGraphQLResponse(graphqlReleasesResponse).paginationInfo;
- releases = convertObjectPropsToCamelCase(originalReleases, { deep: true });
- });
-
- describe('REQUEST_RELEASES', () => {
- it('sets isLoading to true', () => {
- mutations[types.REQUEST_RELEASES](stateCopy);
-
- expect(stateCopy.isLoading).toEqual(true);
- });
- });
-
- describe('RECEIVE_RELEASES_SUCCESS', () => {
- beforeEach(() => {
- mutations[types.RECEIVE_RELEASES_SUCCESS](stateCopy, {
- pageInfo,
- data: releases,
- });
- });
-
- it('sets is loading to false', () => {
- expect(stateCopy.isLoading).toEqual(false);
- });
-
- it('sets hasError to false', () => {
- expect(stateCopy.hasError).toEqual(false);
- });
-
- it('sets data', () => {
- expect(stateCopy.releases).toEqual(releases);
- });
-
- it('sets pageInfo', () => {
- expect(stateCopy.pageInfo).toEqual(pageInfo);
- });
- });
-
- describe('RECEIVE_RELEASES_ERROR', () => {
- it('resets data', () => {
- mutations[types.RECEIVE_RELEASES_SUCCESS](stateCopy, {
- pageInfo,
- data: releases,
- });
-
- mutations[types.RECEIVE_RELEASES_ERROR](stateCopy);
-
- expect(stateCopy.isLoading).toEqual(false);
- expect(stateCopy.releases).toEqual([]);
- expect(stateCopy.pageInfo).toEqual({});
- });
- });
-
- describe('SET_SORTING', () => {
- it('should merge the sorting object with sort value', () => {
- mutations[types.SET_SORTING](stateCopy, { sort: 'asc' });
- expect(stateCopy.sorting).toEqual({ ...stateCopy.sorting, sort: 'asc' });
- });
-
- it('should merge the sorting object with order_by value', () => {
- mutations[types.SET_SORTING](stateCopy, { orderBy: 'created_at' });
- expect(stateCopy.sorting).toEqual({ ...stateCopy.sorting, orderBy: 'created_at' });
- });
- });
-});
diff --git a/spec/frontend/reports/accessibility_report/store/actions_spec.js b/spec/frontend/reports/accessibility_report/store/actions_spec.js
index 46dbe1ff7a1..bab6c4905a7 100644
--- a/spec/frontend/reports/accessibility_report/store/actions_spec.js
+++ b/spec/frontend/reports/accessibility_report/store/actions_spec.js
@@ -17,16 +17,15 @@ describe('Accessibility Reports actions', () => {
});
describe('setEndpoints', () => {
- it('should commit SET_ENDPOINTS mutation', (done) => {
+ it('should commit SET_ENDPOINTS mutation', () => {
const endpoint = 'endpoint.json';
- testAction(
+ return testAction(
actions.setEndpoint,
endpoint,
localState,
[{ type: types.SET_ENDPOINT, payload: endpoint }],
[],
- done,
);
});
});
@@ -46,11 +45,11 @@ describe('Accessibility Reports actions', () => {
});
describe('success', () => {
- it('should commit REQUEST_REPORT mutation and dispatch receiveReportSuccess', (done) => {
+ it('should commit REQUEST_REPORT mutation and dispatch receiveReportSuccess', () => {
const data = { report: { summary: {} } };
mock.onGet(`${TEST_HOST}/endpoint.json`).reply(200, data);
- testAction(
+ return testAction(
actions.fetchReport,
null,
localState,
@@ -61,60 +60,55 @@ describe('Accessibility Reports actions', () => {
type: 'receiveReportSuccess',
},
],
- done,
);
});
});
describe('error', () => {
- it('should commit REQUEST_REPORT and RECEIVE_REPORT_ERROR mutations', (done) => {
+ it('should commit REQUEST_REPORT and RECEIVE_REPORT_ERROR mutations', () => {
mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500);
- testAction(
+ return testAction(
actions.fetchReport,
null,
localState,
[{ type: types.REQUEST_REPORT }],
[{ type: 'receiveReportError' }],
- done,
);
});
});
});
describe('receiveReportSuccess', () => {
- it('should commit RECEIVE_REPORT_SUCCESS mutation with 200', (done) => {
- testAction(
+ it('should commit RECEIVE_REPORT_SUCCESS mutation with 200', () => {
+ return testAction(
actions.receiveReportSuccess,
{ status: 200, data: mockReport },
localState,
[{ type: types.RECEIVE_REPORT_SUCCESS, payload: mockReport }],
[{ type: 'stopPolling' }],
- done,
);
});
- it('should not commit RECEIVE_REPORTS_SUCCESS mutation with 204', (done) => {
- testAction(
+ it('should not commit RECEIVE_REPORTS_SUCCESS mutation with 204', () => {
+ return testAction(
actions.receiveReportSuccess,
{ status: 204, data: mockReport },
localState,
[],
[],
- done,
);
});
});
describe('receiveReportError', () => {
- it('should commit RECEIVE_REPORT_ERROR mutation', (done) => {
- testAction(
+ it('should commit RECEIVE_REPORT_ERROR mutation', () => {
+ return testAction(
actions.receiveReportError,
null,
localState,
[{ type: types.RECEIVE_REPORT_ERROR }],
[{ type: 'stopPolling' }],
- done,
);
});
});
diff --git a/spec/frontend/reports/codequality_report/components/codequality_issue_body_spec.js b/spec/frontend/reports/codequality_report/components/codequality_issue_body_spec.js
index c548007a8a6..17f07ac2b8f 100644
--- a/spec/frontend/reports/codequality_report/components/codequality_issue_body_spec.js
+++ b/spec/frontend/reports/codequality_report/components/codequality_issue_body_spec.js
@@ -51,6 +51,7 @@ describe('code quality issue body issue body', () => {
${'blocker'} | ${'text-danger-800'} | ${'severity-critical'}
${'unknown'} | ${'text-secondary-400'} | ${'severity-unknown'}
${'invalid'} | ${'text-secondary-400'} | ${'severity-unknown'}
+ ${undefined} | ${'text-secondary-400'} | ${'severity-unknown'}
`(
'renders correct icon for "$severity" severity rating',
({ severity, iconClass, iconName }) => {
diff --git a/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js b/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js
index 1f923f41274..b61b65c2713 100644
--- a/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js
+++ b/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js
@@ -135,7 +135,7 @@ describe('Grouped code quality reports app', () => {
});
it('does not render a help icon', () => {
- expect(findWidget().find('[data-testid="question-icon"]').exists()).toBe(false);
+ expect(findWidget().find('[data-testid="question-o-icon"]').exists()).toBe(false);
});
describe('when base report was not found', () => {
@@ -144,7 +144,7 @@ describe('Grouped code quality reports app', () => {
});
it('renders a help icon with more information', () => {
- expect(findWidget().find('[data-testid="question-icon"]').exists()).toBe(true);
+ expect(findWidget().find('[data-testid="question-o-icon"]').exists()).toBe(true);
});
});
});
diff --git a/spec/frontend/reports/codequality_report/store/actions_spec.js b/spec/frontend/reports/codequality_report/store/actions_spec.js
index 1821390786b..71f1a0f4de0 100644
--- a/spec/frontend/reports/codequality_report/store/actions_spec.js
+++ b/spec/frontend/reports/codequality_report/store/actions_spec.js
@@ -23,7 +23,7 @@ describe('Codequality Reports actions', () => {
});
describe('setPaths', () => {
- it('should commit SET_PATHS mutation', (done) => {
+ it('should commit SET_PATHS mutation', () => {
const paths = {
baseBlobPath: 'baseBlobPath',
headBlobPath: 'headBlobPath',
@@ -31,13 +31,12 @@ describe('Codequality Reports actions', () => {
helpPath: 'codequalityHelpPath',
};
- testAction(
+ return testAction(
actions.setPaths,
paths,
localState,
[{ type: types.SET_PATHS, payload: paths }],
[],
- done,
);
});
});
@@ -56,10 +55,10 @@ describe('Codequality Reports actions', () => {
});
describe('on success', () => {
- it('commits REQUEST_REPORTS and dispatches receiveReportsSuccess', (done) => {
+ it('commits REQUEST_REPORTS and dispatches receiveReportsSuccess', () => {
mock.onGet(endpoint).reply(200, reportIssues);
- testAction(
+ return testAction(
actions.fetchReports,
null,
localState,
@@ -70,51 +69,48 @@ describe('Codequality Reports actions', () => {
type: 'receiveReportsSuccess',
},
],
- done,
);
});
});
describe('on error', () => {
- it('commits REQUEST_REPORTS and dispatches receiveReportsError', (done) => {
+ it('commits REQUEST_REPORTS and dispatches receiveReportsError', () => {
mock.onGet(endpoint).reply(500);
- testAction(
+ return testAction(
actions.fetchReports,
null,
localState,
[{ type: types.REQUEST_REPORTS }],
[{ type: 'receiveReportsError', payload: expect.any(Error) }],
- done,
);
});
});
describe('when base report is not found', () => {
- it('commits REQUEST_REPORTS and dispatches receiveReportsError', (done) => {
+ it('commits REQUEST_REPORTS and dispatches receiveReportsError', () => {
const data = { status: STATUS_NOT_FOUND };
mock.onGet(`${TEST_HOST}/codequality_reports.json`).reply(200, data);
- testAction(
+ return testAction(
actions.fetchReports,
null,
localState,
[{ type: types.REQUEST_REPORTS }],
[{ type: 'receiveReportsError', payload: data }],
- done,
);
});
});
describe('while waiting for report results', () => {
- it('continues polling until it receives data', (done) => {
+ it('continues polling until it receives data', () => {
mock
.onGet(endpoint)
.replyOnce(204, undefined, pollIntervalHeader)
.onGet(endpoint)
.reply(200, reportIssues);
- Promise.all([
+ return Promise.all([
testAction(
actions.fetchReports,
null,
@@ -126,7 +122,6 @@ describe('Codequality Reports actions', () => {
type: 'receiveReportsSuccess',
},
],
- done,
),
axios
// wait for initial NO_CONTENT response to be fulfilled
@@ -134,24 +129,23 @@ describe('Codequality Reports actions', () => {
.then(() => {
jest.advanceTimersByTime(pollInterval);
}),
- ]).catch(done.fail);
+ ]);
});
- it('continues polling until it receives an error', (done) => {
+ it('continues polling until it receives an error', () => {
mock
.onGet(endpoint)
.replyOnce(204, undefined, pollIntervalHeader)
.onGet(endpoint)
.reply(500);
- Promise.all([
+ return Promise.all([
testAction(
actions.fetchReports,
null,
localState,
[{ type: types.REQUEST_REPORTS }],
[{ type: 'receiveReportsError', payload: expect.any(Error) }],
- done,
),
axios
// wait for initial NO_CONTENT response to be fulfilled
@@ -159,35 +153,33 @@ describe('Codequality Reports actions', () => {
.then(() => {
jest.advanceTimersByTime(pollInterval);
}),
- ]).catch(done.fail);
+ ]);
});
});
});
describe('receiveReportsSuccess', () => {
- it('commits RECEIVE_REPORTS_SUCCESS', (done) => {
+ it('commits RECEIVE_REPORTS_SUCCESS', () => {
const data = { issues: [] };
- testAction(
+ return testAction(
actions.receiveReportsSuccess,
data,
localState,
[{ type: types.RECEIVE_REPORTS_SUCCESS, payload: data }],
[],
- done,
);
});
});
describe('receiveReportsError', () => {
- it('commits RECEIVE_REPORTS_ERROR', (done) => {
- testAction(
+ it('commits RECEIVE_REPORTS_ERROR', () => {
+ return testAction(
actions.receiveReportsError,
null,
localState,
[{ type: types.RECEIVE_REPORTS_ERROR, payload: null }],
[],
- done,
);
});
});
diff --git a/spec/frontend/reports/components/report_section_spec.js b/spec/frontend/reports/components/report_section_spec.js
index f9eb6dd05f3..888b49f3e0c 100644
--- a/spec/frontend/reports/components/report_section_spec.js
+++ b/spec/frontend/reports/components/report_section_spec.js
@@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import mountComponent, { mountComponentWithSlots } from 'helpers/vue_mount_component_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import HelpPopover from '~/vue_shared/components/help_popover.vue';
import reportSection from '~/reports/components/report_section.vue';
describe('Report section', () => {
@@ -9,6 +10,7 @@ describe('Report section', () => {
let wrapper;
const ReportSection = Vue.extend(reportSection);
const findCollapseButton = () => wrapper.findByTestId('report-section-expand-button');
+ const findPopover = () => wrapper.findComponent(HelpPopover);
const resolvedIssues = [
{
@@ -269,4 +271,33 @@ describe('Report section', () => {
expect(vm.$el.textContent.trim()).not.toContain('This is a success');
});
});
+
+ describe('help popover', () => {
+ describe('when popover options are defined', () => {
+ const options = {
+ title: 'foo',
+ content: 'bar',
+ };
+
+ beforeEach(() => {
+ createComponent({
+ popoverOptions: options,
+ });
+ });
+
+ it('popover is shown with options', () => {
+ expect(findPopover().props('options')).toEqual(options);
+ });
+ });
+
+ describe('when popover options are not defined', () => {
+ beforeEach(() => {
+ createComponent({ popoverOptions: {} });
+ });
+
+ it('popover is not shown', () => {
+ expect(findPopover().exists()).toBe(false);
+ });
+ });
+ });
});
diff --git a/spec/frontend/reports/components/summary_row_spec.js b/spec/frontend/reports/components/summary_row_spec.js
index 04d9d10dcd2..778660d9e44 100644
--- a/spec/frontend/reports/components/summary_row_spec.js
+++ b/spec/frontend/reports/components/summary_row_spec.js
@@ -1,25 +1,26 @@
import { mount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import HelpPopover from '~/vue_shared/components/help_popover.vue';
import SummaryRow from '~/reports/components/summary_row.vue';
describe('Summary row', () => {
let wrapper;
- const props = {
- summary: 'SAST detected 1 new vulnerability and 1 fixed vulnerability',
- popoverOptions: {
- title: 'Static Application Security Testing (SAST)',
- content: '<a>Learn more about SAST</a>',
- },
- statusIcon: 'warning',
+ const summary = 'SAST detected 1 new vulnerability and 1 fixed vulnerability';
+ const popoverOptions = {
+ title: 'Static Application Security Testing (SAST)',
+ content: '<a>Learn more about SAST</a>',
};
+ const statusIcon = 'warning';
- const createComponent = ({ propsData = {}, slots = {} } = {}) => {
+ const createComponent = ({ props = {}, slots = {} } = {}) => {
wrapper = extendedWrapper(
mount(SummaryRow, {
propsData: {
+ summary,
+ popoverOptions,
+ statusIcon,
...props,
- ...propsData,
},
slots,
}),
@@ -28,6 +29,7 @@ describe('Summary row', () => {
const findSummary = () => wrapper.findByTestId('summary-row-description');
const findStatusIcon = () => wrapper.findByTestId('summary-row-icon');
+ const findHelpPopover = () => wrapper.findComponent(HelpPopover);
afterEach(() => {
wrapper.destroy();
@@ -36,7 +38,7 @@ describe('Summary row', () => {
it('renders provided summary', () => {
createComponent();
- expect(findSummary().text()).toContain(props.summary);
+ expect(findSummary().text()).toContain(summary);
});
it('renders provided icon', () => {
@@ -44,12 +46,22 @@ describe('Summary row', () => {
expect(findStatusIcon().classes()).toContain('js-ci-status-icon-warning');
});
+ it('renders help popover if popoverOptions are provided', () => {
+ createComponent();
+ expect(findHelpPopover().props('options')).toEqual(popoverOptions);
+ });
+
+ it('does not render help popover if popoverOptions are not provided', () => {
+ createComponent({ props: { popoverOptions: null } });
+ expect(findHelpPopover().exists()).toBe(false);
+ });
+
describe('summary slot', () => {
it('replaces the summary prop', () => {
const summarySlotContent = 'Summary slot content';
createComponent({ slots: { summary: summarySlotContent } });
- expect(wrapper.text()).not.toContain(props.summary);
+ expect(wrapper.text()).not.toContain(summary);
expect(findSummary().text()).toContain(summarySlotContent);
});
});
diff --git a/spec/frontend/reports/grouped_test_report/store/actions_spec.js b/spec/frontend/reports/grouped_test_report/store/actions_spec.js
index bbc3a5dbba5..5876827c548 100644
--- a/spec/frontend/reports/grouped_test_report/store/actions_spec.js
+++ b/spec/frontend/reports/grouped_test_report/store/actions_spec.js
@@ -24,8 +24,8 @@ describe('Reports Store Actions', () => {
});
describe('setPaths', () => {
- it('should commit SET_PATHS mutation', (done) => {
- testAction(
+ it('should commit SET_PATHS mutation', () => {
+ return testAction(
setPaths,
{ endpoint: 'endpoint.json', headBlobPath: '/blob/path' },
mockedState,
@@ -36,14 +36,13 @@ describe('Reports Store Actions', () => {
},
],
[],
- done,
);
});
});
describe('requestReports', () => {
- it('should commit REQUEST_REPORTS mutation', (done) => {
- testAction(requestReports, null, mockedState, [{ type: types.REQUEST_REPORTS }], [], done);
+ it('should commit REQUEST_REPORTS mutation', () => {
+ return testAction(requestReports, null, mockedState, [{ type: types.REQUEST_REPORTS }], []);
});
});
@@ -62,12 +61,12 @@ describe('Reports Store Actions', () => {
});
describe('success', () => {
- it('dispatches requestReports and receiveReportsSuccess ', (done) => {
+ it('dispatches requestReports and receiveReportsSuccess ', () => {
mock
.onGet(`${TEST_HOST}/endpoint.json`)
.replyOnce(200, { summary: {}, suites: [{ name: 'rspec' }] });
- testAction(
+ return testAction(
fetchReports,
null,
mockedState,
@@ -81,7 +80,6 @@ describe('Reports Store Actions', () => {
type: 'receiveReportsSuccess',
},
],
- done,
);
});
});
@@ -91,8 +89,8 @@ describe('Reports Store Actions', () => {
mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500);
});
- it('dispatches requestReports and receiveReportsError ', (done) => {
- testAction(
+ it('dispatches requestReports and receiveReportsError ', () => {
+ return testAction(
fetchReports,
null,
mockedState,
@@ -105,71 +103,65 @@ describe('Reports Store Actions', () => {
type: 'receiveReportsError',
},
],
- done,
);
});
});
});
describe('receiveReportsSuccess', () => {
- it('should commit RECEIVE_REPORTS_SUCCESS mutation with 200', (done) => {
- testAction(
+ it('should commit RECEIVE_REPORTS_SUCCESS mutation with 200', () => {
+ return testAction(
receiveReportsSuccess,
{ data: { summary: {} }, status: 200 },
mockedState,
[{ type: types.RECEIVE_REPORTS_SUCCESS, payload: { summary: {} } }],
[],
- done,
);
});
- it('should not commit RECEIVE_REPORTS_SUCCESS mutation with 204', (done) => {
- testAction(
+ it('should not commit RECEIVE_REPORTS_SUCCESS mutation with 204', () => {
+ return testAction(
receiveReportsSuccess,
{ data: { summary: {} }, status: 204 },
mockedState,
[],
[],
- done,
);
});
});
describe('receiveReportsError', () => {
- it('should commit RECEIVE_REPORTS_ERROR mutation', (done) => {
- testAction(
+ it('should commit RECEIVE_REPORTS_ERROR mutation', () => {
+ return testAction(
receiveReportsError,
null,
mockedState,
[{ type: types.RECEIVE_REPORTS_ERROR }],
[],
- done,
);
});
});
describe('openModal', () => {
- it('should commit SET_ISSUE_MODAL_DATA', (done) => {
- testAction(
+ it('should commit SET_ISSUE_MODAL_DATA', () => {
+ return testAction(
openModal,
{ name: 'foo' },
mockedState,
[{ type: types.SET_ISSUE_MODAL_DATA, payload: { name: 'foo' } }],
[],
- done,
);
});
});
describe('closeModal', () => {
- it('should commit RESET_ISSUE_MODAL_DATA', (done) => {
- testAction(
+ it('should commit RESET_ISSUE_MODAL_DATA', () => {
+ return testAction(
closeModal,
{},
mockedState,
[{ type: types.RESET_ISSUE_MODAL_DATA, payload: {} }],
[],
- done,
);
});
});
diff --git a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap
index 7854325e4ed..fea937b905f 100644
--- a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap
+++ b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap
@@ -106,114 +106,3 @@ exports[`Repository last commit component renders commit widget 1`] = `
</div>
</div>
`;
-
-exports[`Repository last commit component renders the signature HTML as returned by the backend 1`] = `
-<div
- class="well-segment commit gl-p-5 gl-w-full"
->
- <user-avatar-link-stub
- class="avatar-cell"
- imgalt=""
- imgcssclasses=""
- imgsize="40"
- imgsrc="https://test.com"
- linkhref="/test"
- tooltipplacement="top"
- tooltiptext=""
- username=""
- />
-
- <div
- class="commit-detail flex-list"
- >
- <div
- class="commit-content qa-commit-content"
- >
- <gl-link-stub
- class="commit-row-message item-title"
- href="/commit/123"
- >
- Commit title
- </gl-link-stub>
-
- <!---->
-
- <div
- class="committer"
- >
- <gl-link-stub
- class="commit-author-link js-user-link"
- href="/test"
- >
-
- Test
- </gl-link-stub>
-
- authored
-
- <timeago-tooltip-stub
- cssclass=""
- time="2019-01-01"
- tooltipplacement="bottom"
- />
- </div>
-
- <!---->
- </div>
-
- <div
- class="commit-actions flex-row"
- >
- <div>
- <button>
- Verified
- </button>
- </div>
-
- <div
- class="ci-status-link"
- >
- <gl-link-stub
- class="js-commit-pipeline"
- href="https://test.com/pipeline"
- title="Pipeline: failed"
- >
- <ci-icon-stub
- aria-label="Pipeline: failed"
- cssclasses=""
- size="24"
- status="[object Object]"
- />
- </gl-link-stub>
- </div>
-
- <gl-button-group-stub
- class="gl-ml-4 js-commit-sha-group"
- >
- <gl-button-stub
- buttontextclasses=""
- category="primary"
- class="gl-font-monospace"
- data-testid="last-commit-id-label"
- icon=""
- label="true"
- size="medium"
- variant="default"
- >
- 12345678
- </gl-button-stub>
-
- <clipboard-button-stub
- category="secondary"
- class="input-group-text"
- size="medium"
- text="123456789"
- title="Copy commit SHA"
- tooltipplacement="top"
- variant="default"
- />
- </gl-button-group-stub>
- </div>
- </div>
-</div>
-`;
diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js
index 96c03419dd6..2f6de03b73d 100644
--- a/spec/frontend/repository/components/blob_content_viewer_spec.js
+++ b/spec/frontend/repository/components/blob_content_viewer_spec.js
@@ -25,6 +25,7 @@ import { redirectTo } from '~/lib/utils/url_utility';
import { isLoggedIn } from '~/lib/utils/common_utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import httpStatusCodes from '~/lib/utils/http_status';
+import LineHighlighter from '~/blob/line_highlighter';
import {
simpleViewerMock,
richViewerMock,
@@ -39,6 +40,7 @@ import {
jest.mock('~/repository/components/blob_viewers');
jest.mock('~/lib/utils/url_utility');
jest.mock('~/lib/utils/common_utils');
+jest.mock('~/blob/line_highlighter');
let wrapper;
let mockResolver;
@@ -173,20 +175,30 @@ describe('Blob content viewer component', () => {
});
describe('legacy viewers', () => {
+ const legacyViewerUrl = 'some_file.js?format=json&viewer=simple';
+ const fileType = 'text';
+ const highlightJs = false;
+
it('loads a legacy viewer when a the fileType is text and the highlightJs feature is turned off', async () => {
await createComponent({
- blob: { ...simpleViewerMock, fileType: 'text', highlightJs: false },
+ blob: { ...simpleViewerMock, fileType, highlightJs },
});
expect(mockAxios.history.get).toHaveLength(1);
- expect(mockAxios.history.get[0].url).toEqual('some_file.js?format=json&viewer=simple');
+ expect(mockAxios.history.get[0].url).toBe(legacyViewerUrl);
});
it('loads a legacy viewer when a viewer component is not available', async () => {
await createComponent({ blob: { ...simpleViewerMock, fileType: 'unknown' } });
expect(mockAxios.history.get).toHaveLength(1);
- expect(mockAxios.history.get[0].url).toEqual('some_file.js?format=json&viewer=simple');
+ expect(mockAxios.history.get[0].url).toBe(legacyViewerUrl);
+ });
+
+ it('loads the LineHighlighter', async () => {
+ mockAxios.onGet(legacyViewerUrl).replyOnce(httpStatusCodes.OK, 'test');
+ await createComponent({ blob: { ...simpleViewerMock, fileType, highlightJs } });
+ expect(LineHighlighter).toHaveBeenCalled();
});
});
});
@@ -258,6 +270,7 @@ describe('Blob content viewer component', () => {
codeNavigationPath: simpleViewerMock.codeNavigationPath,
blobPath: simpleViewerMock.path,
pathPrefix: simpleViewerMock.projectBlobPathRoot,
+ wrapTextNodes: true,
});
});
diff --git a/spec/frontend/repository/components/breadcrumbs_spec.js b/spec/frontend/repository/components/breadcrumbs_spec.js
index 0e3e7075e99..eef66045573 100644
--- a/spec/frontend/repository/components/breadcrumbs_spec.js
+++ b/spec/frontend/repository/components/breadcrumbs_spec.js
@@ -12,7 +12,7 @@ const defaultMockRoute = {
describe('Repository breadcrumbs component', () => {
let wrapper;
- const factory = (currentPath, extraProps = {}, mockRoute = {}, newDirModal = true) => {
+ const factory = (currentPath, extraProps = {}, mockRoute = {}) => {
const $apollo = {
queries: {
userPermissions: {
@@ -36,7 +36,6 @@ describe('Repository breadcrumbs component', () => {
},
$apollo,
},
- provide: { glFeatures: { newDirModal } },
});
};
@@ -147,37 +146,21 @@ describe('Repository breadcrumbs component', () => {
});
describe('renders the new directory modal', () => {
- describe('with the feature flag enabled', () => {
- beforeEach(() => {
- window.gon.features = {
- newDirModal: true,
- };
- factory('/', { canEditTree: true });
- });
-
- it('does not render the modal while loading', () => {
- expect(findNewDirectoryModal().exists()).toBe(false);
- });
-
- it('renders the modal once loaded', async () => {
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({ $apollo: { queries: { userPermissions: { loading: false } } } });
-
- await nextTick();
-
- expect(findNewDirectoryModal().exists()).toBe(true);
- });
+ beforeEach(() => {
+ factory('/', { canEditTree: true });
+ });
+ it('does not render the modal while loading', () => {
+ expect(findNewDirectoryModal().exists()).toBe(false);
});
- describe('with the feature flag disabled', () => {
- it('does not render the modal', () => {
- window.gon.features = {
- newDirModal: false,
- };
- factory('/', { canEditTree: true }, {}, {}, false);
- expect(findNewDirectoryModal().exists()).toBe(false);
- });
+ it('renders the modal once loaded', async () => {
+ // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
+ // eslint-disable-next-line no-restricted-syntax
+ wrapper.setData({ $apollo: { queries: { userPermissions: { loading: false } } } });
+
+ await nextTick();
+
+ expect(findNewDirectoryModal().exists()).toBe(true);
});
});
});
diff --git a/spec/frontend/repository/components/last_commit_spec.js b/spec/frontend/repository/components/last_commit_spec.js
index bb710c3a96c..cfbf74e34aa 100644
--- a/spec/frontend/repository/components/last_commit_spec.js
+++ b/spec/frontend/repository/components/last_commit_spec.js
@@ -143,11 +143,30 @@ describe('Repository last commit component', () => {
});
it('renders the signature HTML as returned by the backend', async () => {
- factory(createCommitData({ signatureHtml: '<button>Verified</button>' }));
+ factory(
+ createCommitData({
+ signatureHtml: `<a
+ class="btn gpg-status-box valid"
+ data-content="signature-content"
+ data-html="true"
+ data-placement="top"
+ data-title="signature-title"
+ data-toggle="popover"
+ role="button"
+ tabindex="0"
+ >
+ Verified
+ </a>`,
+ }),
+ );
await nextTick();
- expect(vm.element).toMatchSnapshot();
+ expect(vm.find('.gpg-status-box').html()).toBe(
+ `<a class="btn gpg-status-box valid" data-content="signature-content" data-html="true" data-placement="top" data-title="signature-title" data-toggle="popover" role="button" tabindex="0">
+ Verified
+</a>`,
+ );
});
it('sets correct CSS class if the commit message is empty', async () => {
diff --git a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js
index cdaec0a3a8b..2ef856c90ab 100644
--- a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js
+++ b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js
@@ -13,6 +13,7 @@ import { createAlert } from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { updateHistory } from '~/lib/utils/url_utility';
+import { createLocalState } from '~/runner/graphql/list/local_state';
import AdminRunnersApp from '~/runner/admin_runners/admin_runners_app.vue';
import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue';
import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue';
@@ -30,9 +31,10 @@ import {
INSTANCE_TYPE,
GROUP_TYPE,
PROJECT_TYPE,
+ PARAM_KEY_PAUSED,
PARAM_KEY_STATUS,
PARAM_KEY_TAG,
- STATUS_ACTIVE,
+ STATUS_ONLINE,
RUNNER_PAGE_SIZE,
} from '~/runner/constants';
import adminRunnersQuery from '~/runner/graphql/list/admin_runners.query.graphql';
@@ -40,9 +42,16 @@ import adminRunnersCountQuery from '~/runner/graphql/list/admin_runners_count.qu
import { captureException } from '~/runner/sentry_utils';
import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
-import { runnersData, runnersCountData, runnersDataPaginated } from '../mock_data';
+import {
+ runnersData,
+ runnersCountData,
+ runnersDataPaginated,
+ onlineContactTimeoutSecs,
+ staleTimeoutSecs,
+} from '../mock_data';
const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN';
+const mockRunners = runnersData.data.runners.nodes;
jest.mock('~/flash');
jest.mock('~/runner/sentry_utils');
@@ -58,6 +67,8 @@ describe('AdminRunnersApp', () => {
let wrapper;
let mockRunnersQuery;
let mockRunnersCountQuery;
+ let cacheConfig;
+ let localMutations;
const findRunnerStats = () => wrapper.findComponent(RunnerStats);
const findRunnerActionsCell = () => wrapper.findComponent(RunnerActionsCell);
@@ -69,18 +80,32 @@ describe('AdminRunnersApp', () => {
const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar);
const findFilteredSearch = () => wrapper.findComponent(FilteredSearch);
- const createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => {
+ const createComponent = ({
+ props = {},
+ mountFn = shallowMountExtended,
+ provide,
+ ...options
+ } = {}) => {
+ ({ cacheConfig, localMutations } = createLocalState());
+
const handlers = [
[adminRunnersQuery, mockRunnersQuery],
[adminRunnersCountQuery, mockRunnersCountQuery],
];
wrapper = mountFn(AdminRunnersApp, {
- apolloProvider: createMockApollo(handlers),
+ apolloProvider: createMockApollo(handlers, {}, cacheConfig),
propsData: {
registrationToken: mockRegistrationToken,
...props,
},
+ provide: {
+ localMutations,
+ onlineContactTimeoutSecs,
+ staleTimeoutSecs,
+ ...provide,
+ },
+ ...options,
});
};
@@ -173,7 +198,7 @@ describe('AdminRunnersApp', () => {
});
it('shows the runners list', () => {
- expect(findRunnerList().props('runners')).toEqual(runnersData.data.runners.nodes);
+ expect(findRunnerList().props('runners')).toEqual(mockRunners);
});
it('runner item links to the runner admin page', async () => {
@@ -181,7 +206,7 @@ describe('AdminRunnersApp', () => {
await waitForPromises();
- const { id, shortSha } = runnersData.data.runners.nodes[0];
+ const { id, shortSha } = mockRunners[0];
const numericId = getIdFromGraphQLId(id);
const runnerLink = wrapper.find('tr [data-testid="td-summary"]').find(GlLink);
@@ -197,7 +222,7 @@ describe('AdminRunnersApp', () => {
const runnerActions = wrapper.find('tr [data-testid="td-actions"]').find(RunnerActionsCell);
- const runner = runnersData.data.runners.nodes[0];
+ const runner = mockRunners[0];
expect(runnerActions.props()).toEqual({
runner,
@@ -219,6 +244,10 @@ describe('AdminRunnersApp', () => {
expect(findFilteredSearch().props('tokens')).toEqual([
expect.objectContaining({
+ type: PARAM_KEY_PAUSED,
+ options: expect.any(Array),
+ }),
+ expect.objectContaining({
type: PARAM_KEY_STATUS,
options: expect.any(Array),
}),
@@ -232,12 +261,13 @@ describe('AdminRunnersApp', () => {
describe('Single runner row', () => {
let showToast;
- const mockRunner = runnersData.data.runners.nodes[0];
- const { id: graphqlId, shortSha } = mockRunner;
+ const { id: graphqlId, shortSha } = mockRunners[0];
const id = getIdFromGraphQLId(graphqlId);
+ const COUNT_QUERIES = 7; // Smart queries that display a filtered count of runners
+ const FILTERED_COUNT_QUERIES = 4; // Smart queries that display a count of runners in tabs
beforeEach(async () => {
- mockRunnersQuery.mockClear();
+ mockRunnersCountQuery.mockClear();
createComponent({ mountFn: mountExtended });
showToast = jest.spyOn(wrapper.vm.$root.$toast, 'show');
@@ -252,12 +282,18 @@ describe('AdminRunnersApp', () => {
expect(runnerLink.attributes('href')).toBe(`http://localhost/admin/runners/${id}`);
});
- it('When runner is deleted, data is refetched and a toast message is shown', async () => {
- expect(mockRunnersQuery).toHaveBeenCalledTimes(1);
+ it('When runner is paused or unpaused, some data is refetched', async () => {
+ expect(mockRunnersCountQuery).toHaveBeenCalledTimes(COUNT_QUERIES);
- findRunnerActionsCell().vm.$emit('deleted', { message: 'Runner deleted' });
+ findRunnerActionsCell().vm.$emit('toggledPaused');
- expect(mockRunnersQuery).toHaveBeenCalledTimes(2);
+ expect(mockRunnersCountQuery).toHaveBeenCalledTimes(COUNT_QUERIES + FILTERED_COUNT_QUERIES);
+
+ expect(showToast).toHaveBeenCalledTimes(0);
+ });
+
+ it('When runner is deleted, data is refetched and a toast message is shown', async () => {
+ findRunnerActionsCell().vm.$emit('deleted', { message: 'Runner deleted' });
expect(showToast).toHaveBeenCalledTimes(1);
expect(showToast).toHaveBeenCalledWith('Runner deleted');
@@ -266,7 +302,7 @@ describe('AdminRunnersApp', () => {
describe('when a filter is preselected', () => {
beforeEach(async () => {
- setWindowLocation(`?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}&tag[]=tag1`);
+ setWindowLocation(`?status[]=${STATUS_ONLINE}&runner_type[]=${INSTANCE_TYPE}&tag[]=tag1`);
createComponent();
await waitForPromises();
@@ -276,7 +312,7 @@ describe('AdminRunnersApp', () => {
expect(findRunnerFilteredSearchBar().props('value')).toEqual({
runnerType: INSTANCE_TYPE,
filters: [
- { type: 'status', value: { data: STATUS_ACTIVE, operator: '=' } },
+ { type: 'status', value: { data: STATUS_ONLINE, operator: '=' } },
{ type: 'tag', value: { data: 'tag1', operator: '=' } },
],
sort: 'CREATED_DESC',
@@ -286,7 +322,7 @@ describe('AdminRunnersApp', () => {
it('requests the runners with filter parameters', () => {
expect(mockRunnersQuery).toHaveBeenLastCalledWith({
- status: STATUS_ACTIVE,
+ status: STATUS_ONLINE,
type: INSTANCE_TYPE,
tagList: ['tag1'],
sort: DEFAULT_SORT,
@@ -299,7 +335,7 @@ describe('AdminRunnersApp', () => {
beforeEach(() => {
findRunnerFilteredSearchBar().vm.$emit('input', {
runnerType: null,
- filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } }],
+ filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }],
sort: CREATED_ASC,
});
});
@@ -307,13 +343,13 @@ describe('AdminRunnersApp', () => {
it('updates the browser url', () => {
expect(updateHistory).toHaveBeenLastCalledWith({
title: expect.any(String),
- url: 'http://test.host/admin/runners?status[]=ACTIVE&sort=CREATED_ASC',
+ url: 'http://test.host/admin/runners?status[]=ONLINE&sort=CREATED_ASC',
});
});
it('requests the runners with filters', () => {
expect(mockRunnersQuery).toHaveBeenLastCalledWith({
- status: STATUS_ACTIVE,
+ status: STATUS_ONLINE,
sort: CREATED_ASC,
first: RUNNER_PAGE_SIZE,
});
@@ -325,6 +361,41 @@ describe('AdminRunnersApp', () => {
expect(findRunnerList().props('loading')).toBe(true);
});
+ describe('when bulk delete is enabled', () => {
+ beforeEach(() => {
+ createComponent({
+ provide: {
+ glFeatures: { adminRunnersBulkDelete: true },
+ },
+ });
+ });
+
+ it('runner list is checkable', () => {
+ expect(findRunnerList().props('checkable')).toBe(true);
+ });
+
+ it('responds to checked items by updating the local cache', () => {
+ const setRunnerCheckedMock = jest
+ .spyOn(localMutations, 'setRunnerChecked')
+ .mockImplementation(() => {});
+
+ const runner = mockRunners[0];
+
+ expect(setRunnerCheckedMock).toHaveBeenCalledTimes(0);
+
+ findRunnerList().vm.$emit('checked', {
+ runner,
+ isChecked: true,
+ });
+
+ expect(setRunnerCheckedMock).toHaveBeenCalledTimes(1);
+ expect(setRunnerCheckedMock).toHaveBeenCalledWith({
+ runner,
+ isChecked: true,
+ });
+ });
+ });
+
describe('when no runners are found', () => {
beforeEach(async () => {
mockRunnersQuery = jest.fn().mockResolvedValue({
diff --git a/spec/frontend/runner/components/__snapshots__/runner_status_popover_spec.js.snap b/spec/frontend/runner/components/__snapshots__/runner_status_popover_spec.js.snap
new file mode 100644
index 00000000000..80a04401760
--- /dev/null
+++ b/spec/frontend/runner/components/__snapshots__/runner_status_popover_spec.js.snap
@@ -0,0 +1,3 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`RunnerStatusPopover renders complete text 1`] = `"Never contacted: Runner has never contacted GitLab (when you register a runner, use gitlab-runner run to bring it online) Online: Runner has contacted GitLab within the last 2 hours Offline: Runner has not contacted GitLab in more than 2 hours Stale: Runner has not contacted GitLab in more than 2 months"`;
diff --git a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js
index 0d579106860..7a949cb6505 100644
--- a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js
+++ b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js
@@ -92,6 +92,24 @@ describe('RunnerActionsCell', () => {
expect(findDeleteBtn().props('compact')).toBe(true);
});
+ it('Passes runner data to delete button', () => {
+ createComponent({
+ runner: mockRunner,
+ });
+
+ expect(findDeleteBtn().props('runner')).toEqual(mockRunner);
+ });
+
+ it('Emits toggledPaused events', () => {
+ createComponent();
+
+ expect(wrapper.emitted('toggledPaused')).toBe(undefined);
+
+ findRunnerPauseBtn().vm.$emit('toggledPaused');
+
+ expect(wrapper.emitted('toggledPaused')).toHaveLength(1);
+ });
+
it('Emits delete events', () => {
const value = { name: 'Runner' };
@@ -104,7 +122,7 @@ describe('RunnerActionsCell', () => {
expect(wrapper.emitted('deleted')).toEqual([[value]]);
});
- it('Does not render the runner delete button when user cannot delete', () => {
+ it('Renders the runner delete disabled button when user cannot delete', () => {
createComponent({
runner: {
userPermissions: {
@@ -114,7 +132,7 @@ describe('RunnerActionsCell', () => {
},
});
- expect(findDeleteBtn().exists()).toBe(false);
+ expect(findDeleteBtn().props('disabled')).toBe(true);
});
});
});
diff --git a/spec/frontend/runner/components/cells/runner_summary_cell_spec.js b/spec/frontend/runner/components/cells/runner_summary_cell_spec.js
index b6d957d27ea..b2e8c5a3ad9 100644
--- a/spec/frontend/runner/components/cells/runner_summary_cell_spec.js
+++ b/spec/frontend/runner/components/cells/runner_summary_cell_spec.js
@@ -5,6 +5,7 @@ import { INSTANCE_TYPE, PROJECT_TYPE } from '~/runner/constants';
const mockId = '1';
const mockShortSha = '2P6oDVDm';
const mockDescription = 'runner-1';
+const mockIpAddress = '0.0.0.0';
describe('RunnerTypeCell', () => {
let wrapper;
@@ -18,6 +19,7 @@ describe('RunnerTypeCell', () => {
id: `gid://gitlab/Ci::Runner/${mockId}`,
shortSha: mockShortSha,
description: mockDescription,
+ ipAddress: mockIpAddress,
runnerType: INSTANCE_TYPE,
...runner,
},
@@ -59,6 +61,10 @@ describe('RunnerTypeCell', () => {
expect(wrapper.text()).toContain(mockDescription);
});
+ it('Displays the runner ip address', () => {
+ expect(wrapper.text()).toContain(mockIpAddress);
+ });
+
it('Displays a custom slot', () => {
const slotContent = 'My custom runner summary';
diff --git a/spec/frontend/runner/components/registration/registration_dropdown_spec.js b/spec/frontend/runner/components/registration/registration_dropdown_spec.js
index da8ef7c3af0..5cd93df9967 100644
--- a/spec/frontend/runner/components/registration/registration_dropdown_spec.js
+++ b/spec/frontend/runner/components/registration/registration_dropdown_spec.js
@@ -8,6 +8,7 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue';
+import RegistrationToken from '~/runner/components/registration/registration_token.vue';
import RegistrationTokenResetDropdownItem from '~/runner/components/registration/registration_token_reset_dropdown_item.vue';
import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants';
@@ -30,11 +31,11 @@ describe('RegistrationDropdown', () => {
const findRegistrationInstructionsDropdownItem = () => wrapper.findComponent(GlDropdownItem);
const findTokenDropdownItem = () => wrapper.findComponent(GlDropdownForm);
+ const findRegistrationToken = () => wrapper.findComponent(RegistrationToken);
+ const findRegistrationTokenInput = () => wrapper.findByTestId('token-value').find('input');
const findTokenResetDropdownItem = () =>
wrapper.findComponent(RegistrationTokenResetDropdownItem);
- const findToggleMaskButton = () => wrapper.findByTestId('toggle-masked');
-
const createComponent = ({ props = {}, ...options } = {}, mountFn = shallowMount) => {
wrapper = extendedWrapper(
mountFn(RegistrationDropdown, {
@@ -134,9 +135,7 @@ describe('RegistrationDropdown', () => {
it('Displays masked value by default', () => {
createComponent({}, mount);
- expect(findTokenDropdownItem().text()).toMatchInterpolatedText(
- `Registration token ${maskToken}`,
- );
+ expect(findRegistrationTokenInput().element.value).toBe(maskToken);
});
});
@@ -155,16 +154,14 @@ describe('RegistrationDropdown', () => {
});
it('Updates the token when it gets reset', async () => {
+ const newToken = 'mock1';
createComponent({}, mount);
- const newToken = 'mock1';
+ expect(findRegistrationTokenInput().props('value')).not.toBe(newToken);
findTokenResetDropdownItem().vm.$emit('tokenReset', newToken);
- findToggleMaskButton().vm.$emit('click', { stopPropagation: jest.fn() });
await nextTick();
- expect(findTokenDropdownItem().text()).toMatchInterpolatedText(
- `Registration token ${newToken}`,
- );
+ expect(findRegistrationToken().props('value')).toBe(newToken);
});
});
diff --git a/spec/frontend/runner/components/registration/registration_token_spec.js b/spec/frontend/runner/components/registration/registration_token_spec.js
index 6b9708cc525..cb42c7c8493 100644
--- a/spec/frontend/runner/components/registration/registration_token_spec.js
+++ b/spec/frontend/runner/components/registration/registration_token_spec.js
@@ -1,20 +1,17 @@
-import { nextTick } from 'vue';
import { GlToast } from '@gitlab/ui';
import { createLocalVue } from '@vue/test-utils';
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import RegistrationToken from '~/runner/components/registration/registration_token.vue';
-import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
+import InputCopyToggleVisibility from '~/vue_shared/components/form/input_copy_toggle_visibility.vue';
const mockToken = '01234567890';
const mockMasked = '***********';
describe('RegistrationToken', () => {
let wrapper;
- let stopPropagation;
let showToast;
- const findToggleMaskButton = () => wrapper.findByTestId('toggle-masked');
- const findCopyButton = () => wrapper.findComponent(ModalCopyButton);
+ const findInputCopyToggleVisibility = () => wrapper.findComponent(InputCopyToggleVisibility);
const vueWithGlToast = () => {
const localVue = createLocalVue();
@@ -22,10 +19,14 @@ describe('RegistrationToken', () => {
return localVue;
};
- const createComponent = ({ props = {}, withGlToast = true } = {}) => {
+ const createComponent = ({
+ props = {},
+ withGlToast = true,
+ mountFn = shallowMountExtended,
+ } = {}) => {
const localVue = withGlToast ? vueWithGlToast() : undefined;
- wrapper = shallowMountExtended(RegistrationToken, {
+ wrapper = mountFn(RegistrationToken, {
propsData: {
value: mockToken,
...props,
@@ -36,61 +37,33 @@ describe('RegistrationToken', () => {
showToast = wrapper.vm.$toast ? jest.spyOn(wrapper.vm.$toast, 'show') : null;
};
- beforeEach(() => {
- stopPropagation = jest.fn();
-
- createComponent();
- });
-
afterEach(() => {
wrapper.destroy();
});
- it('Displays masked value by default', () => {
- expect(wrapper.text()).toBe(mockMasked);
- });
+ it('Displays value and copy button', () => {
+ createComponent();
- it('Displays button to reveal token', () => {
- expect(findToggleMaskButton().attributes('aria-label')).toBe('Click to reveal');
+ expect(findInputCopyToggleVisibility().props('value')).toBe(mockToken);
+ expect(findInputCopyToggleVisibility().props('copyButtonTitle')).toBe(
+ 'Copy registration token',
+ );
});
- it('Can copy the original token value', () => {
- expect(findCopyButton().props('text')).toBe(mockToken);
+ // Component integration test to ensure secure masking
+ it('Displays masked value by default', () => {
+ createComponent({ mountFn: mountExtended });
+
+ expect(wrapper.find('input').element.value).toBe(mockMasked);
});
- describe('When the reveal icon is clicked', () => {
+ describe('When the copy to clipboard button is clicked', () => {
beforeEach(() => {
- findToggleMaskButton().vm.$emit('click', { stopPropagation });
- });
-
- it('Click event is not propagated', async () => {
- expect(stopPropagation).toHaveBeenCalledTimes(1);
+ createComponent();
});
- it('Displays the actual value', () => {
- expect(wrapper.text()).toBe(mockToken);
- });
-
- it('Can copy the original token value', () => {
- expect(findCopyButton().props('text')).toBe(mockToken);
- });
-
- it('Displays button to mask token', () => {
- expect(findToggleMaskButton().attributes('aria-label')).toBe('Click to hide');
- });
-
- it('When user clicks again, displays masked value', async () => {
- findToggleMaskButton().vm.$emit('click', { stopPropagation });
- await nextTick();
-
- expect(wrapper.text()).toBe(mockMasked);
- expect(findToggleMaskButton().attributes('aria-label')).toBe('Click to reveal');
- });
- });
-
- describe('When the copy to clipboard button is clicked', () => {
it('shows a copied message', () => {
- findCopyButton().vm.$emit('success');
+ findInputCopyToggleVisibility().vm.$emit('copy');
expect(showToast).toHaveBeenCalledTimes(1);
expect(showToast).toHaveBeenCalledWith('Registration token copied!');
@@ -98,7 +71,7 @@ describe('RegistrationToken', () => {
it('does not fail when toast is not defined', () => {
createComponent({ withGlToast: false });
- findCopyButton().vm.$emit('success');
+ findInputCopyToggleVisibility().vm.$emit('copy');
// This block also tests for unhandled errors
expect(showToast).toBeNull();
diff --git a/spec/frontend/runner/components/runner_assigned_item_spec.js b/spec/frontend/runner/components/runner_assigned_item_spec.js
index c6156c16d4a..1ff6983fbe7 100644
--- a/spec/frontend/runner/components/runner_assigned_item_spec.js
+++ b/spec/frontend/runner/components/runner_assigned_item_spec.js
@@ -1,6 +1,7 @@
import { GlAvatar } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import RunnerAssignedItem from '~/runner/components/runner_assigned_item.vue';
+import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
const mockHref = '/group/project';
const mockName = 'Project';
@@ -40,7 +41,7 @@ describe('RunnerAssignedItem', () => {
alt: mockName,
entityName: mockName,
src: mockAvatarUrl,
- shape: 'rect',
+ shape: AVATAR_SHAPE_OPTION_RECT,
size: 48,
});
});
diff --git a/spec/frontend/runner/components/runner_bulk_delete_spec.js b/spec/frontend/runner/components/runner_bulk_delete_spec.js
new file mode 100644
index 00000000000..f5b56396cf1
--- /dev/null
+++ b/spec/frontend/runner/components/runner_bulk_delete_spec.js
@@ -0,0 +1,103 @@
+import Vue from 'vue';
+import { GlSprintf } from '@gitlab/ui';
+import VueApollo from 'vue-apollo';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
+import RunnerBulkDelete from '~/runner/components/runner_bulk_delete.vue';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { createLocalState } from '~/runner/graphql/list/local_state';
+import waitForPromises from 'helpers/wait_for_promises';
+
+Vue.use(VueApollo);
+
+jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal');
+
+describe('RunnerBulkDelete', () => {
+ let wrapper;
+ let mockState;
+ let mockCheckedRunnerIds;
+
+ const findClearBtn = () => wrapper.findByTestId('clear-btn');
+ const findDeleteBtn = () => wrapper.findByTestId('delete-btn');
+
+ const createComponent = () => {
+ const { cacheConfig, localMutations } = mockState;
+
+ wrapper = shallowMountExtended(RunnerBulkDelete, {
+ apolloProvider: createMockApollo(undefined, undefined, cacheConfig),
+ provide: {
+ localMutations,
+ },
+ stubs: {
+ GlSprintf,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ mockState = createLocalState();
+
+ jest
+ .spyOn(mockState.cacheConfig.typePolicies.Query.fields, 'checkedRunnerIds')
+ .mockImplementation(() => mockCheckedRunnerIds);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('When no runners are checked', () => {
+ beforeEach(async () => {
+ mockCheckedRunnerIds = [];
+
+ createComponent();
+
+ await waitForPromises();
+ });
+
+ it('shows no contents', () => {
+ expect(wrapper.html()).toBe('');
+ });
+ });
+
+ describe.each`
+ count | ids | text
+ ${1} | ${['gid:Runner/1']} | ${'1 runner'}
+ ${2} | ${['gid:Runner/1', 'gid:Runner/2']} | ${'2 runners'}
+ `('When $count runner(s) are checked', ({ count, ids, text }) => {
+ beforeEach(() => {
+ mockCheckedRunnerIds = ids;
+
+ createComponent();
+
+ jest.spyOn(mockState.localMutations, 'clearChecked').mockImplementation(() => {});
+ });
+
+ it(`shows "${text}"`, () => {
+ expect(wrapper.text()).toContain(text);
+ });
+
+ it('clears selection', () => {
+ expect(mockState.localMutations.clearChecked).toHaveBeenCalledTimes(0);
+
+ findClearBtn().vm.$emit('click');
+
+ expect(mockState.localMutations.clearChecked).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows confirmation modal', () => {
+ expect(confirmAction).toHaveBeenCalledTimes(0);
+
+ findDeleteBtn().vm.$emit('click');
+
+ expect(confirmAction).toHaveBeenCalledTimes(1);
+
+ const [, confirmOptions] = confirmAction.mock.calls[0];
+ const { title, modalHtmlMessage, primaryBtnText } = confirmOptions;
+
+ expect(title).toMatch(text);
+ expect(primaryBtnText).toMatch(text);
+ expect(modalHtmlMessage).toMatch(`<strong>${count}</strong>`);
+ });
+ });
+});
diff --git a/spec/frontend/runner/components/runner_delete_button_spec.js b/spec/frontend/runner/components/runner_delete_button_spec.js
index 81c870f23cf..3eb257607b4 100644
--- a/spec/frontend/runner/components/runner_delete_button_spec.js
+++ b/spec/frontend/runner/components/runner_delete_button_spec.js
@@ -9,7 +9,11 @@ import waitForPromises from 'helpers/wait_for_promises';
import { captureException } from '~/runner/sentry_utils';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { createAlert } from '~/flash';
-import { I18N_DELETE_RUNNER } from '~/runner/constants';
+import {
+ I18N_DELETE_RUNNER,
+ I18N_DELETE_DISABLED_MANY_PROJECTS,
+ I18N_DELETE_DISABLED_UNKNOWN_REASON,
+} from '~/runner/constants';
import RunnerDeleteButton from '~/runner/components/runner_delete_button.vue';
import RunnerDeleteModal from '~/runner/components/runner_delete_modal.vue';
@@ -25,26 +29,32 @@ jest.mock('~/runner/sentry_utils');
describe('RunnerDeleteButton', () => {
let wrapper;
+ let apolloProvider;
+ let apolloCache;
let runnerDeleteHandler;
- const getTooltip = () => getBinding(wrapper.element, 'gl-tooltip').value;
- const getModal = () => getBinding(wrapper.element, 'gl-modal').value;
const findBtn = () => wrapper.findComponent(GlButton);
const findModal = () => wrapper.findComponent(RunnerDeleteModal);
+ const getTooltip = () => getBinding(wrapper.element, 'gl-tooltip').value;
+ const getModal = () => getBinding(findBtn().element, 'gl-modal').value;
+
const createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => {
const { runner, ...propsData } = props;
wrapper = mountFn(RunnerDeleteButton, {
propsData: {
runner: {
+ // We need typename so that cache.identify works
+ // eslint-disable-next-line no-underscore-dangle
+ __typename: mockRunner.__typename,
id: mockRunner.id,
shortSha: mockRunner.shortSha,
...runner,
},
...propsData,
},
- apolloProvider: createMockApollo([[runnerDeleteMutation, runnerDeleteHandler]]),
+ apolloProvider,
directives: {
GlTooltip: createMockDirective(),
GlModal: createMockDirective(),
@@ -67,6 +77,11 @@ describe('RunnerDeleteButton', () => {
},
});
});
+ apolloProvider = createMockApollo([[runnerDeleteMutation, runnerDeleteHandler]]);
+ apolloCache = apolloProvider.defaultClient.cache;
+
+ jest.spyOn(apolloCache, 'evict');
+ jest.spyOn(apolloCache, 'gc');
createComponent();
});
@@ -88,6 +103,10 @@ describe('RunnerDeleteButton', () => {
expect(findModal().props('runnerName')).toBe(`#${mockRunnerId} (${mockRunner.shortSha})`);
});
+ it('Does not have tabindex when button is enabled', () => {
+ expect(wrapper.attributes('tabindex')).toBeUndefined();
+ });
+
it('Displays a modal when clicked', () => {
const modalId = `delete-runner-modal-${mockRunnerId}`;
@@ -140,6 +159,13 @@ describe('RunnerDeleteButton', () => {
expect(deleted[0][0].message).toMatch(`#${mockRunnerId}`);
expect(deleted[0][0].message).toMatch(`${mockRunner.shortSha}`);
});
+
+ it('evicts runner from apollo cache', () => {
+ expect(apolloCache.evict).toHaveBeenCalledWith({
+ id: apolloCache.identify(mockRunner),
+ });
+ expect(apolloCache.gc).toHaveBeenCalled();
+ });
});
describe('When update fails', () => {
@@ -190,6 +216,11 @@ describe('RunnerDeleteButton', () => {
it('error is shown to the user', () => {
expect(createAlert).toHaveBeenCalledTimes(1);
});
+
+ it('does not evict runner from apollo cache', () => {
+ expect(apolloCache.evict).not.toHaveBeenCalled();
+ expect(apolloCache.gc).not.toHaveBeenCalled();
+ });
});
});
@@ -230,4 +261,29 @@ describe('RunnerDeleteButton', () => {
});
});
});
+
+ describe.each`
+ reason | runner | tooltip
+ ${'runner belongs to more than 1 project'} | ${{ projectCount: 2 }} | ${I18N_DELETE_DISABLED_MANY_PROJECTS}
+ ${'unknown reason'} | ${{}} | ${I18N_DELETE_DISABLED_UNKNOWN_REASON}
+ `('When button is disabled because $reason', ({ runner, tooltip }) => {
+ beforeEach(() => {
+ createComponent({
+ props: {
+ disabled: true,
+ runner,
+ },
+ });
+ });
+
+ it('Displays a disabled delete button', () => {
+ expect(findBtn().props('disabled')).toBe(true);
+ });
+
+ it(`Tooltip "${tooltip}" is shown`, () => {
+ // tabindex is required for a11y
+ expect(wrapper.attributes('tabindex')).toBe('0');
+ expect(getTooltip()).toBe(tooltip);
+ });
+ });
});
diff --git a/spec/frontend/runner/components/runner_filtered_search_bar_spec.js b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js
index fda96e5918e..b1b436e5443 100644
--- a/spec/frontend/runner/components/runner_filtered_search_bar_spec.js
+++ b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js
@@ -4,7 +4,7 @@ import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_
import { statusTokenConfig } from '~/runner/components/search_tokens/status_token_config';
import TagToken from '~/runner/components/search_tokens/tag_token.vue';
import { tagTokenConfig } from '~/runner/components/search_tokens/tag_token_config';
-import { PARAM_KEY_STATUS, PARAM_KEY_TAG, STATUS_ACTIVE, INSTANCE_TYPE } from '~/runner/constants';
+import { PARAM_KEY_STATUS, PARAM_KEY_TAG, STATUS_ONLINE, INSTANCE_TYPE } from '~/runner/constants';
import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
@@ -18,7 +18,7 @@ describe('RunnerList', () => {
const mockDefaultSort = 'CREATED_DESC';
const mockOtherSort = 'CONTACTED_DESC';
const mockFilters = [
- { type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } },
+ { type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } },
{ type: 'filtered-search-term', value: { data: '' } },
];
@@ -113,7 +113,7 @@ describe('RunnerList', () => {
});
it('filter values are shown', () => {
- expect(findGlFilteredSearch().props('value')).toEqual(mockFilters);
+ expect(findGlFilteredSearch().props('value')).toMatchObject(mockFilters);
});
it('sort option is selected', () => {
diff --git a/spec/frontend/runner/components/runner_jobs_spec.js b/spec/frontend/runner/components/runner_jobs_spec.js
index 9abb2861005..9e40e911448 100644
--- a/spec/frontend/runner/components/runner_jobs_spec.js
+++ b/spec/frontend/runner/components/runner_jobs_spec.js
@@ -1,4 +1,4 @@
-import { GlSkeletonLoading } from '@gitlab/ui';
+import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
diff --git a/spec/frontend/runner/components/runner_list_spec.js b/spec/frontend/runner/components/runner_list_spec.js
index a0f42738d2c..872394430ae 100644
--- a/spec/frontend/runner/components/runner_list_spec.js
+++ b/spec/frontend/runner/components/runner_list_spec.js
@@ -6,7 +6,8 @@ import {
} from 'helpers/vue_test_utils_helper';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import RunnerList from '~/runner/components/runner_list.vue';
-import { runnersData } from '../mock_data';
+import RunnerStatusPopover from '~/runner/components/runner_status_popover.vue';
+import { runnersData, onlineContactTimeoutSecs, staleTimeoutSecs } from '../mock_data';
const mockRunners = runnersData.data.runners.nodes;
const mockActiveRunnersCount = mockRunners.length;
@@ -28,26 +29,38 @@ describe('RunnerList', () => {
activeRunnersCount: mockActiveRunnersCount,
...props,
},
+ provide: {
+ onlineContactTimeoutSecs,
+ staleTimeoutSecs,
+ },
...options,
});
};
- beforeEach(() => {
- createComponent({}, mountExtended);
- });
-
afterEach(() => {
wrapper.destroy();
});
it('Displays headers', () => {
+ createComponent(
+ {
+ stubs: {
+ RunnerStatusPopover: {
+ template: '<div/>',
+ },
+ },
+ },
+ mountExtended,
+ );
+
const headerLabels = findHeaders().wrappers.map((w) => w.text());
+ expect(findHeaders().at(0).findComponent(RunnerStatusPopover).exists()).toBe(true);
+
expect(headerLabels).toEqual([
'Status',
'Runner',
'Version',
- 'IP',
'Jobs',
'Tags',
'Last contact',
@@ -56,19 +69,23 @@ describe('RunnerList', () => {
});
it('Sets runner id as a row key', () => {
- createComponent({});
+ createComponent();
expect(findTable().attributes('primary-key')).toBe('id');
});
it('Displays a list of runners', () => {
+ createComponent({}, mountExtended);
+
expect(findRows()).toHaveLength(4);
expect(findSkeletonLoader().exists()).toBe(false);
});
it('Displays details of a runner', () => {
- const { id, description, version, ipAddress, shortSha } = mockRunners[0];
+ const { id, description, version, shortSha } = mockRunners[0];
+
+ createComponent({}, mountExtended);
// Badges
expect(findCell({ fieldKey: 'status' }).text()).toMatchInterpolatedText(
@@ -83,7 +100,6 @@ describe('RunnerList', () => {
// Other fields
expect(findCell({ fieldKey: 'version' }).text()).toBe(version);
- expect(findCell({ fieldKey: 'ipAddress' }).text()).toBe(ipAddress);
expect(findCell({ fieldKey: 'jobCount' }).text()).toBe('0');
expect(findCell({ fieldKey: 'tagList' }).text()).toBe('');
expect(findCell({ fieldKey: 'contactedAt' }).text()).toEqual(expect.any(String));
@@ -92,6 +108,35 @@ describe('RunnerList', () => {
expect(findCell({ fieldKey: 'actions' }).exists()).toBe(true);
});
+ describe('When the list is checkable', () => {
+ beforeEach(() => {
+ createComponent(
+ {
+ props: {
+ checkable: true,
+ },
+ },
+ mountExtended,
+ );
+ });
+
+ it('Displays a checkbox field', () => {
+ expect(findCell({ fieldKey: 'checkbox' }).find('input').exists()).toBe(true);
+ });
+
+ it('Emits a checked event', () => {
+ const checkbox = findCell({ fieldKey: 'checkbox' }).find('input');
+
+ checkbox.setChecked();
+
+ expect(wrapper.emitted('checked')).toHaveLength(1);
+ expect(wrapper.emitted('checked')[0][0]).toEqual({
+ isChecked: true,
+ runner: mockRunners[0],
+ });
+ });
+ });
+
describe('Scoped cell slots', () => {
it('Render #runner-name slot in "summary" cell', () => {
createComponent(
@@ -156,6 +201,8 @@ describe('RunnerList', () => {
const { id, shortSha } = mockRunners[0];
const numericId = getIdFromGraphQLId(id);
+ createComponent({}, mountExtended);
+
expect(findCell({ fieldKey: 'summary' }).text()).toContain(`#${numericId} (${shortSha})`);
});
diff --git a/spec/frontend/runner/components/runner_pause_button_spec.js b/spec/frontend/runner/components/runner_pause_button_spec.js
index 3d9df03977e..9ebb30b6ed7 100644
--- a/spec/frontend/runner/components/runner_pause_button_spec.js
+++ b/spec/frontend/runner/components/runner_pause_button_spec.js
@@ -146,6 +146,10 @@ describe('RunnerPauseButton', () => {
it('The button does not have a loading state', () => {
expect(findBtn().props('loading')).toBe(false);
});
+
+ it('The button emits toggledPaused', () => {
+ expect(wrapper.emitted('toggledPaused')).toHaveLength(1);
+ });
});
describe('When update fails', () => {
diff --git a/spec/frontend/runner/components/runner_projects_spec.js b/spec/frontend/runner/components/runner_projects_spec.js
index 96de8d11bca..62ebc6539e2 100644
--- a/spec/frontend/runner/components/runner_projects_spec.js
+++ b/spec/frontend/runner/components/runner_projects_spec.js
@@ -1,4 +1,4 @@
-import { GlSkeletonLoading } from '@gitlab/ui';
+import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
diff --git a/spec/frontend/runner/components/runner_status_badge_spec.js b/spec/frontend/runner/components/runner_status_badge_spec.js
index c470c6bb989..bb833bd7d5a 100644
--- a/spec/frontend/runner/components/runner_status_badge_spec.js
+++ b/spec/frontend/runner/components/runner_status_badge_spec.js
@@ -7,6 +7,8 @@ import {
STATUS_OFFLINE,
STATUS_STALE,
STATUS_NEVER_CONTACTED,
+ I18N_NEVER_CONTACTED_TOOLTIP,
+ I18N_STALE_NEVER_CONTACTED_TOOLTIP,
} from '~/runner/constants';
describe('RunnerTypeBadge', () => {
@@ -59,7 +61,7 @@ describe('RunnerTypeBadge', () => {
expect(wrapper.text()).toBe('never contacted');
expect(findBadge().props('variant')).toBe('muted');
- expect(getTooltip().value).toMatch('This runner has never contacted');
+ expect(getTooltip().value).toBe(I18N_NEVER_CONTACTED_TOOLTIP);
});
it('renders offline state', () => {
@@ -72,9 +74,7 @@ describe('RunnerTypeBadge', () => {
expect(wrapper.text()).toBe('offline');
expect(findBadge().props('variant')).toBe('muted');
- expect(getTooltip().value).toBe(
- 'No recent contact from this runner; last contact was 1 day ago',
- );
+ expect(getTooltip().value).toBe('Runner is offline; last contact was 1 day ago');
});
it('renders stale state', () => {
@@ -87,7 +87,20 @@ describe('RunnerTypeBadge', () => {
expect(wrapper.text()).toBe('stale');
expect(findBadge().props('variant')).toBe('warning');
- expect(getTooltip().value).toBe('No contact from this runner in over 3 months');
+ expect(getTooltip().value).toBe('Runner is stale; last contact was 1 year ago');
+ });
+
+ it('renders stale state with no contact time', () => {
+ createComponent({
+ runner: {
+ contactedAt: null,
+ status: STATUS_STALE,
+ },
+ });
+
+ expect(wrapper.text()).toBe('stale');
+ expect(findBadge().props('variant')).toBe('warning');
+ expect(getTooltip().value).toBe(I18N_STALE_NEVER_CONTACTED_TOOLTIP);
});
describe('does not fail when data is missing', () => {
@@ -100,7 +113,7 @@ describe('RunnerTypeBadge', () => {
});
expect(wrapper.text()).toBe('online');
- expect(getTooltip().value).toBe('Runner is online; last contact was n/a');
+ expect(getTooltip().value).toBe('Runner is online; last contact was never');
});
it('status is missing', () => {
diff --git a/spec/frontend/runner/components/runner_status_popover_spec.js b/spec/frontend/runner/components/runner_status_popover_spec.js
new file mode 100644
index 00000000000..789283d1245
--- /dev/null
+++ b/spec/frontend/runner/components/runner_status_popover_spec.js
@@ -0,0 +1,36 @@
+import { GlSprintf } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import RunnerStatusPopover from '~/runner/components/runner_status_popover.vue';
+import HelpPopover from '~/vue_shared/components/help_popover.vue';
+import { onlineContactTimeoutSecs, staleTimeoutSecs } from '../mock_data';
+
+describe('RunnerStatusPopover', () => {
+ let wrapper;
+
+ const createComponent = ({ provide = {} } = {}) => {
+ wrapper = shallowMountExtended(RunnerStatusPopover, {
+ provide: {
+ onlineContactTimeoutSecs,
+ staleTimeoutSecs,
+ ...provide,
+ },
+ stubs: {
+ GlSprintf,
+ },
+ });
+ };
+
+ const findHelpPopover = () => wrapper.findComponent(HelpPopover);
+
+ it('renders popoover', () => {
+ createComponent();
+
+ expect(findHelpPopover().exists()).toBe(true);
+ });
+
+ it('renders complete text', () => {
+ createComponent();
+
+ expect(findHelpPopover().text()).toMatchSnapshot();
+ });
+});
diff --git a/spec/frontend/runner/graphql/local_state_spec.js b/spec/frontend/runner/graphql/local_state_spec.js
new file mode 100644
index 00000000000..5c4302e4aa2
--- /dev/null
+++ b/spec/frontend/runner/graphql/local_state_spec.js
@@ -0,0 +1,72 @@
+import createApolloClient from '~/lib/graphql';
+import { createLocalState } from '~/runner/graphql/list/local_state';
+import getCheckedRunnerIdsQuery from '~/runner/graphql/list/checked_runner_ids.query.graphql';
+
+describe('~/runner/graphql/list/local_state', () => {
+ let localState;
+ let apolloClient;
+
+ const createSubject = () => {
+ if (apolloClient) {
+ throw new Error('test subject already exists!');
+ }
+
+ localState = createLocalState();
+
+ const { cacheConfig, typeDefs } = localState;
+
+ apolloClient = createApolloClient({}, { cacheConfig, typeDefs });
+ };
+
+ const queryCheckedRunnerIds = () => {
+ const { checkedRunnerIds } = apolloClient.readQuery({
+ query: getCheckedRunnerIdsQuery,
+ });
+ return checkedRunnerIds;
+ };
+
+ beforeEach(() => {
+ createSubject();
+ });
+
+ afterEach(() => {
+ localState = null;
+ apolloClient = null;
+ });
+
+ describe('default', () => {
+ it('has empty checked list', () => {
+ expect(queryCheckedRunnerIds()).toEqual([]);
+ });
+ });
+
+ describe.each`
+ inputs | expected
+ ${[['a', true], ['b', true], ['b', true]]} | ${['a', 'b']}
+ ${[['a', true], ['b', true], ['a', false]]} | ${['b']}
+ ${[['c', true], ['b', true], ['a', true], ['d', false]]} | ${['c', 'b', 'a']}
+ `('setRunnerChecked', ({ inputs, expected }) => {
+ beforeEach(() => {
+ inputs.forEach(([id, isChecked]) => {
+ localState.localMutations.setRunnerChecked({ runner: { id }, isChecked });
+ });
+ });
+ it(`for inputs="${inputs}" has a ids="[${expected}]"`, () => {
+ expect(queryCheckedRunnerIds()).toEqual(expected);
+ });
+ });
+
+ describe('clearChecked', () => {
+ it('clears all checked items', () => {
+ ['a', 'b', 'c'].forEach((id) => {
+ localState.localMutations.setRunnerChecked({ runner: { id }, isChecked: true });
+ });
+
+ expect(queryCheckedRunnerIds()).toEqual(['a', 'b', 'c']);
+
+ localState.localMutations.clearChecked();
+
+ expect(queryCheckedRunnerIds()).toEqual([]);
+ });
+ });
+});
diff --git a/spec/frontend/runner/group_runners/group_runners_app_spec.js b/spec/frontend/runner/group_runners/group_runners_app_spec.js
index 70e303e8626..02348bf737a 100644
--- a/spec/frontend/runner/group_runners/group_runners_app_spec.js
+++ b/spec/frontend/runner/group_runners/group_runners_app_spec.js
@@ -28,8 +28,9 @@ import {
INSTANCE_TYPE,
GROUP_TYPE,
PROJECT_TYPE,
+ PARAM_KEY_PAUSED,
PARAM_KEY_STATUS,
- STATUS_ACTIVE,
+ STATUS_ONLINE,
RUNNER_PAGE_SIZE,
I18N_EDIT,
} from '~/runner/constants';
@@ -38,7 +39,13 @@ import getGroupRunnersCountQuery from '~/runner/graphql/list/group_runners_count
import GroupRunnersApp from '~/runner/group_runners/group_runners_app.vue';
import { captureException } from '~/runner/sentry_utils';
import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
-import { groupRunnersData, groupRunnersDataPaginated, groupRunnersCountData } from '../mock_data';
+import {
+ groupRunnersData,
+ groupRunnersDataPaginated,
+ groupRunnersCountData,
+ onlineContactTimeoutSecs,
+ staleTimeoutSecs,
+} from '../mock_data';
Vue.use(VueApollo);
Vue.use(GlToast);
@@ -90,6 +97,10 @@ describe('GroupRunnersApp', () => {
groupRunnersLimitedCount: mockGroupRunnersLimitedCount,
...props,
},
+ provide: {
+ onlineContactTimeoutSecs,
+ staleTimeoutSecs,
+ },
});
};
@@ -178,13 +189,16 @@ describe('GroupRunnersApp', () => {
const tokens = findFilteredSearch().props('tokens');
- expect(tokens).toHaveLength(1);
- expect(tokens[0]).toEqual(
+ expect(tokens).toEqual([
+ expect.objectContaining({
+ type: PARAM_KEY_PAUSED,
+ options: expect.any(Array),
+ }),
expect.objectContaining({
type: PARAM_KEY_STATUS,
options: expect.any(Array),
}),
- );
+ ]);
});
describe('Single runner row', () => {
@@ -193,9 +207,11 @@ describe('GroupRunnersApp', () => {
const { webUrl, editUrl, node } = mockGroupRunnersEdges[0];
const { id: graphqlId, shortSha } = node;
const id = getIdFromGraphQLId(graphqlId);
+ const COUNT_QUERIES = 6; // Smart queries that display a filtered count of runners
+ const FILTERED_COUNT_QUERIES = 3; // Smart queries that display a count of runners in tabs
beforeEach(async () => {
- mockGroupRunnersQuery.mockClear();
+ mockGroupRunnersCountQuery.mockClear();
createComponent({ mountFn: mountExtended });
showToast = jest.spyOn(wrapper.vm.$root.$toast, 'show');
@@ -219,12 +235,20 @@ describe('GroupRunnersApp', () => {
});
});
- it('When runner is deleted, data is refetched and a toast is shown', async () => {
- expect(mockGroupRunnersQuery).toHaveBeenCalledTimes(1);
+ it('When runner is paused or unpaused, some data is refetched', async () => {
+ expect(mockGroupRunnersCountQuery).toHaveBeenCalledTimes(COUNT_QUERIES);
- findRunnerActionsCell().vm.$emit('deleted', { message: 'Runner deleted' });
+ findRunnerActionsCell().vm.$emit('toggledPaused');
+
+ expect(mockGroupRunnersCountQuery).toHaveBeenCalledTimes(
+ COUNT_QUERIES + FILTERED_COUNT_QUERIES,
+ );
- expect(mockGroupRunnersQuery).toHaveBeenCalledTimes(2);
+ expect(showToast).toHaveBeenCalledTimes(0);
+ });
+
+ it('When runner is deleted, data is refetched and a toast message is shown', async () => {
+ findRunnerActionsCell().vm.$emit('deleted', { message: 'Runner deleted' });
expect(showToast).toHaveBeenCalledTimes(1);
expect(showToast).toHaveBeenCalledWith('Runner deleted');
@@ -233,7 +257,7 @@ describe('GroupRunnersApp', () => {
describe('when a filter is preselected', () => {
beforeEach(async () => {
- setWindowLocation(`?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}`);
+ setWindowLocation(`?status[]=${STATUS_ONLINE}&runner_type[]=${INSTANCE_TYPE}`);
createComponent();
await waitForPromises();
@@ -242,7 +266,7 @@ describe('GroupRunnersApp', () => {
it('sets the filters in the search bar', () => {
expect(findRunnerFilteredSearchBar().props('value')).toEqual({
runnerType: INSTANCE_TYPE,
- filters: [{ type: 'status', value: { data: STATUS_ACTIVE, operator: '=' } }],
+ filters: [{ type: 'status', value: { data: STATUS_ONLINE, operator: '=' } }],
sort: 'CREATED_DESC',
pagination: { page: 1 },
});
@@ -251,7 +275,7 @@ describe('GroupRunnersApp', () => {
it('requests the runners with filter parameters', () => {
expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({
groupFullPath: mockGroupFullPath,
- status: STATUS_ACTIVE,
+ status: STATUS_ONLINE,
type: INSTANCE_TYPE,
sort: DEFAULT_SORT,
first: RUNNER_PAGE_SIZE,
@@ -263,7 +287,7 @@ describe('GroupRunnersApp', () => {
beforeEach(async () => {
findRunnerFilteredSearchBar().vm.$emit('input', {
runnerType: null,
- filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } }],
+ filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }],
sort: CREATED_ASC,
});
@@ -273,14 +297,14 @@ describe('GroupRunnersApp', () => {
it('updates the browser url', () => {
expect(updateHistory).toHaveBeenLastCalledWith({
title: expect.any(String),
- url: 'http://test.host/groups/group1/-/runners?status[]=ACTIVE&sort=CREATED_ASC',
+ url: 'http://test.host/groups/group1/-/runners?status[]=ONLINE&sort=CREATED_ASC',
});
});
it('requests the runners with filters', () => {
expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({
groupFullPath: mockGroupFullPath,
- status: STATUS_ACTIVE,
+ status: STATUS_ONLINE,
sort: CREATED_ASC,
first: RUNNER_PAGE_SIZE,
});
diff --git a/spec/frontend/runner/mock_data.js b/spec/frontend/runner/mock_data.js
index 49c25039719..fbe8926124c 100644
--- a/spec/frontend/runner/mock_data.js
+++ b/spec/frontend/runner/mock_data.js
@@ -14,6 +14,10 @@ import runnerWithGroupData from 'test_fixtures/graphql/runner/details/runner.que
import runnerProjectsData from 'test_fixtures/graphql/runner/details/runner_projects.query.graphql.json';
import runnerJobsData from 'test_fixtures/graphql/runner/details/runner_jobs.query.graphql.json';
+// Other mock data
+export const onlineContactTimeoutSecs = 2 * 60 * 60;
+export const staleTimeoutSecs = 5259492; // Ruby's `2.months`
+
export {
runnersData,
runnersCountData,
diff --git a/spec/frontend/runner/runner_search_utils_spec.js b/spec/frontend/runner/runner_search_utils_spec.js
index aff1ec882bb..7834e76fe48 100644
--- a/spec/frontend/runner/runner_search_utils_spec.js
+++ b/spec/frontend/runner/runner_search_utils_spec.js
@@ -181,6 +181,28 @@ describe('search_params.js', () => {
first: RUNNER_PAGE_SIZE,
},
},
+ {
+ name: 'paused runners',
+ urlQuery: '?paused[]=true',
+ search: {
+ runnerType: null,
+ filters: [{ type: 'paused', value: { data: 'true', operator: '=' } }],
+ pagination: { page: 1 },
+ sort: 'CREATED_DESC',
+ },
+ graphqlVariables: { paused: true, sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE },
+ },
+ {
+ name: 'active runners',
+ urlQuery: '?paused[]=false',
+ search: {
+ runnerType: null,
+ filters: [{ type: 'paused', value: { data: 'false', operator: '=' } }],
+ pagination: { page: 1 },
+ sort: 'CREATED_DESC',
+ },
+ graphqlVariables: { paused: false, sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE },
+ },
];
describe('searchValidator', () => {
@@ -197,14 +219,18 @@ describe('search_params.js', () => {
expect(updateOutdatedUrl('http://test.host/?a=b')).toBe(null);
});
- it('returns updated url for updating NOT_CONNECTED to NEVER_CONTACTED', () => {
- expect(updateOutdatedUrl('http://test.host/admin/runners?status[]=NOT_CONNECTED')).toBe(
- 'http://test.host/admin/runners?status[]=NEVER_CONTACTED',
- );
+ it.each`
+ query | updatedQuery
+ ${'status[]=NOT_CONNECTED'} | ${'status[]=NEVER_CONTACTED'}
+ ${'status[]=NOT_CONNECTED&a=b'} | ${'status[]=NEVER_CONTACTED&a=b'}
+ ${'status[]=ACTIVE'} | ${'paused[]=false'}
+ ${'status[]=ACTIVE&a=b'} | ${'a=b&paused[]=false'}
+ ${'status[]=ACTIVE'} | ${'paused[]=false'}
+ ${'status[]=PAUSED'} | ${'paused[]=true'}
+ `('updates "$query" to "$updatedQuery"', ({ query, updatedQuery }) => {
+ const mockUrl = 'http://test.host/admin/runners?';
- expect(updateOutdatedUrl('http://test.host/admin/runners?status[]=NOT_CONNECTED&a=b')).toBe(
- 'http://test.host/admin/runners?status[]=NEVER_CONTACTED&a=b',
- );
+ expect(updateOutdatedUrl(`${mockUrl}${query}`)).toBe(`${mockUrl}${updatedQuery}`);
});
});
diff --git a/spec/frontend/runner/utils_spec.js b/spec/frontend/runner/utils_spec.js
index 3fa9784ecdf..1db9815dfd8 100644
--- a/spec/frontend/runner/utils_spec.js
+++ b/spec/frontend/runner/utils_spec.js
@@ -44,6 +44,10 @@ describe('~/runner/utils', () => {
thClass: expect.arrayContaining(mockClasses),
});
});
+
+ it('a field with custom options', () => {
+ expect(tableField({ foo: 'bar' })).toMatchObject({ foo: 'bar' });
+ });
});
describe('getPaginationVariables', () => {
diff --git a/spec/frontend/search/store/actions_spec.js b/spec/frontend/search/store/actions_spec.js
index 5f8cee8160f..67bd3194f20 100644
--- a/spec/frontend/search/store/actions_spec.js
+++ b/spec/frontend/search/store/actions_spec.js
@@ -56,7 +56,7 @@ describe('Global Search Store Actions', () => {
${actions.fetchGroups} | ${{ method: 'onGet', code: 200, res: MOCK_GROUPS }} | ${'success'} | ${[{ type: types.REQUEST_GROUPS }, { type: types.RECEIVE_GROUPS_SUCCESS, payload: MOCK_GROUPS }]} | ${0}
${actions.fetchGroups} | ${{ method: 'onGet', code: 500, res: null }} | ${'error'} | ${[{ type: types.REQUEST_GROUPS }, { type: types.RECEIVE_GROUPS_ERROR }]} | ${1}
${actions.fetchProjects} | ${{ method: 'onGet', code: 200, res: MOCK_PROJECTS }} | ${'success'} | ${[{ type: types.REQUEST_PROJECTS }, { type: types.RECEIVE_PROJECTS_SUCCESS, payload: MOCK_PROJECTS }]} | ${0}
- ${actions.fetchProjects} | ${{ method: 'onGet', code: 500, res: null }} | ${'error'} | ${[{ type: types.REQUEST_PROJECTS }, { type: types.RECEIVE_PROJECTS_ERROR }]} | ${2}
+ ${actions.fetchProjects} | ${{ method: 'onGet', code: 500, res: null }} | ${'error'} | ${[{ type: types.REQUEST_PROJECTS }, { type: types.RECEIVE_PROJECTS_ERROR }]} | ${1}
`(`axios calls`, ({ action, axiosMock, type, expectedMutations, flashCallCount }) => {
describe(action.name, () => {
describe(`on ${type}`, () => {
@@ -121,8 +121,8 @@ describe('Global Search Store Actions', () => {
describe('when groupId is set', () => {
it('calls Api.groupProjects with expected parameters', () => {
- actions.fetchProjects({ commit: mockCommit, state });
-
+ const callbackTest = jest.fn();
+ actions.fetchProjects({ commit: mockCommit, state }, undefined, callbackTest);
expect(Api.groupProjects).toHaveBeenCalledWith(
state.query.group_id,
state.query.search,
@@ -131,7 +131,8 @@ describe('Global Search Store Actions', () => {
include_subgroups: true,
with_shared: false,
},
- expect.any(Function),
+ callbackTest,
+ true,
);
expect(Api.projects).not.toHaveBeenCalled();
});
@@ -144,15 +145,10 @@ describe('Global Search Store Actions', () => {
it('calls Api.projects', () => {
actions.fetchProjects({ commit: mockCommit, state });
-
expect(Api.groupProjects).not.toHaveBeenCalled();
- expect(Api.projects).toHaveBeenCalledWith(
- state.query.search,
- {
- order_by: 'similarity',
- },
- expect.any(Function),
- );
+ expect(Api.projects).toHaveBeenCalledWith(state.query.search, {
+ order_by: 'similarity',
+ });
});
});
});
diff --git a/spec/frontend/search_autocomplete_spec.js b/spec/frontend/search_autocomplete_spec.js
index c643cf6557d..190f2803324 100644
--- a/spec/frontend/search_autocomplete_spec.js
+++ b/spec/frontend/search_autocomplete_spec.js
@@ -223,34 +223,22 @@ describe('Search autocomplete dropdown', () => {
});
}
- it('suggest Projects', (done) => {
- // eslint-disable-next-line promise/catch-or-return
- triggerAutocomplete().finally(() => {
- const list = widget.wrap.find('.dropdown-menu').find('ul');
- const link = "a[href$='/gitlab-org/gitlab-test']";
+ it('suggest Projects', async () => {
+ await triggerAutocomplete();
- expect(list.find(link).length).toBe(1);
+ const list = widget.wrap.find('.dropdown-menu').find('ul');
+ const link = "a[href$='/gitlab-org/gitlab-test']";
- done();
- });
-
- // Make sure jest properly acknowledge the `done` invocation
- jest.runOnlyPendingTimers();
+ expect(list.find(link).length).toBe(1);
});
- it('suggest Groups', (done) => {
- // eslint-disable-next-line promise/catch-or-return
- triggerAutocomplete().finally(() => {
- const list = widget.wrap.find('.dropdown-menu').find('ul');
- const link = "a[href$='/gitlab-org']";
+ it('suggest Groups', async () => {
+ await triggerAutocomplete();
- expect(list.find(link).length).toBe(1);
-
- done();
- });
+ const list = widget.wrap.find('.dropdown-menu').find('ul');
+ const link = "a[href$='/gitlab-org']";
- // Make sure jest properly acknowledge the `done` invocation
- jest.runOnlyPendingTimers();
+ expect(list.find(link).length).toBe(1);
});
});
diff --git a/spec/frontend/search_settings/components/search_settings_spec.js b/spec/frontend/search_settings/components/search_settings_spec.js
index 6beaea8dba5..d0a2018c7f0 100644
--- a/spec/frontend/search_settings/components/search_settings_spec.js
+++ b/spec/frontend/search_settings/components/search_settings_spec.js
@@ -1,5 +1,6 @@
import { GlSearchBoxByType } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
+import { setHTMLFixture } from 'helpers/fixtures';
import SearchSettings from '~/search_settings/components/search_settings.vue';
import { HIGHLIGHT_CLASS, HIDE_CLASS } from '~/search_settings/constants';
import { isExpanded, expandSection, closeSection } from '~/settings_panels';
@@ -11,7 +12,8 @@ describe('search_settings/components/search_settings.vue', () => {
const GENERAL_SETTINGS_ID = 'js-general-settings';
const ADVANCED_SETTINGS_ID = 'js-advanced-settings';
const EXTRA_SETTINGS_ID = 'js-extra-settings';
- const TEXT_CONTAIN_SEARCH_TERM = `This text contain ${SEARCH_TERM} and <script>alert("111")</script> others.`;
+ const TEXT_CONTAIN_SEARCH_TERM = `This text contain ${SEARCH_TERM}.`;
+ const TEXT_WITH_SIBLING_ELEMENTS = `${SEARCH_TERM} <a data-testid="sibling" href="#">Learn more</a>.`;
let wrapper;
@@ -42,13 +44,7 @@ describe('search_settings/components/search_settings.vue', () => {
});
};
- const matchParentElement = () => {
- const highlightedList = Array.from(document.querySelectorAll(`.${HIGHLIGHT_CLASS}`));
- return highlightedList.map((element) => {
- return element.parentNode;
- });
- };
-
+ const findMatchSiblingElement = () => document.querySelector(`[data-testid="sibling"]`);
const findSearchBox = () => wrapper.find(GlSearchBoxByType);
const search = (term) => {
findSearchBox().vm.$emit('input', term);
@@ -56,7 +52,7 @@ describe('search_settings/components/search_settings.vue', () => {
const clearSearch = () => search('');
beforeEach(() => {
- setFixtures(`
+ setHTMLFixture(`
<div>
<div class="js-search-app"></div>
<div id="${ROOT_ID}">
@@ -69,6 +65,7 @@ describe('search_settings/components/search_settings.vue', () => {
<section id="${EXTRA_SETTINGS_ID}" class="settings">
<span>${SEARCH_TERM}</span>
<span>${TEXT_CONTAIN_SEARCH_TERM}</span>
+ <span>${TEXT_WITH_SIBLING_ELEMENTS}</span>
</section>
</div>
</div>
@@ -99,7 +96,7 @@ describe('search_settings/components/search_settings.vue', () => {
it('highlight elements that match the search term', () => {
search(SEARCH_TERM);
- expect(highlightedElementsCount()).toBe(2);
+ expect(highlightedElementsCount()).toBe(3);
});
it('highlight only search term and not the whole line', () => {
@@ -108,14 +105,26 @@ describe('search_settings/components/search_settings.vue', () => {
expect(highlightedTextNodes()).toBe(true);
});
- it('prevents search xss', () => {
+ // Regression test for https://gitlab.com/gitlab-org/gitlab/-/issues/350494
+ it('preserves elements that are siblings of matches', () => {
+ const snapshot = `
+ <a
+ data-testid="sibling"
+ href="#"
+ >
+ Learn more
+ </a>
+ `;
+
+ expect(findMatchSiblingElement()).toMatchInlineSnapshot(snapshot);
+
search(SEARCH_TERM);
- const parentNodeList = matchParentElement();
- parentNodeList.forEach((element) => {
- const scriptElement = element.getElementsByTagName('script');
- expect(scriptElement.length).toBe(0);
- });
+ expect(findMatchSiblingElement()).toMatchInlineSnapshot(snapshot);
+
+ clearSearch();
+
+ expect(findMatchSiblingElement()).toMatchInlineSnapshot(snapshot);
});
describe('default', () => {
diff --git a/spec/frontend/security_configuration/components/app_spec.js b/spec/frontend/security_configuration/components/app_spec.js
index 963577fa763..9a18cb636b2 100644
--- a/spec/frontend/security_configuration/components/app_spec.js
+++ b/spec/frontend/security_configuration/components/app_spec.js
@@ -1,4 +1,4 @@
-import { GlTab, GlTabs } from '@gitlab/ui';
+import { GlTab, GlTabs, GlLink } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
@@ -33,6 +33,7 @@ const autoDevopsHelpPagePath = '/autoDevopsHelpPagePath';
const autoDevopsPath = '/autoDevopsPath';
const gitlabCiHistoryPath = 'test/historyPath';
const projectFullPath = 'namespace/project';
+const vulnerabilityTrainingDocsPath = 'user/application_security/vulnerabilities/index';
useLocalStorageSpy();
@@ -55,6 +56,7 @@ describe('App component', () => {
autoDevopsHelpPagePath,
autoDevopsPath,
projectFullPath,
+ vulnerabilityTrainingDocsPath,
glFeatures: {
secureVulnerabilityTraining,
},
@@ -107,6 +109,7 @@ describe('App component', () => {
const findUpgradeBanner = () => wrapper.findComponent(UpgradeBanner);
const findAutoDevopsAlert = () => wrapper.findComponent(AutoDevopsAlert);
const findAutoDevopsEnabledAlert = () => wrapper.findComponent(AutoDevopsEnabledAlert);
+ const findVulnerabilityManagementTab = () => wrapper.findByTestId('vulnerability-management-tab');
const securityFeaturesMock = [
{
@@ -454,9 +457,14 @@ describe('App component', () => {
});
it('renders security training description', () => {
- const vulnerabilityManagementTab = wrapper.findByTestId('vulnerability-management-tab');
+ expect(findVulnerabilityManagementTab().text()).toContain(i18n.securityTrainingDescription);
+ });
+
+ it('renders link to help docs', () => {
+ const trainingLink = findVulnerabilityManagementTab().findComponent(GlLink);
- expect(vulnerabilityManagementTab.text()).toContain(i18n.securityTrainingDescription);
+ expect(trainingLink.text()).toBe('Learn more about vulnerability training');
+ expect(trainingLink.attributes('href')).toBe(vulnerabilityTrainingDocsPath);
});
});
diff --git a/spec/frontend/security_configuration/components/feature_card_badge_spec.js b/spec/frontend/security_configuration/components/feature_card_badge_spec.js
new file mode 100644
index 00000000000..dcde0808fa4
--- /dev/null
+++ b/spec/frontend/security_configuration/components/feature_card_badge_spec.js
@@ -0,0 +1,40 @@
+import { mount } from '@vue/test-utils';
+import { GlBadge, GlTooltip } from '@gitlab/ui';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import FeatureCardBadge from '~/security_configuration/components/feature_card_badge.vue';
+
+describe('Feature card badge component', () => {
+ let wrapper;
+
+ const createComponent = (propsData) => {
+ wrapper = extendedWrapper(
+ mount(FeatureCardBadge, {
+ propsData,
+ }),
+ );
+ };
+
+ const findTooltip = () => wrapper.findComponent(GlTooltip);
+ const findBadge = () => wrapper.findComponent(GlBadge);
+
+ describe('tooltip render', () => {
+ describe.each`
+ context | badge | badgeHref
+ ${'href on a badge object'} | ${{ tooltipText: 'test', badgeHref: 'href' }} | ${undefined}
+ ${'href as property '} | ${{ tooltipText: null, badgeHref: '' }} | ${'link'}
+ ${'default href no property on badge or component'} | ${{ tooltipText: null, badgeHref: '' }} | ${undefined}
+ `('given $context', ({ badge, badgeHref }) => {
+ beforeEach(() => {
+ createComponent({ badge, badgeHref });
+ });
+
+ it('should show badge when badge given in configuration and available', () => {
+ expect(findTooltip().exists()).toBe(Boolean(badge && badge.tooltipText));
+ });
+
+ it('should render correct link if link is provided', () => {
+ expect(findBadge().attributes().href).toEqual(badgeHref);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/security_configuration/components/feature_card_spec.js b/spec/frontend/security_configuration/components/feature_card_spec.js
index f0d902bf9fe..d10722be8ea 100644
--- a/spec/frontend/security_configuration/components/feature_card_spec.js
+++ b/spec/frontend/security_configuration/components/feature_card_spec.js
@@ -2,6 +2,7 @@ import { GlIcon } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import FeatureCard from '~/security_configuration/components/feature_card.vue';
+import FeatureCardBadge from '~/security_configuration/components/feature_card_badge.vue';
import ManageViaMr from '~/vue_shared/security_configuration/components/manage_via_mr.vue';
import { REPORT_TYPE_SAST } from '~/vue_shared/security_reports/constants';
import { makeFeature } from './utils';
@@ -16,6 +17,7 @@ describe('FeatureCard component', () => {
propsData,
stubs: {
ManageViaMr: true,
+ FeatureCardBadge: true,
},
}),
);
@@ -24,6 +26,8 @@ describe('FeatureCard component', () => {
const findLinks = ({ text, href }) =>
wrapper.findAll(`a[href="${href}"]`).filter((link) => link.text() === text);
+ const findBadge = () => wrapper.findComponent(FeatureCardBadge);
+
const findEnableLinks = () =>
findLinks({
text: `Enable ${feature.shortName ?? feature.name}`,
@@ -262,5 +266,28 @@ describe('FeatureCard component', () => {
});
});
});
+
+ describe('information badge', () => {
+ describe.each`
+ context | available | badge
+ ${'available feature with badge'} | ${true} | ${{ text: 'test' }}
+ ${'unavailable feature without badge'} | ${false} | ${null}
+ ${'available feature without badge'} | ${true} | ${null}
+ ${'unavailable feature with badge'} | ${false} | ${{ text: 'test' }}
+ ${'available feature with empty badge'} | ${false} | ${{}}
+ `('given $context', ({ available, badge }) => {
+ beforeEach(() => {
+ feature = makeFeature({
+ available,
+ badge,
+ });
+ createComponent({ feature });
+ });
+
+ it('should show badge when badge given in configuration and available', () => {
+ expect(findBadge().exists()).toBe(Boolean(available && badge && badge.text));
+ });
+ });
+ });
});
});
diff --git a/spec/frontend/security_configuration/components/training_provider_list_spec.js b/spec/frontend/security_configuration/components/training_provider_list_spec.js
index b8c1bef0ddd..309a9cd4cd6 100644
--- a/spec/frontend/security_configuration/components/training_provider_list_spec.js
+++ b/spec/frontend/security_configuration/components/training_provider_list_spec.js
@@ -1,5 +1,13 @@
import * as Sentry from '@sentry/browser';
-import { GlAlert, GlLink, GlToggle, GlCard, GlSkeletonLoader, GlIcon } from '@gitlab/ui';
+import {
+ GlAlert,
+ GlLink,
+ GlFormRadio,
+ GlToggle,
+ GlCard,
+ GlSkeletonLoader,
+ GlIcon,
+} from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
@@ -87,7 +95,7 @@ describe('TrainingProviderList component', () => {
const findLinks = () => wrapper.findAllComponents(GlLink);
const findToggles = () => wrapper.findAllComponents(GlToggle);
const findFirstToggle = () => findToggles().at(0);
- const findPrimaryProviderRadios = () => wrapper.findAllByTestId('primary-provider-radio');
+ const findPrimaryProviderRadios = () => wrapper.findAllComponents(GlFormRadio);
const findLoader = () => wrapper.findComponent(GlSkeletonLoader);
const findErrorAlert = () => wrapper.findComponent(GlAlert);
const findLogos = () => wrapper.findAllByTestId('provider-logo');
@@ -177,8 +185,8 @@ describe('TrainingProviderList component', () => {
const primaryProviderRadioForCurrentCard = findPrimaryProviderRadios().at(index);
// if the given provider is not enabled it should not be possible select it as primary
- expect(primaryProviderRadioForCurrentCard.find('input').attributes('disabled')).toBe(
- isEnabled ? undefined : 'disabled',
+ expect(primaryProviderRadioForCurrentCard.attributes('disabled')).toBe(
+ isEnabled ? undefined : 'true',
);
expect(primaryProviderRadioForCurrentCard.text()).toBe(
diff --git a/spec/frontend/self_monitor/store/actions_spec.js b/spec/frontend/self_monitor/store/actions_spec.js
index 6bcb2a713ea..59ee87c4a02 100644
--- a/spec/frontend/self_monitor/store/actions_spec.js
+++ b/spec/frontend/self_monitor/store/actions_spec.js
@@ -16,27 +16,25 @@ describe('self monitor actions', () => {
});
describe('setSelfMonitor', () => {
- it('commits the SET_ENABLED mutation', (done) => {
- testAction(
+ it('commits the SET_ENABLED mutation', () => {
+ return testAction(
actions.setSelfMonitor,
null,
state,
[{ type: types.SET_ENABLED, payload: null }],
[],
- done,
);
});
});
describe('resetAlert', () => {
- it('commits the SET_ENABLED mutation', (done) => {
- testAction(
+ it('commits the SET_ENABLED mutation', () => {
+ return testAction(
actions.resetAlert,
null,
state,
[{ type: types.SET_SHOW_ALERT, payload: false }],
[],
- done,
);
});
});
@@ -54,8 +52,8 @@ describe('self monitor actions', () => {
});
});
- it('dispatches status request with job data', (done) => {
- testAction(
+ it('dispatches status request with job data', () => {
+ return testAction(
actions.requestCreateProject,
null,
state,
@@ -71,12 +69,11 @@ describe('self monitor actions', () => {
payload: '123',
},
],
- done,
);
});
- it('dispatches success with project path', (done) => {
- testAction(
+ it('dispatches success with project path', () => {
+ return testAction(
actions.requestCreateProjectStatus,
null,
state,
@@ -87,7 +84,6 @@ describe('self monitor actions', () => {
payload: { project_full_path: '/self-monitor-url' },
},
],
- done,
);
});
});
@@ -98,8 +94,8 @@ describe('self monitor actions', () => {
mock.onPost(state.createProjectEndpoint).reply(500);
});
- it('dispatches error', (done) => {
- testAction(
+ it('dispatches error', () => {
+ return testAction(
actions.requestCreateProject,
null,
state,
@@ -115,14 +111,13 @@ describe('self monitor actions', () => {
payload: new Error('Request failed with status code 500'),
},
],
- done,
);
});
});
describe('requestCreateProjectSuccess', () => {
- it('should commit the received data', (done) => {
- testAction(
+ it('should commit the received data', () => {
+ return testAction(
actions.requestCreateProjectSuccess,
{ project_full_path: '/self-monitor-url' },
state,
@@ -146,7 +141,6 @@ describe('self monitor actions', () => {
type: 'setSelfMonitor',
},
],
- done,
);
});
});
@@ -165,8 +159,8 @@ describe('self monitor actions', () => {
});
});
- it('dispatches status request with job data', (done) => {
- testAction(
+ it('dispatches status request with job data', () => {
+ return testAction(
actions.requestDeleteProject,
null,
state,
@@ -182,12 +176,11 @@ describe('self monitor actions', () => {
payload: '456',
},
],
- done,
);
});
- it('dispatches success with status', (done) => {
- testAction(
+ it('dispatches success with status', () => {
+ return testAction(
actions.requestDeleteProjectStatus,
null,
state,
@@ -198,7 +191,6 @@ describe('self monitor actions', () => {
payload: { status: 'success' },
},
],
- done,
);
});
});
@@ -209,8 +201,8 @@ describe('self monitor actions', () => {
mock.onDelete(state.deleteProjectEndpoint).reply(500);
});
- it('dispatches error', (done) => {
- testAction(
+ it('dispatches error', () => {
+ return testAction(
actions.requestDeleteProject,
null,
state,
@@ -226,14 +218,13 @@ describe('self monitor actions', () => {
payload: new Error('Request failed with status code 500'),
},
],
- done,
);
});
});
describe('requestDeleteProjectSuccess', () => {
- it('should commit mutations to remove previously set data', (done) => {
- testAction(
+ it('should commit mutations to remove previously set data', () => {
+ return testAction(
actions.requestDeleteProjectSuccess,
null,
state,
@@ -252,7 +243,6 @@ describe('self monitor actions', () => {
{ type: types.SET_LOADING, payload: false },
],
[],
- done,
);
});
});
diff --git a/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap b/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap
index f57b9418be5..0f4dfdf8a75 100644
--- a/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap
+++ b/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap
@@ -3,7 +3,7 @@
exports[`EmptyStateComponent should render content 1`] = `
"<section class=\\"gl-display-flex empty-state gl-text-center gl-flex-direction-column\\">
<div class=\\"gl-max-w-full\\">
- <div class=\\"svg-250 svg-content\\"><img src=\\"/image.svg\\" alt=\\"\\" role=\\"img\\" class=\\"gl-max-w-full\\"></div>
+ <div class=\\"svg-250 svg-content\\"><img src=\\"/image.svg\\" alt=\\"\\" role=\\"img\\" class=\\"gl-max-w-full gl-dark-invert-keep-hue\\"></div>
</div>
<div class=\\"gl-max-w-full gl-m-auto\\">
<div class=\\"gl-mx-auto gl-my-0 gl-p-5\\">
diff --git a/spec/frontend/serverless/store/actions_spec.js b/spec/frontend/serverless/store/actions_spec.js
index 61b9bd121af..5fbecf081a6 100644
--- a/spec/frontend/serverless/store/actions_spec.js
+++ b/spec/frontend/serverless/store/actions_spec.js
@@ -7,13 +7,22 @@ import { mockServerlessFunctions, mockMetrics } from '../mock_data';
import { adjustMetricQuery } from '../utils';
describe('ServerlessActions', () => {
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
describe('fetchFunctions', () => {
- it('should successfully fetch functions', (done) => {
+ it('should successfully fetch functions', () => {
const endpoint = '/functions';
- const mock = new MockAdapter(axios);
mock.onGet(endpoint).reply(statusCodes.OK, JSON.stringify(mockServerlessFunctions));
- testAction(
+ return testAction(
fetchFunctions,
{ functionsPath: endpoint },
{},
@@ -22,68 +31,49 @@ describe('ServerlessActions', () => {
{ type: 'requestFunctionsLoading' },
{ type: 'receiveFunctionsSuccess', payload: mockServerlessFunctions },
],
- () => {
- mock.restore();
- done();
- },
);
});
- it('should successfully retry', (done) => {
+ it('should successfully retry', () => {
const endpoint = '/functions';
- const mock = new MockAdapter(axios);
mock
.onGet(endpoint)
.reply(() => new Promise((resolve) => setTimeout(() => resolve(200), Infinity)));
- testAction(
+ return testAction(
fetchFunctions,
{ functionsPath: endpoint },
{},
[],
[{ type: 'requestFunctionsLoading' }],
- () => {
- mock.restore();
- done();
- },
);
});
});
describe('fetchMetrics', () => {
- it('should return no prometheus', (done) => {
+ it('should return no prometheus', () => {
const endpoint = '/metrics';
- const mock = new MockAdapter(axios);
mock.onGet(endpoint).reply(statusCodes.NO_CONTENT);
- testAction(
+ return testAction(
fetchMetrics,
{ metricsPath: endpoint, hasPrometheus: false },
{},
[],
[{ type: 'receiveMetricsNoPrometheus' }],
- () => {
- mock.restore();
- done();
- },
);
});
- it('should successfully fetch metrics', (done) => {
+ it('should successfully fetch metrics', () => {
const endpoint = '/metrics';
- const mock = new MockAdapter(axios);
mock.onGet(endpoint).reply(statusCodes.OK, JSON.stringify(mockMetrics));
- testAction(
+ return testAction(
fetchMetrics,
{ metricsPath: endpoint, hasPrometheus: true },
{},
[],
[{ type: 'receiveMetricsSuccess', payload: adjustMetricQuery(mockMetrics) }],
- () => {
- mock.restore();
- done();
- },
);
});
});
diff --git a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js
index c105810e11c..0b672cbc93e 100644
--- a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js
+++ b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js
@@ -26,7 +26,7 @@ describe('SetStatusModalWrapper', () => {
defaultEmoji,
};
- const createComponent = (props = {}, improvedEmojiPicker = false) => {
+ const createComponent = (props = {}) => {
return shallowMount(SetStatusModalWrapper, {
propsData: {
...defaultProps,
@@ -35,19 +35,15 @@ describe('SetStatusModalWrapper', () => {
mocks: {
$toast,
},
- provide: {
- glFeatures: { improvedEmojiPicker },
- },
});
};
const findModal = () => wrapper.find(GlModal);
const findFormField = (field) => wrapper.find(`[name="user[status][${field}]"]`);
const findClearStatusButton = () => wrapper.find('.js-clear-user-status-button');
- const findNoEmojiPlaceholder = () => wrapper.find('.js-no-emoji-placeholder');
- const findToggleEmojiButton = () => wrapper.find('.js-toggle-emoji-menu');
const findAvailabilityCheckbox = () => wrapper.find(GlFormCheckbox);
const findClearStatusAtMessage = () => wrapper.find('[data-testid="clear-status-at-message"]');
+ const getEmojiPicker = () => wrapper.findComponent(EmojiPicker);
const initModal = async ({ mockOnUpdateSuccess = true, mockOnUpdateFailure = true } = {}) => {
const modal = findModal();
@@ -95,12 +91,6 @@ describe('SetStatusModalWrapper', () => {
expect(findClearStatusButton().isVisible()).toBe(true);
});
- it('clicking the toggle emoji button displays the emoji list', () => {
- expect(wrapper.vm.showEmojiMenu).not.toHaveBeenCalled();
- findToggleEmojiButton().trigger('click');
- expect(wrapper.vm.showEmojiMenu).toHaveBeenCalled();
- });
-
it('displays the clear status at dropdown', () => {
expect(wrapper.find('[data-testid="clear-status-at-dropdown"]').exists()).toBe(true);
});
@@ -108,16 +98,6 @@ describe('SetStatusModalWrapper', () => {
it('does not display the clear status at message', () => {
expect(findClearStatusAtMessage().exists()).toBe(false);
});
- });
-
- describe('improvedEmojiPicker is true', () => {
- const getEmojiPicker = () => wrapper.findComponent(EmojiPicker);
-
- beforeEach(async () => {
- await initEmojiMock();
- wrapper = createComponent({}, true);
- return initModal();
- });
it('renders emoji picker dropdown with custom positioning', () => {
expect(getEmojiPicker().props()).toMatchObject({
@@ -147,10 +127,6 @@ describe('SetStatusModalWrapper', () => {
it('hides the clear status button', () => {
expect(findClearStatusButton().isVisible()).toBe(false);
});
-
- it('shows the placeholder emoji', () => {
- expect(findNoEmojiPlaceholder().isVisible()).toBe(true);
- });
});
describe('with no currentEmoji set', () => {
@@ -163,22 +139,6 @@ describe('SetStatusModalWrapper', () => {
it('does not set the hidden status emoji field', () => {
expect(findFormField('emoji').element.value).toBe('');
});
-
- it('hides the placeholder emoji', () => {
- expect(findNoEmojiPlaceholder().isVisible()).toBe(false);
- });
-
- describe('with no currentMessage set', () => {
- beforeEach(async () => {
- await initEmojiMock();
- wrapper = createComponent({ currentEmoji: '', currentMessage: '' });
- return initModal();
- });
-
- it('shows the placeholder emoji', () => {
- expect(findNoEmojiPlaceholder().isVisible()).toBe(true);
- });
- });
});
describe('with currentClearStatusAfter set', () => {
diff --git a/spec/frontend/shortcuts_spec.js b/spec/frontend/shortcuts_spec.js
index 49148123a1c..8b9a11056f2 100644
--- a/spec/frontend/shortcuts_spec.js
+++ b/spec/frontend/shortcuts_spec.js
@@ -41,7 +41,7 @@ describe('Shortcuts', () => {
).toHaveBeenCalled();
});
- it('focues preview button inside edit comment form', () => {
+ it('focuses preview button inside edit comment form', () => {
document.querySelector('.js-note-edit').click();
Shortcuts.toggleMarkdownPreview(
diff --git a/spec/frontend/sidebar/assignees_realtime_spec.js b/spec/frontend/sidebar/assignees_realtime_spec.js
index 2249a1c08b8..ae8f07bf901 100644
--- a/spec/frontend/sidebar/assignees_realtime_spec.js
+++ b/spec/frontend/sidebar/assignees_realtime_spec.js
@@ -2,11 +2,16 @@ import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
import AssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue';
import issuableAssigneesSubscription from '~/sidebar/queries/issuable_assignees.subscription.graphql';
import SidebarMediator from '~/sidebar/sidebar_mediator';
import getIssueAssigneesQuery from '~/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql';
-import Mock, { issuableQueryResponse, subscriptionNullResponse } from './mock_data';
+import Mock, {
+ issuableQueryResponse,
+ subscriptionNullResponse,
+ subscriptionResponse,
+} from './mock_data';
Vue.use(VueApollo);
@@ -20,7 +25,6 @@ describe('Assignees Realtime', () => {
const createComponent = ({
issuableType = 'issue',
- issuableId = 1,
subscriptionHandler = subscriptionInitialHandler,
} = {}) => {
fakeApollo = createMockApollo([
@@ -30,7 +34,6 @@ describe('Assignees Realtime', () => {
wrapper = shallowMount(AssigneesRealtime, {
propsData: {
issuableType,
- issuableId,
queryVariables: {
issuableIid: '1',
projectPath: 'path/to/project',
@@ -60,11 +63,23 @@ describe('Assignees Realtime', () => {
});
});
- it('calls the subscription with correct variable for issue', () => {
+ it('calls the subscription with correct variable for issue', async () => {
createComponent();
+ await waitForPromises();
expect(subscriptionInitialHandler).toHaveBeenCalledWith({
issuableId: 'gid://gitlab/Issue/1',
});
});
+
+ it('emits an `assigneesUpdated` event on subscription response', async () => {
+ createComponent({
+ subscriptionHandler: jest.fn().mockResolvedValue(subscriptionResponse),
+ });
+ await waitForPromises();
+
+ expect(wrapper.emitted('assigneesUpdated')).toEqual([
+ [{ id: '1', assignees: subscriptionResponse.data.issuableAssigneesUpdated.assignees.nodes }],
+ ]);
+ });
});
diff --git a/spec/frontend/sidebar/components/incidents/escalation_status_spec.js b/spec/frontend/sidebar/components/incidents/escalation_status_spec.js
index 7a736624fc0..8d8c10d10f1 100644
--- a/spec/frontend/sidebar/components/incidents/escalation_status_spec.js
+++ b/spec/frontend/sidebar/components/incidents/escalation_status_spec.js
@@ -1,4 +1,5 @@
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import waitForPromises from 'helpers/wait_for_promises';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import EscalationStatus from '~/sidebar/components/incidents/escalation_status.vue';
import {
@@ -25,6 +26,11 @@ describe('EscalationStatus', () => {
const findDropdownComponent = () => wrapper.findComponent(GlDropdown);
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
+ const findDropdownMenu = () => findDropdownComponent().find('.dropdown-menu');
+ const toggleDropdown = async () => {
+ await findDropdownComponent().findComponent('button').trigger('click');
+ await waitForPromises();
+ };
describe('status', () => {
it('shows the current status', () => {
@@ -49,4 +55,32 @@ describe('EscalationStatus', () => {
expect(wrapper.emitted().input[0][0]).toBe(STATUS_ACKNOWLEDGED);
});
});
+
+ describe('close behavior', () => {
+ it('allows the dropdown to be closed by default', async () => {
+ createComponent();
+ // Open dropdown
+ await toggleDropdown();
+
+ expect(findDropdownMenu().classes('show')).toBe(true);
+
+ // Attempt to close dropdown
+ await toggleDropdown();
+
+ expect(findDropdownMenu().classes('show')).toBe(false);
+ });
+
+ it('preventDropdownClose prevents the dropdown from closing', async () => {
+ createComponent({ preventDropdownClose: true });
+ // Open dropdown
+ await toggleDropdown();
+
+ expect(findDropdownMenu().classes('show')).toBe(true);
+
+ // Attempt to close dropdown
+ await toggleDropdown();
+
+ expect(findDropdownMenu().classes('show')).toBe(true);
+ });
+ });
});
diff --git a/spec/frontend/sidebar/mock_data.js b/spec/frontend/sidebar/mock_data.js
index fbca00636b6..2b421037339 100644
--- a/spec/frontend/sidebar/mock_data.js
+++ b/spec/frontend/sidebar/mock_data.js
@@ -415,6 +415,28 @@ export const subscriptionNullResponse = {
},
};
+export const subscriptionResponse = {
+ data: {
+ issuableAssigneesUpdated: {
+ id: '1',
+ assignees: {
+ nodes: [
+ {
+ __typename: 'UserCore',
+ id: 'gid://gitlab/User/1',
+ avatarUrl:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
+ name: 'Administrator',
+ username: 'root',
+ webUrl: '/root',
+ status: null,
+ },
+ ],
+ },
+ },
+ },
+};
+
const mockUser1 = {
__typename: 'UserCore',
id: 'gid://gitlab/User/1',
diff --git a/spec/frontend/sidebar/participants_spec.js b/spec/frontend/sidebar/participants_spec.js
index 356628849d9..2517b625225 100644
--- a/spec/frontend/sidebar/participants_spec.js
+++ b/spec/frontend/sidebar/participants_spec.js
@@ -17,8 +17,7 @@ const PARTICIPANT_LIST = [PARTICIPANT, { ...PARTICIPANT, id: 2 }, { ...PARTICIPA
describe('Participants', () => {
let wrapper;
- const getMoreParticipantsButton = () => wrapper.find('button');
-
+ const getMoreParticipantsButton = () => wrapper.find('[data-testid="more-participants"]');
const getCollapsedParticipantsCount = () => wrapper.find('[data-testid="collapsed-count"]');
const mountComponent = (propsData) =>
@@ -167,7 +166,7 @@ describe('Participants', () => {
expect(wrapper.vm.isShowingMoreParticipants).toBe(false);
- getMoreParticipantsButton().trigger('click');
+ getMoreParticipantsButton().vm.$emit('click');
expect(wrapper.vm.isShowingMoreParticipants).toBe(true);
});
diff --git a/spec/frontend/snippets/components/edit_spec.js b/spec/frontend/snippets/components/edit_spec.js
index 61424fa1eb2..9cfe136129a 100644
--- a/spec/frontend/snippets/components/edit_spec.js
+++ b/spec/frontend/snippets/components/edit_spec.js
@@ -19,8 +19,8 @@ import {
SNIPPET_VISIBILITY_INTERNAL,
SNIPPET_VISIBILITY_PUBLIC,
} from '~/snippets/constants';
-import CreateSnippetMutation from '~/snippets/mutations/createSnippet.mutation.graphql';
-import UpdateSnippetMutation from '~/snippets/mutations/updateSnippet.mutation.graphql';
+import CreateSnippetMutation from '~/snippets/mutations/create_snippet.mutation.graphql';
+import UpdateSnippetMutation from '~/snippets/mutations/update_snippet.mutation.graphql';
import FormFooterActions from '~/vue_shared/components/form/form_footer_actions.vue';
import TitleField from '~/vue_shared/components/form/title.vue';
import { testEntries, createGQLSnippetsQueryResponse, createGQLSnippet } from '../test_utils';
diff --git a/spec/frontend/snippets/components/snippet_header_spec.js b/spec/frontend/snippets/components/snippet_header_spec.js
index 1b9d170556b..b750225a383 100644
--- a/spec/frontend/snippets/components/snippet_header_spec.js
+++ b/spec/frontend/snippets/components/snippet_header_spec.js
@@ -8,7 +8,7 @@ import waitForPromises from 'helpers/wait_for_promises';
import { Blob, BinaryBlob } from 'jest/blob/components/mock_data';
import { differenceInMilliseconds } from '~/lib/utils/datetime_utility';
import SnippetHeader, { i18n } from '~/snippets/components/snippet_header.vue';
-import DeleteSnippetMutation from '~/snippets/mutations/deleteSnippet.mutation.graphql';
+import DeleteSnippetMutation from '~/snippets/mutations/delete_snippet.mutation.graphql';
import axios from '~/lib/utils/axios_utils';
import createFlash, { FLASH_TYPES } from '~/flash';
diff --git a/spec/frontend/task_list_spec.js b/spec/frontend/task_list_spec.js
index bf470e7e126..fbdb73ae6de 100644
--- a/spec/frontend/task_list_spec.js
+++ b/spec/frontend/task_list_spec.js
@@ -121,7 +121,7 @@ describe('TaskList', () => {
});
describe('update', () => {
- it('should disable task list items and make a patch request then enable them again', (done) => {
+ it('should disable task list items and make a patch request then enable them again', () => {
const response = { data: { lock_version: 3 } };
jest.spyOn(taskList, 'enableTaskListItems').mockImplementation(() => {});
jest.spyOn(taskList, 'disableTaskListItems').mockImplementation(() => {});
@@ -156,20 +156,17 @@ describe('TaskList', () => {
expect(taskList.onUpdate).toHaveBeenCalled();
- update
- .then(() => {
- expect(taskList.disableTaskListItems).toHaveBeenCalledWith(event);
- expect(axios.patch).toHaveBeenCalledWith(endpoint, patchData);
- expect(taskList.enableTaskListItems).toHaveBeenCalledWith(event);
- expect(taskList.onSuccess).toHaveBeenCalledWith(response.data);
- expect(taskList.lockVersion).toEqual(response.data.lock_version);
- })
- .then(done)
- .catch(done.fail);
+ return update.then(() => {
+ expect(taskList.disableTaskListItems).toHaveBeenCalledWith(event);
+ expect(axios.patch).toHaveBeenCalledWith(endpoint, patchData);
+ expect(taskList.enableTaskListItems).toHaveBeenCalledWith(event);
+ expect(taskList.onSuccess).toHaveBeenCalledWith(response.data);
+ expect(taskList.lockVersion).toEqual(response.data.lock_version);
+ });
});
});
- it('should handle request error and enable task list items', (done) => {
+ it('should handle request error and enable task list items', () => {
const response = { data: { error: 1 } };
jest.spyOn(taskList, 'enableTaskListItems').mockImplementation(() => {});
jest.spyOn(taskList, 'onUpdate').mockImplementation(() => {});
@@ -182,12 +179,9 @@ describe('TaskList', () => {
expect(taskList.onUpdate).toHaveBeenCalled();
- update
- .then(() => {
- expect(taskList.enableTaskListItems).toHaveBeenCalledWith(event);
- expect(taskList.onError).toHaveBeenCalledWith(response.data);
- })
- .then(done)
- .catch(done.fail);
+ return update.then(() => {
+ expect(taskList.enableTaskListItems).toHaveBeenCalledWith(event);
+ expect(taskList.onError).toHaveBeenCalledWith(response.data);
+ });
});
});
diff --git a/spec/frontend/terraform/components/empty_state_spec.js b/spec/frontend/terraform/components/empty_state_spec.js
index b1303cf2b5e..21bfff5f1be 100644
--- a/spec/frontend/terraform/components/empty_state_spec.js
+++ b/spec/frontend/terraform/components/empty_state_spec.js
@@ -13,15 +13,20 @@ describe('EmptyStateComponent', () => {
const findLink = () => wrapper.findComponent(GlLink);
beforeEach(() => {
- wrapper = shallowMount(EmptyState, { propsData, stubs: { GlEmptyState, GlLink } });
+ wrapper = shallowMount(EmptyState, { propsData });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
});
it('should render content', () => {
- expect(findEmptyState().exists()).toBe(true);
- expect(wrapper.text()).toContain('Get started with Terraform');
+ expect(findEmptyState().props('title')).toBe(
+ "Your project doesn't have any Terraform state files",
+ );
});
- it('should have a link to the GitLab managed Terraform States docs', () => {
+ it('should have a link to the GitLab managed Terraform states docs', () => {
expect(findLink().attributes('href')).toBe(docsUrl);
});
});
diff --git a/spec/frontend/terraform/components/mock_data.js b/spec/frontend/terraform/components/mock_data.js
new file mode 100644
index 00000000000..f0109047d4c
--- /dev/null
+++ b/spec/frontend/terraform/components/mock_data.js
@@ -0,0 +1,35 @@
+export const getStatesResponse = {
+ data: {
+ project: {
+ id: 'project-1',
+ terraformStates: {
+ count: 1,
+ nodes: {
+ _showDetails: true,
+ errorMessages: [],
+ loadingLock: false,
+ loadingRemove: false,
+ id: 'state-1',
+ name: 'state',
+ lockedAt: '01-01-2022',
+ updatedAt: '01-01-2022',
+ lockedByUser: {
+ id: 'user-1',
+ avatarUrl: 'avatar',
+ name: 'User 1',
+ username: 'user-1',
+ webUrl: 'web',
+ },
+ latestVersion: null,
+ },
+ pageInfo: {
+ __typename: 'PageInfo',
+ hasNextPage: true,
+ hasPreviousPage: false,
+ startCursor: 'prev',
+ endCursor: 'next',
+ },
+ },
+ },
+ },
+};
diff --git a/spec/frontend/terraform/components/states_table_actions_spec.js b/spec/frontend/terraform/components/states_table_actions_spec.js
index a6c80b95af4..d01f6af9023 100644
--- a/spec/frontend/terraform/components/states_table_actions_spec.js
+++ b/spec/frontend/terraform/components/states_table_actions_spec.js
@@ -9,6 +9,8 @@ import StateActions from '~/terraform/components/states_table_actions.vue';
import lockStateMutation from '~/terraform/graphql/mutations/lock_state.mutation.graphql';
import removeStateMutation from '~/terraform/graphql/mutations/remove_state.mutation.graphql';
import unlockStateMutation from '~/terraform/graphql/mutations/unlock_state.mutation.graphql';
+import getStatesQuery from '~/terraform/graphql/queries/get_states.query.graphql';
+import { getStatesResponse } from './mock_data';
Vue.use(VueApollo);
@@ -49,6 +51,7 @@ describe('StatesTableActions', () => {
[lockStateMutation, lockResponse],
[removeStateMutation, removeResponse],
[unlockStateMutation, unlockResponse],
+ [getStatesQuery, jest.fn().mockResolvedValue(getStatesResponse)],
],
{
Mutation: {
diff --git a/spec/frontend/tracking/tracking_spec.js b/spec/frontend/tracking/tracking_spec.js
index d85299cdfc3..665bf44fc77 100644
--- a/spec/frontend/tracking/tracking_spec.js
+++ b/spec/frontend/tracking/tracking_spec.js
@@ -129,6 +129,72 @@ describe('Tracking', () => {
});
});
+ describe('.definition', () => {
+ const TEST_VALID_BASENAME = '202108302307_default_click_button';
+ const TEST_EVENT_DATA = { category: undefined, action: 'click_button' };
+ let eventSpy;
+ let dispatcherSpy;
+
+ beforeAll(() => {
+ Tracking.definitionsManifest = {
+ '202108302307_default_click_button': 'config/events/202108302307_default_click_button.yml',
+ };
+ });
+
+ beforeEach(() => {
+ eventSpy = jest.spyOn(Tracking, 'event');
+ dispatcherSpy = jest.spyOn(Tracking, 'dispatchFromDefinition');
+ });
+
+ it('throws an error if the definition does not exists', () => {
+ const basename = '20220230_default_missing_definition';
+ const expectedError = new Error(`Missing Snowplow event definition "${basename}"`);
+
+ expect(() => Tracking.definition(basename)).toThrow(expectedError);
+ });
+
+ it('dispatches an event from a definition present in the manifest', () => {
+ Tracking.definition(TEST_VALID_BASENAME);
+
+ expect(dispatcherSpy).toHaveBeenCalledWith(TEST_VALID_BASENAME, {});
+ });
+
+ it('push events to the queue if not loaded', () => {
+ Tracking.definitionsLoaded = false;
+ Tracking.definitionsEventsQueue = [];
+
+ const dispatched = Tracking.definition(TEST_VALID_BASENAME);
+
+ expect(dispatched).toBe(false);
+ expect(Tracking.definitionsEventsQueue[0]).toStrictEqual([TEST_VALID_BASENAME, {}]);
+ expect(eventSpy).not.toHaveBeenCalled();
+ });
+
+ it('dispatch events when the definition is loaded', () => {
+ const definition = { key: TEST_VALID_BASENAME, ...TEST_EVENT_DATA };
+ Tracking.definitions = [{ ...definition }];
+ Tracking.definitionsEventsQueue = [];
+ Tracking.definitionsLoaded = true;
+
+ const dispatched = Tracking.definition(TEST_VALID_BASENAME);
+
+ expect(dispatched).not.toBe(false);
+ expect(Tracking.definitionsEventsQueue).toEqual([]);
+ expect(eventSpy).toHaveBeenCalledWith(definition.category, definition.action, {});
+ });
+
+ it('lets defined event data takes precedence', () => {
+ const definition = { key: TEST_VALID_BASENAME, category: undefined, action: 'click_button' };
+ const eventData = { category: TEST_CATEGORY };
+ Tracking.definitions = [{ ...definition }];
+ Tracking.definitionsLoaded = true;
+
+ Tracking.definition(TEST_VALID_BASENAME, eventData);
+
+ expect(eventSpy).toHaveBeenCalledWith(TEST_CATEGORY, definition.action, eventData);
+ });
+ });
+
describe('.enableFormTracking', () => {
it('tells snowplow to enable form tracking, with only explicit contexts', () => {
const config = { forms: { allow: ['form-class1'] }, fields: { allow: ['input-class1'] } };
diff --git a/spec/frontend/user_lists/components/edit_user_list_spec.js b/spec/frontend/user_lists/components/edit_user_list_spec.js
index 7cafe5e1f56..941c8244247 100644
--- a/spec/frontend/user_lists/components/edit_user_list_spec.js
+++ b/spec/frontend/user_lists/components/edit_user_list_spec.js
@@ -8,7 +8,7 @@ import { redirectTo } from '~/lib/utils/url_utility';
import EditUserList from '~/user_lists/components/edit_user_list.vue';
import UserListForm from '~/user_lists/components/user_list_form.vue';
import createStore from '~/user_lists/store/edit';
-import { userList } from '../../feature_flags/mock_data';
+import { userList } from 'jest/feature_flags/mock_data';
jest.mock('~/api');
jest.mock('~/lib/utils/url_utility');
diff --git a/spec/frontend/user_lists/components/new_user_list_spec.js b/spec/frontend/user_lists/components/new_user_list_spec.js
index 5eb44970fe4..ace4a284347 100644
--- a/spec/frontend/user_lists/components/new_user_list_spec.js
+++ b/spec/frontend/user_lists/components/new_user_list_spec.js
@@ -7,7 +7,7 @@ import Api from '~/api';
import { redirectTo } from '~/lib/utils/url_utility';
import NewUserList from '~/user_lists/components/new_user_list.vue';
import createStore from '~/user_lists/store/new';
-import { userList } from '../../feature_flags/mock_data';
+import { userList } from 'jest/feature_flags/mock_data';
jest.mock('~/api');
jest.mock('~/lib/utils/url_utility');
diff --git a/spec/frontend/user_lists/components/user_list_form_spec.js b/spec/frontend/user_lists/components/user_list_form_spec.js
index 42f7659600e..e09d8eac32f 100644
--- a/spec/frontend/user_lists/components/user_list_form_spec.js
+++ b/spec/frontend/user_lists/components/user_list_form_spec.js
@@ -1,6 +1,6 @@
import { mount } from '@vue/test-utils';
import Form from '~/user_lists/components/user_list_form.vue';
-import { userList } from '../../feature_flags/mock_data';
+import { userList } from 'jest/feature_flags/mock_data';
describe('user_lists/components/user_list_form', () => {
let wrapper;
diff --git a/spec/frontend/user_lists/components/user_list_spec.js b/spec/frontend/user_lists/components/user_list_spec.js
index 88dad06938b..f126c733dd5 100644
--- a/spec/frontend/user_lists/components/user_list_spec.js
+++ b/spec/frontend/user_lists/components/user_list_spec.js
@@ -7,7 +7,7 @@ import Api from '~/api';
import UserList from '~/user_lists/components/user_list.vue';
import createStore from '~/user_lists/store/show';
import { parseUserIds, stringifyUserIds } from '~/user_lists/store/utils';
-import { userList } from '../../feature_flags/mock_data';
+import { userList } from 'jest/feature_flags/mock_data';
jest.mock('~/api');
diff --git a/spec/frontend/user_lists/components/user_lists_spec.js b/spec/frontend/user_lists/components/user_lists_spec.js
index 10742c029c1..161eb036361 100644
--- a/spec/frontend/user_lists/components/user_lists_spec.js
+++ b/spec/frontend/user_lists/components/user_lists_spec.js
@@ -9,7 +9,7 @@ import UserListsComponent from '~/user_lists/components/user_lists.vue';
import UserListsTable from '~/user_lists/components/user_lists_table.vue';
import createStore from '~/user_lists/store/index';
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
-import { userList } from '../../feature_flags/mock_data';
+import { userList } from 'jest/feature_flags/mock_data';
jest.mock('~/api');
diff --git a/spec/frontend/user_lists/components/user_lists_table_spec.js b/spec/frontend/user_lists/components/user_lists_table_spec.js
index 63587703392..08eb8ae0843 100644
--- a/spec/frontend/user_lists/components/user_lists_table_spec.js
+++ b/spec/frontend/user_lists/components/user_lists_table_spec.js
@@ -3,7 +3,7 @@ import { mount } from '@vue/test-utils';
import * as timeago from 'timeago.js';
import { nextTick } from 'vue';
import UserListsTable from '~/user_lists/components/user_lists_table.vue';
-import { userList } from '../../feature_flags/mock_data';
+import { userList } from 'jest/feature_flags/mock_data';
jest.mock('timeago.js', () => ({
format: jest.fn().mockReturnValue('2 weeks ago'),
diff --git a/spec/frontend/user_lists/store/edit/actions_spec.js b/spec/frontend/user_lists/store/edit/actions_spec.js
index c4b0f888d3e..ca56c935ea5 100644
--- a/spec/frontend/user_lists/store/edit/actions_spec.js
+++ b/spec/frontend/user_lists/store/edit/actions_spec.js
@@ -4,7 +4,7 @@ import { redirectTo } from '~/lib/utils/url_utility';
import * as actions from '~/user_lists/store/edit/actions';
import * as types from '~/user_lists/store/edit/mutation_types';
import createState from '~/user_lists/store/edit/state';
-import { userList } from '../../../feature_flags/mock_data';
+import { userList } from 'jest/feature_flags/mock_data';
jest.mock('~/api');
jest.mock('~/lib/utils/url_utility');
diff --git a/spec/frontend/user_lists/store/edit/mutations_spec.js b/spec/frontend/user_lists/store/edit/mutations_spec.js
index 0943c64e934..7971906429b 100644
--- a/spec/frontend/user_lists/store/edit/mutations_spec.js
+++ b/spec/frontend/user_lists/store/edit/mutations_spec.js
@@ -2,7 +2,7 @@ import statuses from '~/user_lists/constants/edit';
import * as types from '~/user_lists/store/edit/mutation_types';
import mutations from '~/user_lists/store/edit/mutations';
import createState from '~/user_lists/store/edit/state';
-import { userList } from '../../../feature_flags/mock_data';
+import { userList } from 'jest/feature_flags/mock_data';
describe('User List Edit Mutations', () => {
let state;
diff --git a/spec/frontend/user_lists/store/index/actions_spec.js b/spec/frontend/user_lists/store/index/actions_spec.js
index c5d7d557de9..4a8d0afb963 100644
--- a/spec/frontend/user_lists/store/index/actions_spec.js
+++ b/spec/frontend/user_lists/store/index/actions_spec.js
@@ -12,7 +12,7 @@ import {
} from '~/user_lists/store/index/actions';
import * as types from '~/user_lists/store/index/mutation_types';
import createState from '~/user_lists/store/index/state';
-import { userList } from '../../../feature_flags/mock_data';
+import { userList } from 'jest/feature_flags/mock_data';
jest.mock('~/api.js');
@@ -24,14 +24,13 @@ describe('~/user_lists/store/index/actions', () => {
});
describe('setUserListsOptions', () => {
- it('should commit SET_USER_LISTS_OPTIONS mutation', (done) => {
- testAction(
+ it('should commit SET_USER_LISTS_OPTIONS mutation', () => {
+ return testAction(
setUserListsOptions,
{ page: '1', scope: 'all' },
state,
[{ type: types.SET_USER_LISTS_OPTIONS, payload: { page: '1', scope: 'all' } }],
[],
- done,
);
});
});
@@ -42,8 +41,8 @@ describe('~/user_lists/store/index/actions', () => {
});
describe('success', () => {
- it('dispatches requestUserLists and receiveUserListsSuccess ', (done) => {
- testAction(
+ it('dispatches requestUserLists and receiveUserListsSuccess ', () => {
+ return testAction(
fetchUserLists,
null,
state,
@@ -57,16 +56,15 @@ describe('~/user_lists/store/index/actions', () => {
type: 'receiveUserListsSuccess',
},
],
- done,
);
});
});
describe('error', () => {
- it('dispatches requestUserLists and receiveUserListsError ', (done) => {
+ it('dispatches requestUserLists and receiveUserListsError ', () => {
Api.fetchFeatureFlagUserLists.mockRejectedValue();
- testAction(
+ return testAction(
fetchUserLists,
null,
state,
@@ -79,21 +77,20 @@ describe('~/user_lists/store/index/actions', () => {
type: 'receiveUserListsError',
},
],
- done,
);
});
});
});
describe('requestUserLists', () => {
- it('should commit RECEIVE_USER_LISTS_SUCCESS mutation', (done) => {
- testAction(requestUserLists, null, state, [{ type: types.REQUEST_USER_LISTS }], [], done);
+ it('should commit RECEIVE_USER_LISTS_SUCCESS mutation', () => {
+ return testAction(requestUserLists, null, state, [{ type: types.REQUEST_USER_LISTS }], []);
});
});
describe('receiveUserListsSuccess', () => {
- it('should commit RECEIVE_USER_LISTS_SUCCESS mutation', (done) => {
- testAction(
+ it('should commit RECEIVE_USER_LISTS_SUCCESS mutation', () => {
+ return testAction(
receiveUserListsSuccess,
{ data: [userList], headers: {} },
state,
@@ -104,20 +101,18 @@ describe('~/user_lists/store/index/actions', () => {
},
],
[],
- done,
);
});
});
describe('receiveUserListsError', () => {
- it('should commit RECEIVE_USER_LISTS_ERROR mutation', (done) => {
- testAction(
+ it('should commit RECEIVE_USER_LISTS_ERROR mutation', () => {
+ return testAction(
receiveUserListsError,
null,
state,
[{ type: types.RECEIVE_USER_LISTS_ERROR }],
[],
- done,
);
});
});
@@ -132,14 +127,13 @@ describe('~/user_lists/store/index/actions', () => {
Api.deleteFeatureFlagUserList.mockResolvedValue();
});
- it('should refresh the user lists', (done) => {
- testAction(
+ it('should refresh the user lists', () => {
+ return testAction(
deleteUserList,
userList,
state,
[],
[{ type: 'requestDeleteUserList', payload: userList }, { type: 'fetchUserLists' }],
- done,
);
});
});
@@ -149,8 +143,8 @@ describe('~/user_lists/store/index/actions', () => {
Api.deleteFeatureFlagUserList.mockRejectedValue({ response: { data: 'some error' } });
});
- it('should dispatch receiveDeleteUserListError', (done) => {
- testAction(
+ it('should dispatch receiveDeleteUserListError', () => {
+ return testAction(
deleteUserList,
userList,
state,
@@ -162,15 +156,14 @@ describe('~/user_lists/store/index/actions', () => {
payload: { list: userList, error: 'some error' },
},
],
- done,
);
});
});
});
describe('receiveDeleteUserListError', () => {
- it('should commit RECEIVE_DELETE_USER_LIST_ERROR with the given list', (done) => {
- testAction(
+ it('should commit RECEIVE_DELETE_USER_LIST_ERROR with the given list', () => {
+ return testAction(
receiveDeleteUserListError,
{ list: userList, error: 'mock error' },
state,
@@ -181,22 +174,20 @@ describe('~/user_lists/store/index/actions', () => {
},
],
[],
- done,
);
});
});
describe('clearAlert', () => {
- it('should commit RECEIVE_CLEAR_ALERT', (done) => {
+ it('should commit RECEIVE_CLEAR_ALERT', () => {
const alertIndex = 3;
- testAction(
+ return testAction(
clearAlert,
alertIndex,
state,
[{ type: 'RECEIVE_CLEAR_ALERT', payload: alertIndex }],
[],
- done,
);
});
});
diff --git a/spec/frontend/user_lists/store/index/mutations_spec.js b/spec/frontend/user_lists/store/index/mutations_spec.js
index 370838ae5fb..18d6a9b8f38 100644
--- a/spec/frontend/user_lists/store/index/mutations_spec.js
+++ b/spec/frontend/user_lists/store/index/mutations_spec.js
@@ -2,7 +2,7 @@ import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import * as types from '~/user_lists/store/index/mutation_types';
import mutations from '~/user_lists/store/index/mutations';
import createState from '~/user_lists/store/index/state';
-import { userList } from '../../../feature_flags/mock_data';
+import { userList } from 'jest/feature_flags/mock_data';
describe('~/user_lists/store/index/mutations', () => {
let state;
diff --git a/spec/frontend/user_lists/store/new/actions_spec.js b/spec/frontend/user_lists/store/new/actions_spec.js
index 916ec2e6da7..fa69fa7fa66 100644
--- a/spec/frontend/user_lists/store/new/actions_spec.js
+++ b/spec/frontend/user_lists/store/new/actions_spec.js
@@ -4,7 +4,7 @@ import { redirectTo } from '~/lib/utils/url_utility';
import * as actions from '~/user_lists/store/new/actions';
import * as types from '~/user_lists/store/new/mutation_types';
import createState from '~/user_lists/store/new/state';
-import { userList } from '../../../feature_flags/mock_data';
+import { userList } from 'jest/feature_flags/mock_data';
jest.mock('~/api');
jest.mock('~/lib/utils/url_utility');
diff --git a/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js b/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js
index 36850e623c7..4985417ad99 100644
--- a/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js
+++ b/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js
@@ -1,3 +1,4 @@
+import { nextTick } from 'vue';
import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import createFlash from '~/flash';
@@ -28,11 +29,6 @@ const testApprovals = () => ({
});
const testApprovalRulesResponse = () => ({ rules: [{ id: 2 }] });
-// For some reason, the `Promise.resolve()` needs to be deferred
-// or the timing doesn't work.
-const tick = () => Promise.resolve();
-const waitForTick = (done) => tick().then(done).catch(done.fail);
-
describe('MRWidget approvals', () => {
let wrapper;
let service;
@@ -105,7 +101,7 @@ describe('MRWidget approvals', () => {
// eslint-disable-next-line no-restricted-syntax
wrapper.setData({ fetchingApprovals: true });
- return tick().then(() => {
+ return nextTick().then(() => {
expect(wrapper.text()).toContain(FETCH_LOADING);
});
});
@@ -116,10 +112,10 @@ describe('MRWidget approvals', () => {
});
describe('when fetch approvals error', () => {
- beforeEach((done) => {
+ beforeEach(() => {
jest.spyOn(service, 'fetchApprovals').mockReturnValue(Promise.reject());
createComponent();
- waitForTick(done);
+ return nextTick();
});
it('still shows loading message', () => {
@@ -133,13 +129,13 @@ describe('MRWidget approvals', () => {
describe('action button', () => {
describe('when mr is closed', () => {
- beforeEach((done) => {
+ beforeEach(() => {
mr.isOpen = false;
mr.approvals.user_has_approved = false;
mr.approvals.user_can_approve = true;
createComponent();
- waitForTick(done);
+ return nextTick();
});
it('action is not rendered', () => {
@@ -148,12 +144,12 @@ describe('MRWidget approvals', () => {
});
describe('when user cannot approve', () => {
- beforeEach((done) => {
+ beforeEach(() => {
mr.approvals.user_has_approved = false;
mr.approvals.user_can_approve = false;
createComponent();
- waitForTick(done);
+ return nextTick();
});
it('action is not rendered', () => {
@@ -168,9 +164,9 @@ describe('MRWidget approvals', () => {
});
describe('and MR is unapproved', () => {
- beforeEach((done) => {
+ beforeEach(() => {
createComponent();
- waitForTick(done);
+ return nextTick();
});
it('approve action is rendered', () => {
@@ -188,10 +184,10 @@ describe('MRWidget approvals', () => {
});
describe('with no approvers', () => {
- beforeEach((done) => {
+ beforeEach(() => {
mr.approvals.approved_by = [];
createComponent();
- waitForTick(done);
+ return nextTick();
});
it('approve action (with inverted style) is rendered', () => {
@@ -204,10 +200,10 @@ describe('MRWidget approvals', () => {
});
describe('with approvers', () => {
- beforeEach((done) => {
+ beforeEach(() => {
mr.approvals.approved_by = [{ user: { id: 7 } }];
createComponent();
- waitForTick(done);
+ return nextTick();
});
it('approve additionally action is rendered', () => {
@@ -221,9 +217,9 @@ describe('MRWidget approvals', () => {
});
describe('when approve action is clicked', () => {
- beforeEach((done) => {
+ beforeEach(() => {
createComponent();
- waitForTick(done);
+ return nextTick();
});
it('shows loading icon', () => {
@@ -234,15 +230,15 @@ describe('MRWidget approvals', () => {
action.vm.$emit('click');
- return tick().then(() => {
+ return nextTick().then(() => {
expect(action.props('loading')).toBe(true);
});
});
describe('and after loading', () => {
- beforeEach((done) => {
+ beforeEach(() => {
findAction().vm.$emit('click');
- waitForTick(done);
+ return nextTick();
});
it('calls service approve', () => {
@@ -259,10 +255,10 @@ describe('MRWidget approvals', () => {
});
describe('and error', () => {
- beforeEach((done) => {
+ beforeEach(() => {
jest.spyOn(service, 'approveMergeRequest').mockReturnValue(Promise.reject());
findAction().vm.$emit('click');
- waitForTick(done);
+ return nextTick();
});
it('flashes error message', () => {
@@ -273,12 +269,12 @@ describe('MRWidget approvals', () => {
});
describe('when user has approved', () => {
- beforeEach((done) => {
+ beforeEach(() => {
mr.approvals.user_has_approved = true;
mr.approvals.user_can_approve = false;
createComponent();
- waitForTick(done);
+ return nextTick();
});
it('revoke action is rendered', () => {
@@ -291,9 +287,9 @@ describe('MRWidget approvals', () => {
describe('when revoke action is clicked', () => {
describe('and successful', () => {
- beforeEach((done) => {
+ beforeEach(() => {
findAction().vm.$emit('click');
- waitForTick(done);
+ return nextTick();
});
it('calls service unapprove', () => {
@@ -310,10 +306,10 @@ describe('MRWidget approvals', () => {
});
describe('and error', () => {
- beforeEach((done) => {
+ beforeEach(() => {
jest.spyOn(service, 'unapproveMergeRequest').mockReturnValue(Promise.reject());
findAction().vm.$emit('click');
- waitForTick(done);
+ return nextTick();
});
it('flashes error message', () => {
@@ -333,11 +329,11 @@ describe('MRWidget approvals', () => {
});
describe('and can approve', () => {
- beforeEach((done) => {
+ beforeEach(() => {
mr.approvals.user_can_approve = true;
createComponent();
- waitForTick(done);
+ return nextTick();
});
it('is shown', () => {
@@ -350,11 +346,11 @@ describe('MRWidget approvals', () => {
});
describe('and cannot approve', () => {
- beforeEach((done) => {
+ beforeEach(() => {
mr.approvals.user_can_approve = false;
createComponent();
- waitForTick(done);
+ return nextTick();
});
it('is shown', () => {
@@ -369,9 +365,9 @@ describe('MRWidget approvals', () => {
});
describe('approvals summary', () => {
- beforeEach((done) => {
+ beforeEach(() => {
createComponent();
- waitForTick(done);
+ return nextTick();
});
it('is rendered with props', () => {
diff --git a/spec/frontend/vue_mr_widget/components/extensions/utils_spec.js b/spec/frontend/vue_mr_widget/components/extensions/utils_spec.js
index 64e802c4fa5..98cfc04eb25 100644
--- a/spec/frontend/vue_mr_widget/components/extensions/utils_spec.js
+++ b/spec/frontend/vue_mr_widget/components/extensions/utils_spec.js
@@ -8,7 +8,7 @@ describe('generateText', () => {
${'%{danger_start}Hello world%{danger_end}'} | ${'<span class="gl-font-weight-bold gl-text-red-500">Hello world</span>'}
${'%{critical_start}Hello world%{critical_end}'} | ${'<span class="gl-font-weight-bold gl-text-red-800">Hello world</span>'}
${'%{same_start}Hello world%{same_end}'} | ${'<span class="gl-font-weight-bold gl-text-gray-700">Hello world</span>'}
- ${'%{small_start}Hello world%{small_end}'} | ${'<span class="gl-font-sm">Hello world</span>'}
+ ${'%{small_start}Hello world%{small_end}'} | ${'<span class="gl-font-sm gl-text-gray-700">Hello world</span>'}
${'%{strong_start}%{danger_start}Hello world%{danger_end}%{strong_end}'} | ${'<span class="gl-font-weight-bold"><span class="gl-font-weight-bold gl-text-red-500">Hello world</span></span>'}
${'%{no_exist_start}Hello world%{no_exist_end}'} | ${'Hello world'}
${['array']} | ${null}
diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_memory_usage_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_memory_usage_spec.js
index c0a30a5093d..f0106914674 100644
--- a/spec/frontend/vue_mr_widget/components/mr_widget_memory_usage_spec.js
+++ b/spec/frontend/vue_mr_widget/components/mr_widget_memory_usage_spec.js
@@ -175,22 +175,19 @@ describe('MemoryUsage', () => {
expect(el.querySelector('.js-usage-info')).toBeDefined();
});
- it('should show loading metrics message while metrics are being loaded', (done) => {
+ it('should show loading metrics message while metrics are being loaded', async () => {
vm.loadingMetrics = true;
vm.hasMetrics = false;
vm.loadFailed = false;
- nextTick(() => {
- expect(el.querySelector('.js-usage-info.usage-info-loading')).toBeDefined();
+ await nextTick();
- expect(el.querySelector('.js-usage-info .usage-info-load-spinner')).toBeDefined();
-
- expect(el.querySelector('.js-usage-info').innerText).toContain(messages.loadingMetrics);
- done();
- });
+ expect(el.querySelector('.js-usage-info.usage-info-loading')).toBeDefined();
+ expect(el.querySelector('.js-usage-info .usage-info-load-spinner')).toBeDefined();
+ expect(el.querySelector('.js-usage-info').innerText).toContain(messages.loadingMetrics);
});
- it('should show deployment memory usage when metrics are loaded', (done) => {
+ it('should show deployment memory usage when metrics are loaded', async () => {
// ignore BoostrapVue warnings
jest.spyOn(console, 'warn').mockImplementation();
@@ -199,37 +196,32 @@ describe('MemoryUsage', () => {
vm.loadFailed = false;
vm.memoryMetrics = metricsMockData.metrics.memory_values[0].values;
- nextTick(() => {
- expect(el.querySelector('.memory-graph-container')).toBeDefined();
- expect(el.querySelector('.js-usage-info').innerText).toContain(messages.hasMetrics);
- done();
- });
+ await nextTick();
+
+ expect(el.querySelector('.memory-graph-container')).toBeDefined();
+ expect(el.querySelector('.js-usage-info').innerText).toContain(messages.hasMetrics);
});
- it('should show failure message when metrics loading failed', (done) => {
+ it('should show failure message when metrics loading failed', async () => {
vm.loadingMetrics = false;
vm.hasMetrics = false;
vm.loadFailed = true;
- nextTick(() => {
- expect(el.querySelector('.js-usage-info.usage-info-failed')).toBeDefined();
+ await nextTick();
- expect(el.querySelector('.js-usage-info').innerText).toContain(messages.loadFailed);
- done();
- });
+ expect(el.querySelector('.js-usage-info.usage-info-failed')).toBeDefined();
+ expect(el.querySelector('.js-usage-info').innerText).toContain(messages.loadFailed);
});
- it('should show metrics unavailable message when metrics loading failed', (done) => {
+ it('should show metrics unavailable message when metrics loading failed', async () => {
vm.loadingMetrics = false;
vm.hasMetrics = false;
vm.loadFailed = false;
- nextTick(() => {
- expect(el.querySelector('.js-usage-info.usage-info-unavailable')).toBeDefined();
+ await nextTick();
- expect(el.querySelector('.js-usage-info').innerText).toContain(messages.metricsUnavailable);
- done();
- });
+ expect(el.querySelector('.js-usage-info.usage-info-unavailable')).toBeDefined();
+ expect(el.querySelector('.js-usage-info').innerText).toContain(messages.metricsUnavailable);
});
});
});
diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js
index 7d86e453bc7..8efc4d84624 100644
--- a/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js
+++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js
@@ -198,14 +198,13 @@ describe('MRWidgetMerged', () => {
);
});
- it('hides button to copy commit SHA if SHA does not exist', (done) => {
+ it('hides button to copy commit SHA if SHA does not exist', async () => {
vm.mr.mergeCommitSha = null;
- nextTick(() => {
- expect(selectors.copyMergeShaButton).toBe(null);
- expect(vm.$el.querySelector('.mr-info-list').innerText).not.toContain('with');
- done();
- });
+ await nextTick();
+
+ expect(selectors.copyMergeShaButton).toBe(null);
+ expect(vm.$el.querySelector('.mr-info-list').innerText).not.toContain('with');
});
it('shows merge commit SHA link', () => {
@@ -214,24 +213,22 @@ describe('MRWidgetMerged', () => {
expect(selectors.mergeCommitShaLink.href).toBe(vm.mr.mergeCommitPath);
});
- it('should not show source branch deleted text', (done) => {
+ it('should not show source branch deleted text', async () => {
vm.mr.sourceBranchRemoved = false;
- nextTick(() => {
- expect(vm.$el.innerText).not.toContain('The source branch has been deleted');
- done();
- });
+ await nextTick();
+
+ expect(vm.$el.innerText).not.toContain('The source branch has been deleted');
});
- it('should show source branch deleting text', (done) => {
+ it('should show source branch deleting text', async () => {
vm.mr.isRemovingSourceBranch = true;
vm.mr.sourceBranchRemoved = false;
- nextTick(() => {
- expect(vm.$el.innerText).toContain('The source branch is being deleted');
- expect(vm.$el.innerText).not.toContain('The source branch has been deleted');
- done();
- });
+ await nextTick();
+
+ expect(vm.$el.innerText).toContain('The source branch is being deleted');
+ expect(vm.$el.innerText).not.toContain('The source branch has been deleted');
});
it('should use mergedEvent mergedAt as tooltip title', () => {
diff --git a/spec/frontend/vue_mr_widget/extensions/test_report/index_spec.js b/spec/frontend/vue_mr_widget/extensions/test_report/index_spec.js
new file mode 100644
index 00000000000..88b8e32bd5d
--- /dev/null
+++ b/spec/frontend/vue_mr_widget/extensions/test_report/index_spec.js
@@ -0,0 +1,149 @@
+import { GlButton } from '@gitlab/ui';
+import MockAdapter from 'axios-mock-adapter';
+import testReportExtension from '~/vue_merge_request_widget/extensions/test_report';
+import { i18n } from '~/vue_merge_request_widget/extensions/test_report/constants';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import { trimText } from 'helpers/text_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import axios from '~/lib/utils/axios_utils';
+import extensionsContainer from '~/vue_merge_request_widget/components/extensions/container';
+import { registerExtension } from '~/vue_merge_request_widget/components/extensions';
+import httpStatusCodes from '~/lib/utils/http_status';
+
+import { failedReport } from 'jest/reports/mock_data/mock_data';
+import mixedResultsTestReports from 'jest/reports/mock_data/new_and_fixed_failures_report.json';
+import newErrorsTestReports from 'jest/reports/mock_data/new_errors_report.json';
+import newFailedTestReports from 'jest/reports/mock_data/new_failures_report.json';
+import successTestReports from 'jest/reports/mock_data/no_failures_report.json';
+import resolvedFailures from 'jest/reports/mock_data/resolved_failures.json';
+
+const reportWithParsingErrors = failedReport;
+reportWithParsingErrors.suites[0].suite_errors = {
+ head: 'JUnit XML parsing failed: 2:24: FATAL: attributes construct error',
+ base: 'JUnit data parsing failed: string not matched',
+};
+
+describe('Test report extension', () => {
+ let wrapper;
+ let mock;
+
+ registerExtension(testReportExtension);
+
+ const endpoint = '/root/repo/-/merge_requests/4/test_reports.json';
+
+ const mockApi = (statusCode, data = mixedResultsTestReports) => {
+ mock.onGet(endpoint).reply(statusCode, data);
+ };
+
+ const findToggleCollapsedButton = () => wrapper.findByTestId('toggle-button');
+ const findTertiaryButton = () => wrapper.find(GlButton);
+ const findAllExtensionListItems = () => wrapper.findAllByTestId('extension-list-item');
+
+ const createComponent = () => {
+ wrapper = mountExtended(extensionsContainer, {
+ propsData: {
+ mr: {
+ testResultsPath: endpoint,
+ headBlobPath: 'head/blob/path',
+ pipeline: { path: 'pipeline/path' },
+ },
+ },
+ });
+ };
+
+ const createExpandedWidgetWithData = async (data = mixedResultsTestReports) => {
+ mockApi(httpStatusCodes.OK, data);
+ createComponent();
+ await waitForPromises();
+ findToggleCollapsedButton().trigger('click');
+ await waitForPromises();
+ };
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ mock.restore();
+ });
+
+ describe('summary', () => {
+ it('displays loading text', () => {
+ mockApi(httpStatusCodes.OK);
+ createComponent();
+
+ expect(wrapper.text()).toContain(i18n.loading);
+ });
+
+ it('displays failed loading text', async () => {
+ mockApi(httpStatusCodes.INTERNAL_SERVER_ERROR);
+ createComponent();
+
+ await waitForPromises();
+
+ expect(wrapper.text()).toContain(i18n.error);
+ });
+
+ it.each`
+ description | mockData | expectedResult
+ ${'mixed test results'} | ${mixedResultsTestReports} | ${'Test summary: 2 failed and 2 fixed test results, 11 total tests'}
+ ${'unchanged test results'} | ${successTestReports} | ${'Test summary: no changed test results, 11 total tests'}
+ ${'tests with errors'} | ${newErrorsTestReports} | ${'Test summary: 2 errors, 11 total tests'}
+ ${'failed test results'} | ${newFailedTestReports} | ${'Test summary: 2 failed, 11 total tests'}
+ ${'resolved failures'} | ${resolvedFailures} | ${'Test summary: 4 fixed test results, 11 total tests'}
+ `('displays summary text for $description', async ({ mockData, expectedResult }) => {
+ mockApi(httpStatusCodes.OK, mockData);
+ createComponent();
+
+ await waitForPromises();
+
+ expect(wrapper.text()).toContain(expectedResult);
+ });
+
+ it('displays a link to the full report', async () => {
+ mockApi(httpStatusCodes.OK);
+ createComponent();
+
+ await waitForPromises();
+
+ expect(findTertiaryButton().text()).toBe('Full report');
+ expect(findTertiaryButton().attributes('href')).toBe('pipeline/path/test_report');
+ });
+
+ it('shows an error when a suite has a parsing error', async () => {
+ mockApi(httpStatusCodes.OK, reportWithParsingErrors);
+ createComponent();
+
+ await waitForPromises();
+
+ expect(wrapper.text()).toContain(i18n.error);
+ });
+ });
+
+ describe('expanded data', () => {
+ it('displays summary for each suite', async () => {
+ await createExpandedWidgetWithData();
+
+ expect(trimText(findAllExtensionListItems().at(0).text())).toBe(
+ 'rspec:pg: 1 failed and 2 fixed test results, 8 total tests',
+ );
+ expect(trimText(findAllExtensionListItems().at(1).text())).toBe(
+ 'java ant: 1 failed, 3 total tests',
+ );
+ });
+
+ it('displays suite parsing errors', async () => {
+ await createExpandedWidgetWithData(reportWithParsingErrors);
+
+ const suiteText = trimText(findAllExtensionListItems().at(0).text());
+
+ expect(suiteText).toContain(
+ 'Head report parsing error: JUnit XML parsing failed: 2:24: FATAL: attributes construct error',
+ );
+ expect(suiteText).toContain(
+ 'Base report parsing error: JUnit data parsing failed: string not matched',
+ );
+ });
+ });
+});
diff --git a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js
index 0540107ea5f..9719e81fe12 100644
--- a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js
+++ b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js
@@ -46,6 +46,8 @@ describe('MrWidgetOptions', () => {
let mock;
const COLLABORATION_MESSAGE = 'Members who can merge are allowed to add commits';
+ const findExtensionToggleButton = () =>
+ wrapper.find('[data-testid="widget-extension"] [data-testid="toggle-button"]');
beforeEach(() => {
gl.mrWidgetData = { ...mockData };
@@ -187,9 +189,9 @@ describe('MrWidgetOptions', () => {
});
describe('when merge request is opened', () => {
- beforeEach((done) => {
+ beforeEach(() => {
wrapper.vm.mr.isOpen = true;
- nextTick(done);
+ return nextTick();
});
it('should render collaboration status', () => {
@@ -198,9 +200,9 @@ describe('MrWidgetOptions', () => {
});
describe('when merge request is not opened', () => {
- beforeEach((done) => {
+ beforeEach(() => {
wrapper.vm.mr.isOpen = false;
- nextTick(done);
+ return nextTick();
});
it('should not render collaboration status', () => {
@@ -215,9 +217,9 @@ describe('MrWidgetOptions', () => {
});
describe('when merge request is opened', () => {
- beforeEach((done) => {
+ beforeEach(() => {
wrapper.vm.mr.isOpen = true;
- nextTick(done);
+ return nextTick();
});
it('should not render collaboration status', () => {
@@ -229,11 +231,11 @@ describe('MrWidgetOptions', () => {
describe('showMergePipelineForkWarning', () => {
describe('when the source project and target project are the same', () => {
- beforeEach((done) => {
+ beforeEach(() => {
Vue.set(wrapper.vm.mr, 'mergePipelinesEnabled', true);
Vue.set(wrapper.vm.mr, 'sourceProjectId', 1);
Vue.set(wrapper.vm.mr, 'targetProjectId', 1);
- nextTick(done);
+ return nextTick();
});
it('should be false', () => {
@@ -242,11 +244,11 @@ describe('MrWidgetOptions', () => {
});
describe('when merge pipelines are not enabled', () => {
- beforeEach((done) => {
+ beforeEach(() => {
Vue.set(wrapper.vm.mr, 'mergePipelinesEnabled', false);
Vue.set(wrapper.vm.mr, 'sourceProjectId', 1);
Vue.set(wrapper.vm.mr, 'targetProjectId', 2);
- nextTick(done);
+ return nextTick();
});
it('should be false', () => {
@@ -255,11 +257,11 @@ describe('MrWidgetOptions', () => {
});
describe('when merge pipelines are enabled _and_ the source project and target project are different', () => {
- beforeEach((done) => {
+ beforeEach(() => {
Vue.set(wrapper.vm.mr, 'mergePipelinesEnabled', true);
Vue.set(wrapper.vm.mr, 'sourceProjectId', 1);
Vue.set(wrapper.vm.mr, 'targetProjectId', 2);
- nextTick(done);
+ return nextTick();
});
it('should be true', () => {
@@ -439,15 +441,10 @@ describe('MrWidgetOptions', () => {
expect(setFaviconOverlay).toHaveBeenCalledWith(overlayDataUrl);
});
- it('should not call setFavicon when there is no ciStatusFaviconPath', (done) => {
+ it('should not call setFavicon when there is no ciStatusFaviconPath', async () => {
wrapper.vm.mr.ciStatusFaviconPath = null;
- wrapper.vm
- .setFaviconHelper()
- .then(() => {
- expect(faviconElement.getAttribute('href')).toEqual(null);
- done();
- })
- .catch(done.fail);
+ await wrapper.vm.setFaviconHelper();
+ expect(faviconElement.getAttribute('href')).toEqual(null);
});
});
@@ -534,44 +531,36 @@ describe('MrWidgetOptions', () => {
expect(wrapper.find('.close-related-link').exists()).toBe(true);
});
- it('does not render if state is nothingToMerge', (done) => {
+ it('does not render if state is nothingToMerge', async () => {
wrapper.vm.mr.state = stateKey.nothingToMerge;
- nextTick(() => {
- expect(wrapper.find('.close-related-link').exists()).toBe(false);
- done();
- });
+ await nextTick();
+ expect(wrapper.find('.close-related-link').exists()).toBe(false);
});
});
describe('rendering source branch removal status', () => {
- it('renders when user cannot remove branch and branch should be removed', (done) => {
+ it('renders when user cannot remove branch and branch should be removed', async () => {
wrapper.vm.mr.canRemoveSourceBranch = false;
wrapper.vm.mr.shouldRemoveSourceBranch = true;
wrapper.vm.mr.state = 'readyToMerge';
- nextTick(() => {
- const tooltip = wrapper.find('[data-testid="question-o-icon"]');
-
- expect(wrapper.text()).toContain('Deletes the source branch');
- expect(tooltip.attributes('title')).toBe(
- 'A user with write access to the source branch selected this option',
- );
+ await nextTick();
+ const tooltip = wrapper.find('[data-testid="question-o-icon"]');
- done();
- });
+ expect(wrapper.text()).toContain('Deletes the source branch');
+ expect(tooltip.attributes('title')).toBe(
+ 'A user with write access to the source branch selected this option',
+ );
});
- it('does not render in merged state', (done) => {
+ it('does not render in merged state', async () => {
wrapper.vm.mr.canRemoveSourceBranch = false;
wrapper.vm.mr.shouldRemoveSourceBranch = true;
wrapper.vm.mr.state = 'merged';
- nextTick(() => {
- expect(wrapper.text()).toContain('The source branch has been deleted');
- expect(wrapper.text()).not.toContain('Deletes the source branch');
-
- done();
- });
+ await nextTick();
+ expect(wrapper.text()).toContain('The source branch has been deleted');
+ expect(wrapper.text()).not.toContain('Deletes the source branch');
});
});
@@ -605,7 +594,7 @@ describe('MrWidgetOptions', () => {
status: SUCCESS,
};
- beforeEach((done) => {
+ beforeEach(() => {
wrapper.vm.mr.deployments.push(
{
...deploymentMockData,
@@ -616,7 +605,7 @@ describe('MrWidgetOptions', () => {
},
);
- nextTick(done);
+ return nextTick();
});
it('renders multiple deployments', () => {
@@ -639,7 +628,7 @@ describe('MrWidgetOptions', () => {
describe('pipeline for target branch after merge', () => {
describe('with information for target branch pipeline', () => {
- beforeEach((done) => {
+ beforeEach(() => {
wrapper.vm.mr.state = 'merged';
wrapper.vm.mr.mergePipeline = {
id: 127,
@@ -747,7 +736,7 @@ describe('MrWidgetOptions', () => {
},
cancel_path: '/root/ci-web-terminal/pipelines/127/cancel',
};
- nextTick(done);
+ return nextTick();
});
it('renders pipeline block', () => {
@@ -755,7 +744,7 @@ describe('MrWidgetOptions', () => {
});
describe('with post merge deployments', () => {
- beforeEach((done) => {
+ beforeEach(() => {
wrapper.vm.mr.postMergeDeployments = [
{
id: 15,
@@ -787,7 +776,7 @@ describe('MrWidgetOptions', () => {
},
];
- nextTick(done);
+ return nextTick();
});
it('renders post deployment information', () => {
@@ -797,10 +786,10 @@ describe('MrWidgetOptions', () => {
});
describe('without information for target branch pipeline', () => {
- beforeEach((done) => {
+ beforeEach(() => {
wrapper.vm.mr.state = 'merged';
- nextTick(done);
+ return nextTick();
});
it('does not render pipeline block', () => {
@@ -809,10 +798,10 @@ describe('MrWidgetOptions', () => {
});
describe('when state is not merged', () => {
- beforeEach((done) => {
+ beforeEach(() => {
wrapper.vm.mr.state = 'archived';
- nextTick(done);
+ return nextTick();
});
it('does not render pipeline block', () => {
@@ -905,7 +894,7 @@ describe('MrWidgetOptions', () => {
beforeEach(() => {
pollRequest = jest.spyOn(Poll.prototype, 'makeRequest');
- registerExtension(workingExtension);
+ registerExtension(workingExtension());
createComponent();
});
@@ -937,9 +926,7 @@ describe('MrWidgetOptions', () => {
it('renders full data', async () => {
await waitForPromises();
- wrapper
- .find('[data-testid="widget-extension"] [data-testid="toggle-button"]')
- .trigger('click');
+ findExtensionToggleButton().trigger('click');
await nextTick();
@@ -975,6 +962,24 @@ describe('MrWidgetOptions', () => {
});
});
+ describe('expansion', () => {
+ it('hides collapse button', async () => {
+ registerExtension(workingExtension(false));
+ createComponent();
+ await waitForPromises();
+
+ expect(findExtensionToggleButton().exists()).toBe(false);
+ });
+
+ it('shows collapse button', async () => {
+ registerExtension(workingExtension(true));
+ createComponent();
+ await waitForPromises();
+
+ expect(findExtensionToggleButton().exists()).toBe(true);
+ });
+ });
+
describe('mock polling extension', () => {
let pollRequest;
let pollStop;
@@ -1025,7 +1030,7 @@ describe('MrWidgetOptions', () => {
it('captures sentry error and displays error when poll has failed', () => {
expect(captureException).toHaveBeenCalledTimes(1);
expect(captureException).toHaveBeenCalledWith(new Error('Fetch error'));
- expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('error');
+ expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('failed');
});
});
});
@@ -1036,7 +1041,7 @@ describe('MrWidgetOptions', () => {
const itHandlesTheException = () => {
expect(captureException).toHaveBeenCalledTimes(1);
expect(captureException).toHaveBeenCalledWith(new Error('Fetch error'));
- expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('error');
+ expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('failed');
};
beforeEach(() => {
diff --git a/spec/frontend/vue_mr_widget/stores/artifacts_list/actions_spec.js b/spec/frontend/vue_mr_widget/stores/artifacts_list/actions_spec.js
index 9423fa17c44..22562bb4ddb 100644
--- a/spec/frontend/vue_mr_widget/stores/artifacts_list/actions_spec.js
+++ b/spec/frontend/vue_mr_widget/stores/artifacts_list/actions_spec.js
@@ -22,27 +22,25 @@ describe('Artifacts App Store Actions', () => {
});
describe('setEndpoint', () => {
- it('should commit SET_ENDPOINT mutation', (done) => {
- testAction(
+ it('should commit SET_ENDPOINT mutation', () => {
+ return testAction(
setEndpoint,
'endpoint.json',
mockedState,
[{ type: types.SET_ENDPOINT, payload: 'endpoint.json' }],
[],
- done,
);
});
});
describe('requestArtifacts', () => {
- it('should commit REQUEST_ARTIFACTS mutation', (done) => {
- testAction(
+ it('should commit REQUEST_ARTIFACTS mutation', () => {
+ return testAction(
requestArtifacts,
null,
mockedState,
[{ type: types.REQUEST_ARTIFACTS }],
[],
- done,
);
});
});
@@ -62,7 +60,7 @@ describe('Artifacts App Store Actions', () => {
});
describe('success', () => {
- it('dispatches requestArtifacts and receiveArtifactsSuccess ', (done) => {
+ it('dispatches requestArtifacts and receiveArtifactsSuccess ', () => {
mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, [
{
text: 'result.txt',
@@ -72,7 +70,7 @@ describe('Artifacts App Store Actions', () => {
},
]);
- testAction(
+ return testAction(
fetchArtifacts,
null,
mockedState,
@@ -96,7 +94,6 @@ describe('Artifacts App Store Actions', () => {
type: 'receiveArtifactsSuccess',
},
],
- done,
);
});
});
@@ -106,8 +103,8 @@ describe('Artifacts App Store Actions', () => {
mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500);
});
- it('dispatches requestArtifacts and receiveArtifactsError ', (done) => {
- testAction(
+ it('dispatches requestArtifacts and receiveArtifactsError ', () => {
+ return testAction(
fetchArtifacts,
null,
mockedState,
@@ -120,45 +117,41 @@ describe('Artifacts App Store Actions', () => {
type: 'receiveArtifactsError',
},
],
- done,
);
});
});
});
describe('receiveArtifactsSuccess', () => {
- it('should commit RECEIVE_ARTIFACTS_SUCCESS mutation with 200', (done) => {
- testAction(
+ it('should commit RECEIVE_ARTIFACTS_SUCCESS mutation with 200', () => {
+ return testAction(
receiveArtifactsSuccess,
{ data: { summary: {} }, status: 200 },
mockedState,
[{ type: types.RECEIVE_ARTIFACTS_SUCCESS, payload: { summary: {} } }],
[],
- done,
);
});
- it('should not commit RECEIVE_ARTIFACTS_SUCCESS mutation with 204', (done) => {
- testAction(
+ it('should not commit RECEIVE_ARTIFACTS_SUCCESS mutation with 204', () => {
+ return testAction(
receiveArtifactsSuccess,
{ data: { summary: {} }, status: 204 },
mockedState,
[],
[],
- done,
);
});
});
describe('receiveArtifactsError', () => {
- it('should commit RECEIVE_ARTIFACTS_ERROR mutation', (done) => {
- testAction(
+ it('should commit RECEIVE_ARTIFACTS_ERROR mutation', () => {
+ return testAction(
receiveArtifactsError,
null,
mockedState,
[{ type: types.RECEIVE_ARTIFACTS_ERROR }],
[],
- done,
);
});
});
diff --git a/spec/frontend/vue_mr_widget/test_extensions.js b/spec/frontend/vue_mr_widget/test_extensions.js
index 986c1d6545a..6344636873f 100644
--- a/spec/frontend/vue_mr_widget/test_extensions.js
+++ b/spec/frontend/vue_mr_widget/test_extensions.js
@@ -1,6 +1,6 @@
import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants';
-export const workingExtension = {
+export const workingExtension = (shouldCollapse = true) => ({
name: 'WidgetTestExtension',
props: ['targetProjectFullPath'],
expandEvent: 'test_expand_event',
@@ -11,6 +11,9 @@ export const workingExtension = {
statusIcon({ count }) {
return count > 0 ? EXTENSION_ICONS.warning : EXTENSION_ICONS.success;
},
+ shouldCollapse() {
+ return shouldCollapse;
+ },
},
methods: {
fetchCollapsedData({ targetProjectFullPath }) {
@@ -36,7 +39,7 @@ export const workingExtension = {
]);
},
},
-};
+});
export const collapsedDataErrorExtension = {
name: 'WidgetTestCollapsedErrorExtension',
@@ -99,7 +102,7 @@ export const fullDataErrorExtension = {
};
export const pollingExtension = {
- ...workingExtension,
+ ...workingExtension(),
enablePolling: true,
};
diff --git a/spec/frontend/vue_shared/alert_details/alert_details_spec.js b/spec/frontend/vue_shared/alert_details/alert_details_spec.js
index 7ee6e29e6de..7aa54a1c55a 100644
--- a/spec/frontend/vue_shared/alert_details/alert_details_spec.js
+++ b/spec/frontend/vue_shared/alert_details/alert_details_spec.js
@@ -12,12 +12,17 @@ import AlertSummaryRow from '~/vue_shared/alert_details/components/alert_summary
import { PAGE_CONFIG, SEVERITY_LEVELS } from '~/vue_shared/alert_details/constants';
import createIssueMutation from '~/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
+import MetricImagesTab from '~/vue_shared/components/metric_images/metric_images_tab.vue';
+import createStore from '~/vue_shared/components/metric_images/store/';
+import service from '~/vue_shared/alert_details/service';
import mockAlerts from './mocks/alerts.json';
const mockAlert = mockAlerts[0];
const environmentName = 'Production';
const environmentPath = '/fake/path';
+jest.mock('~/vue_shared/alert_details/service');
+
describe('AlertDetails', () => {
let environmentData = { name: environmentName, path: environmentPath };
let mock;
@@ -67,9 +72,11 @@ describe('AlertDetails', () => {
$route: { params: {} },
},
stubs: {
- ...stubs,
AlertSummaryRow,
+ 'metric-images-tab': true,
+ ...stubs,
},
+ store: createStore({}, service),
}),
);
}
@@ -91,7 +98,7 @@ describe('AlertDetails', () => {
const findEnvironmentName = () => wrapper.findByTestId('environmentName');
const findEnvironmentPath = () => wrapper.findByTestId('environmentPath');
const findDetailsTable = () => wrapper.findComponent(AlertDetailsTable);
- const findMetricsTab = () => wrapper.findByTestId('metrics');
+ const findMetricsTab = () => wrapper.findComponent(MetricImagesTab);
describe('Alert details', () => {
describe('when alert is null', () => {
@@ -129,8 +136,21 @@ describe('AlertDetails', () => {
expect(wrapper.findByTestId('startTimeItem').exists()).toBe(true);
expect(wrapper.findByTestId('startTimeItem').props('time')).toBe(mockAlert.startedAt);
});
+ });
+
+ describe('Metrics tab', () => {
+ it('should mount without errors', () => {
+ mountComponent({
+ mountMethod: mount,
+ provide: {
+ canUpdate: true,
+ iid: '1',
+ },
+ stubs: {
+ MetricImagesTab,
+ },
+ });
- it('renders the metrics tab', () => {
expect(findMetricsTab().exists()).toBe(true);
});
});
@@ -312,7 +332,9 @@ describe('AlertDetails', () => {
describe('header', () => {
const findHeader = () => wrapper.findByTestId('alert-header');
- const stubs = { TimeAgoTooltip: { template: '<span>now</span>' } };
+ const stubs = {
+ TimeAgoTooltip: { template: '<span>now</span>' },
+ };
describe('individual header fields', () => {
describe.each`
diff --git a/spec/frontend/vue_shared/alert_details/service_spec.js b/spec/frontend/vue_shared/alert_details/service_spec.js
new file mode 100644
index 00000000000..790854d0ca7
--- /dev/null
+++ b/spec/frontend/vue_shared/alert_details/service_spec.js
@@ -0,0 +1,44 @@
+import { fileList, fileListRaw } from 'jest/vue_shared/components/metric_images/mock_data';
+import {
+ getMetricImages,
+ uploadMetricImage,
+ updateMetricImage,
+ deleteMetricImage,
+} from '~/vue_shared/alert_details/service';
+import * as alertManagementAlertsApi from '~/api/alert_management_alerts_api';
+
+jest.mock('~/api/alert_management_alerts_api');
+
+describe('Alert details service', () => {
+ it('fetches metric images', async () => {
+ alertManagementAlertsApi.fetchAlertMetricImages.mockResolvedValue({ data: fileListRaw });
+ const result = await getMetricImages();
+
+ expect(alertManagementAlertsApi.fetchAlertMetricImages).toHaveBeenCalled();
+ expect(result).toEqual(fileList);
+ });
+
+ it('uploads a metric image', async () => {
+ alertManagementAlertsApi.uploadAlertMetricImage.mockResolvedValue({ data: fileListRaw[0] });
+ const result = await uploadMetricImage();
+
+ expect(alertManagementAlertsApi.uploadAlertMetricImage).toHaveBeenCalled();
+ expect(result).toEqual(fileList[0]);
+ });
+
+ it('updates a metric image', async () => {
+ alertManagementAlertsApi.updateAlertMetricImage.mockResolvedValue({ data: fileListRaw[0] });
+ const result = await updateMetricImage();
+
+ expect(alertManagementAlertsApi.updateAlertMetricImage).toHaveBeenCalled();
+ expect(result).toEqual(fileList[0]);
+ });
+
+ it('deletes a metric image', async () => {
+ alertManagementAlertsApi.deleteAlertMetricImage.mockResolvedValue({ data: '' });
+ const result = await deleteMetricImage();
+
+ expect(alertManagementAlertsApi.deleteAlertMetricImage).toHaveBeenCalled();
+ expect(result).toEqual({});
+ });
+});
diff --git a/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap
index c14cf0db370..bdf5ea23812 100644
--- a/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap
+++ b/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap
@@ -218,65 +218,88 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
<div
class="award-menu-holder gl-my-2"
>
- <button
- aria-label="Add reaction"
- class="btn add-reaction-button js-add-award btn-default btn-md gl-button js-test-add-button-class"
+ <div
+ class="emoji-picker"
+ data-testid="emoji-picker"
title="Add reaction"
- type="button"
>
- <!---->
-
- <!---->
-
- <span
- class="gl-button-text"
+ <div
+ boundary="scrollParent"
+ class="dropdown b-dropdown gl-new-dropdown btn-group"
+ id="__BVID__13"
+ lazy=""
+ menu-class="dropdown-extended-height"
+ no-flip=""
>
- <span
- class="reaction-control-icon reaction-control-icon-neutral"
+ <!---->
+ <button
+ aria-expanded="false"
+ aria-haspopup="true"
+ class="btn dropdown-toggle btn-default btn-md add-reaction-button btn-icon gl-relative! gl-button gl-dropdown-toggle btn-default-secondary"
+ id="__BVID__13__BV_toggle_"
+ type="button"
>
- <svg
- aria-hidden="true"
- class="gl-icon s16"
- data-testid="slight-smile-icon"
- role="img"
+ <span
+ class="gl-sr-only"
>
- <use
- href="#slight-smile"
- />
- </svg>
- </span>
-
- <span
- class="reaction-control-icon reaction-control-icon-positive"
- >
- <svg
- aria-hidden="true"
- class="gl-icon s16"
- data-testid="smiley-icon"
- role="img"
+ Add reaction
+ </span>
+
+ <span
+ class="reaction-control-icon reaction-control-icon-neutral"
>
- <use
- href="#smiley"
- />
- </svg>
- </span>
-
- <span
- class="reaction-control-icon reaction-control-icon-super-positive"
- >
- <svg
- aria-hidden="true"
- class="gl-icon s16"
- data-testid="smile-icon"
- role="img"
+ <svg
+ aria-hidden="true"
+ class="gl-icon s16"
+ data-testid="slight-smile-icon"
+ role="img"
+ >
+ <use
+ href="#slight-smile"
+ />
+ </svg>
+ </span>
+
+ <span
+ class="reaction-control-icon reaction-control-icon-positive"
>
- <use
- href="#smile"
- />
- </svg>
- </span>
- </span>
- </button>
+ <svg
+ aria-hidden="true"
+ class="gl-icon s16"
+ data-testid="smiley-icon"
+ role="img"
+ >
+ <use
+ href="#smiley"
+ />
+ </svg>
+ </span>
+
+ <span
+ class="reaction-control-icon reaction-control-icon-super-positive"
+ >
+ <svg
+ aria-hidden="true"
+ class="gl-icon s16"
+ data-testid="smile-icon"
+ role="img"
+ >
+ <use
+ href="#smile"
+ />
+ </svg>
+ </span>
+ </button>
+ <ul
+ aria-labelledby="__BVID__13__BV_toggle_"
+ class="dropdown-menu dropdown-extended-height dropdown-menu-right"
+ role="menu"
+ tabindex="-1"
+ >
+ <!---->
+ </ul>
+ </div>
+ </div>
</div>
</div>
`;
diff --git a/spec/frontend/vue_shared/components/__snapshots__/identicon_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/identicon_spec.js.snap
deleted file mode 100644
index 1d8e04b83a3..00000000000
--- a/spec/frontend/vue_shared/components/__snapshots__/identicon_spec.js.snap
+++ /dev/null
@@ -1,21 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Identicon entity id is a GraphQL id matches snapshot 1`] = `
-<div
- class="avatar identicon s40 bg2"
->
-
- E
-
-</div>
-`;
-
-exports[`Identicon entity id is a number matches snapshot 1`] = `
-<div
- class="avatar identicon s40 bg2"
->
-
- E
-
-</div>
-`;
diff --git a/spec/frontend/vue_shared/components/awards_list_spec.js b/spec/frontend/vue_shared/components/awards_list_spec.js
index 95e9760c181..1c8cf726aca 100644
--- a/spec/frontend/vue_shared/components/awards_list_spec.js
+++ b/spec/frontend/vue_shared/components/awards_list_spec.js
@@ -76,7 +76,7 @@ describe('vue_shared/components/awards_list', () => {
count: Number(x.find('.js-counter').text()),
};
});
- const findAddAwardButton = () => wrapper.find('.js-add-award');
+ const findAddAwardButton = () => wrapper.find('[data-testid="emoji-picker"]');
describe('default', () => {
beforeEach(() => {
@@ -151,7 +151,6 @@ describe('vue_shared/components/awards_list', () => {
const btn = findAddAwardButton();
expect(btn.exists()).toBe(true);
- expect(btn.classes(TEST_ADD_BUTTON_CLASS)).toBe(true);
});
});
diff --git a/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js b/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js
index 663ebd3e12f..4b44311b253 100644
--- a/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js
+++ b/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js
@@ -2,9 +2,6 @@ import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { HIGHLIGHT_CLASS_NAME } from '~/vue_shared/components/blob_viewers/constants';
import SimpleViewer from '~/vue_shared/components/blob_viewers/simple_viewer.vue';
-import LineHighlighter from '~/blob/line_highlighter';
-
-jest.mock('~/blob/line_highlighter');
describe('Blob Simple Viewer component', () => {
let wrapper;
@@ -30,20 +27,6 @@ describe('Blob Simple Viewer component', () => {
wrapper.destroy();
});
- describe('refactorBlobViewer feature flag', () => {
- it('loads the LineHighlighter if refactorBlobViewer is enabled', () => {
- createComponent('', false, { refactorBlobViewer: true });
-
- expect(LineHighlighter).toHaveBeenCalled();
- });
-
- it('does not load the LineHighlighter if refactorBlobViewer is disabled', () => {
- createComponent('', false, { refactorBlobViewer: false });
-
- expect(LineHighlighter).not.toHaveBeenCalled();
- });
- });
-
it('does not fail if content is empty', () => {
const spy = jest.spyOn(window.console, 'error');
createComponent('');
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js
index 575e8a73050..b6a181e6a0b 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js
@@ -26,7 +26,6 @@ import {
tokenValueMilestone,
tokenValueMembership,
tokenValueConfidential,
- tokenValueEmpty,
} from './mock_data';
jest.mock('~/vue_shared/components/filtered_search_bar/filtered_search_utils', () => ({
@@ -207,33 +206,14 @@ describe('FilteredSearchBarRoot', () => {
});
});
- describe('watchers', () => {
- describe('filterValue', () => {
- it('emits component event `onFilter` with empty array and false when filter was never selected', async () => {
- wrapper = createComponent({ initialFilterValue: [tokenValueEmpty] });
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- initialRender: false,
- filterValue: [tokenValueEmpty],
- });
-
- await nextTick();
- expect(wrapper.emitted('onFilter')[0]).toEqual([[], false]);
- });
+ describe('events', () => {
+ it('emits component event `onFilter` with empty array and true when initially selected filter value was cleared', async () => {
+ wrapper = createComponent({ initialFilterValue: [tokenValueLabel] });
- it('emits component event `onFilter` with empty array and true when initially selected filter value was cleared', async () => {
- wrapper = createComponent({ initialFilterValue: [tokenValueLabel] });
- // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
- // eslint-disable-next-line no-restricted-syntax
- wrapper.setData({
- initialRender: false,
- filterValue: [tokenValueEmpty],
- });
+ wrapper.find(GlFilteredSearch).vm.$emit('clear');
- await nextTick();
- expect(wrapper.emitted('onFilter')[0]).toEqual([[], true]);
- });
+ await nextTick();
+ expect(wrapper.emitted('onFilter')[0]).toEqual([[], true]);
});
});
diff --git a/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js b/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js
index b67385cc43e..e636f58d868 100644
--- a/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js
+++ b/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js
@@ -89,8 +89,11 @@ describe('InputCopyToggleVisibility', () => {
});
describe('when clicked', () => {
+ let event;
+
beforeEach(async () => {
- await findRevealButton().trigger('click');
+ event = { stopPropagation: jest.fn() };
+ await findRevealButton().trigger('click', event);
});
it('displays value', () => {
@@ -110,6 +113,11 @@ describe('InputCopyToggleVisibility', () => {
it('emits `visibility-change` event', () => {
expect(wrapper.emitted('visibility-change')[0]).toEqual([true]);
});
+
+ it('stops propagation on click event', () => {
+ // in case the input is located in a dropdown or modal
+ expect(event.stopPropagation).toHaveBeenCalledTimes(1);
+ });
});
});
diff --git a/spec/frontend/vue_shared/components/help_popover_spec.js b/spec/frontend/vue_shared/components/help_popover_spec.js
index 597fb63d95c..64dce194327 100644
--- a/spec/frontend/vue_shared/components/help_popover_spec.js
+++ b/spec/frontend/vue_shared/components/help_popover_spec.js
@@ -34,7 +34,7 @@ describe('HelpPopover', () => {
it('renders a link button with an icon question', () => {
expect(findQuestionButton().props()).toMatchObject({
- icon: 'question',
+ icon: 'question-o',
variant: 'link',
});
});
diff --git a/spec/frontend/vue_shared/components/identicon_spec.js b/spec/frontend/vue_shared/components/identicon_spec.js
deleted file mode 100644
index 24fc3713e2b..00000000000
--- a/spec/frontend/vue_shared/components/identicon_spec.js
+++ /dev/null
@@ -1,50 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import IdenticonComponent from '~/vue_shared/components/identicon.vue';
-
-describe('Identicon', () => {
- let wrapper;
-
- const defaultProps = {
- entityId: 1,
- entityName: 'entity-name',
- sizeClass: 's40',
- };
-
- const createComponent = (props = {}) => {
- wrapper = shallowMount(IdenticonComponent, {
- propsData: {
- ...defaultProps,
- ...props,
- },
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
- describe('entity id is a number', () => {
- beforeEach(() => createComponent());
-
- it('matches snapshot', () => {
- expect(wrapper.element).toMatchSnapshot();
- });
-
- it('adds a correct class to identicon', () => {
- expect(wrapper.find({ ref: 'identicon' }).classes()).toContain('bg2');
- });
- });
-
- describe('entity id is a GraphQL id', () => {
- beforeEach(() => createComponent({ entityId: 'gid://gitlab/Project/8' }));
-
- it('matches snapshot', () => {
- expect(wrapper.element).toMatchSnapshot();
- });
-
- it('adds a correct class to identicon', () => {
- expect(wrapper.find({ ref: 'identicon' }).classes()).toContain('bg2');
- });
- });
-});
diff --git a/spec/frontend/vue_shared/components/line_numbers_spec.js b/spec/frontend/vue_shared/components/line_numbers_spec.js
deleted file mode 100644
index 38c26226863..00000000000
--- a/spec/frontend/vue_shared/components/line_numbers_spec.js
+++ /dev/null
@@ -1,37 +0,0 @@
-import { shallowMount } from '@vue/test-utils';
-import { GlIcon, GlLink } from '@gitlab/ui';
-import LineNumbers from '~/vue_shared/components/line_numbers.vue';
-
-describe('Line Numbers component', () => {
- let wrapper;
- const lines = 10;
-
- const createComponent = () => {
- wrapper = shallowMount(LineNumbers, { propsData: { lines } });
- };
-
- const findGlIcon = () => wrapper.findComponent(GlIcon);
- const findLineNumbers = () => wrapper.findAllComponents(GlLink);
- const findFirstLineNumber = () => findLineNumbers().at(0);
-
- beforeEach(() => createComponent());
-
- afterEach(() => wrapper.destroy());
-
- describe('rendering', () => {
- it('renders Line Numbers', () => {
- expect(findLineNumbers().length).toBe(lines);
- expect(findFirstLineNumber().attributes()).toMatchObject({
- id: 'L1',
- to: '#LC1',
- });
- });
-
- it('renders a link icon', () => {
- expect(findGlIcon().props()).toMatchObject({
- size: 12,
- name: 'link',
- });
- });
- });
-});
diff --git a/spec/frontend/vue_shared/components/local_storage_sync_spec.js b/spec/frontend/vue_shared/components/local_storage_sync_spec.js
index dac633fe6c8..a80717a1aea 100644
--- a/spec/frontend/vue_shared/components/local_storage_sync_spec.js
+++ b/spec/frontend/vue_shared/components/local_storage_sync_spec.js
@@ -1,31 +1,29 @@
import { shallowMount } from '@vue/test-utils';
-import { nextTick } from 'vue';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
+const STORAGE_KEY = 'key';
+
describe('Local Storage Sync', () => {
let wrapper;
- const createComponent = ({ props = {}, slots = {} } = {}) => {
+ const createComponent = ({ value, asString = false, slots = {} } = {}) => {
wrapper = shallowMount(LocalStorageSync, {
- propsData: props,
+ propsData: { storageKey: STORAGE_KEY, value, asString },
slots,
});
};
+ const setStorageValue = (value) => localStorage.setItem(STORAGE_KEY, value);
+ const getStorageValue = (value) => localStorage.getItem(STORAGE_KEY, value);
+
afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
- wrapper = null;
+ wrapper.destroy();
localStorage.clear();
});
it('is a renderless component', () => {
const html = '<div class="test-slot"></div>';
createComponent({
- props: {
- storageKey: 'key',
- },
slots: {
default: html,
},
@@ -35,233 +33,136 @@ describe('Local Storage Sync', () => {
});
describe('localStorage empty', () => {
- const storageKey = 'issue_list_order';
-
it('does not emit input event', () => {
- createComponent({
- props: {
- storageKey,
- value: 'ascending',
- },
- });
-
- expect(wrapper.emitted('input')).toBeFalsy();
- });
-
- it.each('foo', 3, true, ['foo', 'bar'], { foo: 'bar' })(
- 'saves updated value to localStorage',
- async (newValue) => {
- createComponent({
- props: {
- storageKey,
- value: 'initial',
- },
- });
-
- wrapper.setProps({ value: newValue });
+ createComponent({ value: 'ascending' });
- await nextTick();
- expect(localStorage.getItem(storageKey)).toBe(String(newValue));
- },
- );
-
- it('does not save default value', () => {
- const value = 'ascending';
+ expect(wrapper.emitted('input')).toBeUndefined();
+ });
- createComponent({
- props: {
- storageKey,
- value,
- },
- });
+ it('does not save initial value if it did not change', () => {
+ createComponent({ value: 'ascending' });
- expect(localStorage.getItem(storageKey)).toBe(null);
+ expect(getStorageValue()).toBeNull();
});
});
describe('localStorage has saved value', () => {
- const storageKey = 'issue_list_order_by';
const savedValue = 'last_updated';
beforeEach(() => {
- localStorage.setItem(storageKey, savedValue);
+ setStorageValue(savedValue);
+ createComponent({ asString: true });
});
it('emits input event with saved value', () => {
- createComponent({
- props: {
- storageKey,
- value: 'ascending',
- },
- });
-
expect(wrapper.emitted('input')[0][0]).toBe(savedValue);
});
- it('does not overwrite localStorage with prop value', () => {
- createComponent({
- props: {
- storageKey,
- value: 'created',
- },
- });
-
- expect(localStorage.getItem(storageKey)).toBe(savedValue);
+ it('does not overwrite localStorage with initial prop value', () => {
+ expect(getStorageValue()).toBe(savedValue);
});
it('updating the value updates localStorage', async () => {
- createComponent({
- props: {
- storageKey,
- value: 'created',
- },
- });
-
const newValue = 'last_updated';
- wrapper.setProps({
- value: newValue,
- });
+ await wrapper.setProps({ value: newValue });
- await nextTick();
- expect(localStorage.getItem(storageKey)).toBe(newValue);
+ expect(getStorageValue()).toBe(newValue);
});
+ });
+ describe('persist prop', () => {
it('persists the value by default', async () => {
const persistedValue = 'persisted';
+ createComponent({ asString: true });
+ // Sanity check to make sure we start with nothing saved.
+ expect(getStorageValue()).toBeNull();
- createComponent({
- props: {
- storageKey,
- },
- });
+ await wrapper.setProps({ value: persistedValue });
- wrapper.setProps({ value: persistedValue });
- await nextTick();
- expect(localStorage.getItem(storageKey)).toBe(persistedValue);
+ expect(getStorageValue()).toBe(persistedValue);
});
it('does not save a value if persist is set to false', async () => {
+ const value = 'saved';
const notPersistedValue = 'notPersisted';
+ createComponent({ asString: true });
+ // Save some value so we can test that it's not overwritten.
+ await wrapper.setProps({ value });
- createComponent({
- props: {
- storageKey,
- },
- });
+ expect(getStorageValue()).toBe(value);
- wrapper.setProps({ persist: false, value: notPersistedValue });
- await nextTick();
- expect(localStorage.getItem(storageKey)).not.toBe(notPersistedValue);
+ await wrapper.setProps({ persist: false, value: notPersistedValue });
+
+ expect(getStorageValue()).toBe(value);
});
});
- describe('with "asJson" prop set to "true"', () => {
- const storageKey = 'testStorageKey';
-
- describe.each`
- value | serializedValue
- ${null} | ${'null'}
- ${''} | ${'""'}
- ${true} | ${'true'}
- ${false} | ${'false'}
- ${42} | ${'42'}
- ${'42'} | ${'"42"'}
- ${'{ foo: '} | ${'"{ foo: "'}
- ${['test']} | ${'["test"]'}
- ${{ foo: 'bar' }} | ${'{"foo":"bar"}'}
- `('given $value', ({ value, serializedValue }) => {
- describe('is a new value', () => {
- beforeEach(async () => {
- createComponent({
- props: {
- storageKey,
- value: 'initial',
- asJson: true,
- },
- });
-
- wrapper.setProps({ value });
-
- await nextTick();
- });
-
- it('serializes the value correctly to localStorage', () => {
- expect(localStorage.getItem(storageKey)).toBe(serializedValue);
- });
- });
-
- describe('is already stored', () => {
- beforeEach(() => {
- localStorage.setItem(storageKey, serializedValue);
-
- createComponent({
- props: {
- storageKey,
- value: 'initial',
- asJson: true,
- },
- });
- });
-
- it('emits an input event with the deserialized value', () => {
- expect(wrapper.emitted('input')).toEqual([[value]]);
- });
- });
+ describe('saving and restoring', () => {
+ it.each`
+ value | asString
+ ${'foo'} | ${true}
+ ${'foo'} | ${false}
+ ${'{ a: 1 }'} | ${true}
+ ${'{ a: 1 }'} | ${false}
+ ${3} | ${false}
+ ${['foo', 'bar']} | ${false}
+ ${{ foo: 'bar' }} | ${false}
+ ${null} | ${false}
+ ${' '} | ${false}
+ ${true} | ${false}
+ ${false} | ${false}
+ ${42} | ${false}
+ ${'42'} | ${false}
+ ${'{ foo: '} | ${false}
+ `('saves and restores the same value', async ({ value, asString }) => {
+ // Create an initial component to save the value.
+ createComponent({ asString });
+ await wrapper.setProps({ value });
+ wrapper.destroy();
+ // Create a second component to restore the value. Restore is only done once, when the
+ // component is first mounted.
+ createComponent({ asString });
+
+ expect(wrapper.emitted('input')[0][0]).toEqual(value);
});
- describe('with bad JSON in storage', () => {
- const badJSON = '{ badJSON';
-
- beforeEach(() => {
- jest.spyOn(console, 'warn').mockImplementation();
- localStorage.setItem(storageKey, badJSON);
-
- createComponent({
- props: {
- storageKey,
- value: 'initial',
- asJson: true,
- },
- });
- });
-
- it('should console warn', () => {
- // eslint-disable-next-line no-console
- expect(console.warn).toHaveBeenCalledWith(
- `[gitlab] Failed to deserialize value from localStorage (key=${storageKey})`,
- badJSON,
- );
- });
-
- it('should not emit an input event', () => {
- expect(wrapper.emitted('input')).toBeUndefined();
- });
+ it('shows a warning when trying to save a non-string value when asString prop is true', async () => {
+ const spy = jest.spyOn(console, 'warn').mockImplementation();
+ createComponent({ asString: true });
+ await wrapper.setProps({ value: [] });
+
+ expect(spy).toHaveBeenCalled();
});
});
- it('clears localStorage when clear property is true', async () => {
- const storageKey = 'key';
- const value = 'initial';
+ describe('with bad JSON in storage', () => {
+ const badJSON = '{ badJSON';
+ let spy;
- createComponent({
- props: {
- storageKey,
- },
+ beforeEach(() => {
+ spy = jest.spyOn(console, 'warn').mockImplementation();
+ setStorageValue(badJSON);
+ createComponent();
});
- wrapper.setProps({
- value,
+
+ it('should console warn', () => {
+ expect(spy).toHaveBeenCalled();
});
- await nextTick();
+ it('should not emit an input event', () => {
+ expect(wrapper.emitted('input')).toBeUndefined();
+ });
+ });
- expect(localStorage.getItem(storageKey)).toBe(value);
+ it('clears localStorage when clear property is true', async () => {
+ const value = 'initial';
+ createComponent({ asString: true });
+ await wrapper.setProps({ value });
- wrapper.setProps({
- clear: true,
- });
+ expect(getStorageValue()).toBe(value);
- await nextTick();
+ await wrapper.setProps({ clear: true });
- expect(localStorage.getItem(storageKey)).toBe(null);
+ expect(getStorageValue()).toBeNull();
});
});
diff --git a/spec/frontend/vue_shared/components/markdown/apply_suggestion_spec.js b/spec/frontend/vue_shared/components/markdown/apply_suggestion_spec.js
index c56628fcbcd..ecb2b37c3a5 100644
--- a/spec/frontend/vue_shared/components/markdown/apply_suggestion_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/apply_suggestion_spec.js
@@ -1,4 +1,4 @@
-import { GlDropdown, GlFormTextarea, GlButton } from '@gitlab/ui';
+import { GlDropdown, GlFormTextarea, GlButton, GlAlert } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import ApplySuggestionComponent from '~/vue_shared/components/markdown/apply_suggestion.vue';
@@ -10,9 +10,10 @@ describe('Apply Suggestion component', () => {
wrapper = shallowMount(ApplySuggestionComponent, { propsData: { ...propsData, ...props } });
};
- const findDropdown = () => wrapper.find(GlDropdown);
- const findTextArea = () => wrapper.find(GlFormTextarea);
- const findApplyButton = () => wrapper.find(GlButton);
+ const findDropdown = () => wrapper.findComponent(GlDropdown);
+ const findTextArea = () => wrapper.findComponent(GlFormTextarea);
+ const findApplyButton = () => wrapper.findComponent(GlButton);
+ const findAlert = () => wrapper.findComponent(GlAlert);
beforeEach(() => createWrapper());
@@ -53,6 +54,20 @@ describe('Apply Suggestion component', () => {
});
});
+ describe('error', () => {
+ it('displays an error message', () => {
+ const errorMessage = 'Error message';
+ createWrapper({ errorMessage });
+
+ const alert = findAlert();
+
+ expect(alert.exists()).toBe(true);
+ expect(alert.props('variant')).toBe('danger');
+ expect(alert.props('dismissible')).toBe(false);
+ expect(alert.text()).toBe(errorMessage);
+ });
+ });
+
describe('apply suggestion', () => {
it('emits an apply event with no message if no message was added', () => {
findTextArea().vm.$emit('input', null);
diff --git a/spec/frontend/vue_shared/components/markdown/field_spec.js b/spec/frontend/vue_shared/components/markdown/field_spec.js
index b5daa389fc6..d1c4d777d44 100644
--- a/spec/frontend/vue_shared/components/markdown/field_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/field_spec.js
@@ -85,7 +85,7 @@ describe('Markdown field component', () => {
describe('mounted', () => {
const previewHTML = `
<p>markdown preview</p>
- <video src="${FIXTURES_PATH}/static/mock-video.mp4" muted="muted"></video>
+ <video src="${FIXTURES_PATH}/static/mock-video.mp4"></video>
`;
let previewLink;
let writeLink;
@@ -101,6 +101,21 @@ describe('Markdown field component', () => {
expect(subject.find('.zen-backdrop textarea').element).not.toBeNull();
});
+ it('renders referenced commands on markdown preview', async () => {
+ axiosMock
+ .onPost(markdownPreviewPath)
+ .reply(200, { references: { users: [], commands: 'test command' } });
+
+ previewLink = getPreviewLink();
+ previewLink.vm.$emit('click', { target: {} });
+
+ await axios.waitFor(markdownPreviewPath);
+ const referencedCommands = subject.find('[data-testid="referenced-commands"]');
+
+ expect(referencedCommands.exists()).toBe(true);
+ expect(referencedCommands.text()).toContain('test command');
+ });
+
describe('markdown preview', () => {
beforeEach(() => {
axiosMock.onPost(markdownPreviewPath).reply(200, { body: previewHTML });
diff --git a/spec/frontend/vue_shared/components/markdown/header_spec.js b/spec/frontend/vue_shared/components/markdown/header_spec.js
index 9ffb9c6a541..fa4ca63f910 100644
--- a/spec/frontend/vue_shared/components/markdown/header_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/header_spec.js
@@ -95,7 +95,7 @@ describe('Markdown field header component', () => {
it('hides toolbar in preview mode', () => {
createWrapper({ previewMarkdown: true });
- expect(findToolbar().classes().includes('gl-display-none')).toBe(true);
+ expect(findToolbar().classes().includes('gl-display-none!')).toBe(true);
});
it('emits toggle markdown event when clicking preview tab', async () => {
diff --git a/spec/frontend/vue_shared/components/metric_images/__snapshots__/metric_images_table_spec.js.snap b/spec/frontend/vue_shared/components/metric_images/__snapshots__/metric_images_table_spec.js.snap
new file mode 100644
index 00000000000..5dd12d9edf5
--- /dev/null
+++ b/spec/frontend/vue_shared/components/metric_images/__snapshots__/metric_images_table_spec.js.snap
@@ -0,0 +1,73 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Metrics upload item render the metrics image component 1`] = `
+<gl-card-stub
+ bodyclass="gl-border-1,gl-border-t-solid,gl-border-gray-100,[object Object]"
+ class="collapsible-card border gl-p-0 gl-mb-5"
+ footerclass=""
+ headerclass="gl-display-flex gl-align-items-center gl-border-b-0 gl-py-3"
+>
+ <gl-modal-stub
+ actioncancel="[object Object]"
+ actionprimary="[object Object]"
+ body-class="gl-pb-0! gl-min-h-6!"
+ dismisslabel="Close"
+ modalclass=""
+ modalid="delete-metric-modal"
+ size="sm"
+ titletag="h4"
+ >
+
+ <p>
+ Are you sure you wish to delete this image?
+ </p>
+ </gl-modal-stub>
+
+ <gl-modal-stub
+ actioncancel="[object Object]"
+ actionprimary="[object Object]"
+ data-testid="metric-image-edit-modal"
+ dismisslabel="Close"
+ modalclass=""
+ modalid="edit-metric-modal"
+ size="sm"
+ titletag="h4"
+ >
+
+ <gl-form-group-stub
+ label="Text (optional)"
+ label-for="upload-text-input"
+ labeldescription=""
+ optionaltext="(optional)"
+ >
+ <gl-form-input-stub
+ data-testid="metric-image-text-field"
+ id="upload-text-input"
+ />
+ </gl-form-group-stub>
+
+ <gl-form-group-stub
+ description="Must start with http or https"
+ label="Link (optional)"
+ label-for="upload-url-input"
+ labeldescription=""
+ optionaltext="(optional)"
+ >
+ <gl-form-input-stub
+ data-testid="metric-image-url-field"
+ id="upload-url-input"
+ />
+ </gl-form-group-stub>
+ </gl-modal-stub>
+
+ <div
+ class="gl-display-flex gl-flex-direction-column"
+ data-testid="metric-image-body"
+ >
+ <img
+ class="gl-max-w-full gl-align-self-center"
+ src="test_file_path"
+ />
+ </div>
+</gl-card-stub>
+`;
diff --git a/spec/frontend/vue_shared/components/metric_images/metric_images_tab_spec.js b/spec/frontend/vue_shared/components/metric_images/metric_images_tab_spec.js
new file mode 100644
index 00000000000..2cefa77b72d
--- /dev/null
+++ b/spec/frontend/vue_shared/components/metric_images/metric_images_tab_spec.js
@@ -0,0 +1,174 @@
+import { GlFormInput, GlModal } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import merge from 'lodash/merge';
+import Vuex from 'vuex';
+import MetricImagesTable from '~/vue_shared/components/metric_images/metric_images_table.vue';
+import MetricImagesTab from '~/vue_shared/components/metric_images/metric_images_tab.vue';
+import createStore from '~/vue_shared/components/metric_images/store';
+import waitForPromises from 'helpers/wait_for_promises';
+import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
+import { fileList, initialData } from './mock_data';
+
+const service = {
+ getMetricImages: jest.fn(),
+};
+
+const mockEvent = { preventDefault: jest.fn() };
+
+Vue.use(Vuex);
+
+describe('Metric images tab', () => {
+ let wrapper;
+ let store;
+
+ const mountComponent = (options = {}) => {
+ store = createStore({}, service);
+
+ wrapper = shallowMount(
+ MetricImagesTab,
+ merge(
+ {
+ store,
+ provide: {
+ canUpdate: true,
+ iid: initialData.issueIid,
+ projectId: initialData.projectId,
+ },
+ },
+ options,
+ ),
+ );
+ };
+
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ });
+
+ const findUploadDropzone = () => wrapper.findComponent(UploadDropzone);
+ const findImages = () => wrapper.findAllComponents(MetricImagesTable);
+ const findModal = () => wrapper.findComponent(GlModal);
+ const submitModal = () => findModal().vm.$emit('primary', mockEvent);
+ const cancelModal = () => findModal().vm.$emit('hidden');
+
+ describe('empty state', () => {
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it('renders the upload component', () => {
+ expect(findUploadDropzone().exists()).toBe(true);
+ });
+ });
+
+ describe('permissions', () => {
+ beforeEach(() => {
+ mountComponent({ provide: { canUpdate: false } });
+ });
+
+ it('hides the upload component when disallowed', () => {
+ expect(findUploadDropzone().exists()).toBe(false);
+ });
+ });
+
+ describe('onLoad action', () => {
+ it('should load images', async () => {
+ service.getMetricImages.mockImplementation(() => Promise.resolve(fileList));
+
+ mountComponent();
+
+ await waitForPromises();
+
+ expect(findImages().length).toBe(1);
+ });
+ });
+
+ describe('add metric dialog', () => {
+ const testUrl = 'test url';
+
+ it('should open the add metric dialog when clicked', async () => {
+ mountComponent();
+
+ findUploadDropzone().vm.$emit('change');
+
+ await waitForPromises();
+
+ expect(findModal().attributes('visible')).toBe('true');
+ });
+
+ it('should close when cancelled', async () => {
+ mountComponent({
+ data() {
+ return { modalVisible: true };
+ },
+ });
+
+ cancelModal();
+
+ await waitForPromises();
+
+ expect(findModal().attributes('visible')).toBeFalsy();
+ });
+
+ it('should add files and url when selected', async () => {
+ mountComponent({
+ data() {
+ return { modalVisible: true, modalUrl: testUrl, currentFiles: fileList };
+ },
+ });
+
+ const dispatchSpy = jest.spyOn(store, 'dispatch');
+
+ submitModal();
+
+ await waitForPromises();
+
+ expect(dispatchSpy).toHaveBeenCalledWith('uploadImage', {
+ files: fileList,
+ url: testUrl,
+ urlText: '',
+ });
+ });
+
+ describe('url field', () => {
+ beforeEach(() => {
+ mountComponent({
+ data() {
+ return { modalVisible: true, modalUrl: testUrl };
+ },
+ });
+ });
+
+ it('should display the url field', () => {
+ expect(wrapper.find('#upload-url-input').attributes('value')).toBe(testUrl);
+ });
+
+ it('should display the url text field', () => {
+ expect(wrapper.find('#upload-text-input').attributes('value')).toBe('');
+ });
+
+ it('should clear url when cancelled', async () => {
+ cancelModal();
+
+ await waitForPromises();
+
+ expect(wrapper.findComponent(GlFormInput).attributes('value')).toBe('');
+ });
+
+ it('should clear url when submitted', async () => {
+ submitModal();
+
+ await waitForPromises();
+
+ expect(wrapper.findComponent(GlFormInput).attributes('value')).toBe('');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/metric_images/metric_images_table_spec.js b/spec/frontend/vue_shared/components/metric_images/metric_images_table_spec.js
new file mode 100644
index 00000000000..d792bd46ccd
--- /dev/null
+++ b/spec/frontend/vue_shared/components/metric_images/metric_images_table_spec.js
@@ -0,0 +1,230 @@
+import { GlLink, GlModal } from '@gitlab/ui';
+import { shallowMount, mount } from '@vue/test-utils';
+import Vue from 'vue';
+import merge from 'lodash/merge';
+import Vuex from 'vuex';
+import createStore from '~/vue_shared/components/metric_images/store';
+import MetricsImageTable from '~/vue_shared/components/metric_images/metric_images_table.vue';
+import waitForPromises from 'helpers/wait_for_promises';
+
+const defaultProps = {
+ id: 1,
+ filePath: 'test_file_path',
+ filename: 'test_file_name',
+};
+
+const mockEvent = { preventDefault: jest.fn() };
+
+Vue.use(Vuex);
+
+describe('Metrics upload item', () => {
+ let wrapper;
+ let store;
+
+ const mountComponent = (options = {}, mountMethod = mount) => {
+ store = createStore();
+
+ wrapper = mountMethod(
+ MetricsImageTable,
+ merge(
+ {
+ store,
+ propsData: {
+ ...defaultProps,
+ },
+ provide: { canUpdate: true },
+ },
+ options,
+ ),
+ );
+ };
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ });
+
+ const findImageLink = () => wrapper.findComponent(GlLink);
+ const findLabelTextSpan = () => wrapper.find('[data-testid="metric-image-label-span"]');
+ const findCollapseButton = () => wrapper.find('[data-testid="collapse-button"]');
+ const findMetricImageBody = () => wrapper.find('[data-testid="metric-image-body"]');
+ const findModal = () => wrapper.findComponent(GlModal);
+ const findEditModal = () => wrapper.find('[data-testid="metric-image-edit-modal"]');
+ const findDeleteButton = () => wrapper.find('[data-testid="delete-button"]');
+ const findEditButton = () => wrapper.find('[data-testid="edit-button"]');
+ const findImageTextInput = () => wrapper.find('[data-testid="metric-image-text-field"]');
+ const findImageUrlInput = () => wrapper.find('[data-testid="metric-image-url-field"]');
+
+ const closeModal = () => findModal().vm.$emit('hidden');
+ const submitModal = () => findModal().vm.$emit('primary', mockEvent);
+ const deleteImage = () => findDeleteButton().vm.$emit('click');
+ const closeEditModal = () => findEditModal().vm.$emit('hidden');
+ const submitEditModal = () => findEditModal().vm.$emit('primary', mockEvent);
+ const editImage = () => findEditButton().vm.$emit('click');
+
+ it('render the metrics image component', () => {
+ mountComponent({}, shallowMount);
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('shows a link with the correct url', () => {
+ const testUrl = 'test_url';
+ mountComponent({ propsData: { url: testUrl } });
+
+ expect(findImageLink().attributes('href')).toBe(testUrl);
+ expect(findImageLink().text()).toBe(defaultProps.filename);
+ });
+
+ it('shows a link with the url text, if url text is present', () => {
+ const testUrl = 'test_url';
+ const testUrlText = 'test_url_text';
+ mountComponent({ propsData: { url: testUrl, urlText: testUrlText } });
+
+ expect(findImageLink().attributes('href')).toBe(testUrl);
+ expect(findImageLink().text()).toBe(testUrlText);
+ });
+
+ it('shows the url text with no url, if no url is present', () => {
+ const testUrlText = 'test_url_text';
+ mountComponent({ propsData: { urlText: testUrlText } });
+
+ expect(findLabelTextSpan().text()).toBe(testUrlText);
+ });
+
+ describe('expand and collapse', () => {
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it('the card is expanded by default', () => {
+ expect(findMetricImageBody().isVisible()).toBe(true);
+ });
+
+ it('the card is collapsed when clicked', async () => {
+ findCollapseButton().trigger('click');
+
+ await waitForPromises();
+
+ expect(findMetricImageBody().isVisible()).toBe(false);
+ });
+ });
+
+ describe('delete functionality', () => {
+ it('should open the delete modal when clicked', async () => {
+ mountComponent({ stubs: { GlModal: true } });
+
+ deleteImage();
+
+ await waitForPromises();
+
+ expect(findModal().attributes('visible')).toBe('true');
+ });
+
+ describe('when the modal is open', () => {
+ beforeEach(() => {
+ mountComponent(
+ {
+ data() {
+ return { modalVisible: true };
+ },
+ },
+ shallowMount,
+ );
+ });
+
+ it('should close the modal when cancelled', async () => {
+ closeModal();
+
+ await waitForPromises();
+
+ expect(findModal().attributes('visible')).toBeFalsy();
+ });
+
+ it('should delete the image when selected', async () => {
+ const dispatchSpy = jest.spyOn(store, 'dispatch').mockImplementation(jest.fn());
+
+ submitModal();
+
+ await waitForPromises();
+
+ expect(dispatchSpy).toHaveBeenCalledWith('deleteImage', defaultProps.id);
+ });
+ });
+
+ describe('canUpdate permission', () => {
+ it('delete button is hidden when user lacks update permissions', () => {
+ mountComponent({ provide: { canUpdate: false } });
+
+ expect(findDeleteButton().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('edit functionality', () => {
+ it('should open the delete modal when clicked', async () => {
+ mountComponent({ stubs: { GlModal: true } });
+
+ editImage();
+
+ await waitForPromises();
+
+ expect(findEditModal().attributes('visible')).toBe('true');
+ });
+
+ describe('when the modal is open', () => {
+ beforeEach(() => {
+ mountComponent({
+ data() {
+ return { editModalVisible: true };
+ },
+ propsData: { urlText: 'test' },
+ stubs: { GlModal: true },
+ });
+ });
+
+ it('should close the modal when cancelled', async () => {
+ closeEditModal();
+
+ await waitForPromises();
+
+ expect(findEditModal().attributes('visible')).toBeFalsy();
+ });
+
+ it('should delete the image when selected', async () => {
+ const dispatchSpy = jest.spyOn(store, 'dispatch').mockImplementation(jest.fn());
+
+ submitEditModal();
+
+ await waitForPromises();
+
+ expect(dispatchSpy).toHaveBeenCalledWith('updateImage', {
+ imageId: defaultProps.id,
+ url: null,
+ urlText: 'test',
+ });
+ });
+
+ it('should clear edits when the modal is closed', async () => {
+ await findImageTextInput().setValue('test value');
+ await findImageUrlInput().setValue('http://www.gitlab.com');
+
+ expect(findImageTextInput().element.value).toBe('test value');
+ expect(findImageUrlInput().element.value).toBe('http://www.gitlab.com');
+
+ closeEditModal();
+
+ await waitForPromises();
+
+ editImage();
+
+ await waitForPromises();
+
+ expect(findImageTextInput().element.value).toBe('test');
+ expect(findImageUrlInput().element.value).toBe('');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/metric_images/mock_data.js b/spec/frontend/vue_shared/components/metric_images/mock_data.js
new file mode 100644
index 00000000000..480491077fb
--- /dev/null
+++ b/spec/frontend/vue_shared/components/metric_images/mock_data.js
@@ -0,0 +1,5 @@
+export const fileList = [{ filePath: 'test', filename: 'hello', id: 5, url: null }];
+
+export const fileListRaw = [{ file_path: 'test', filename: 'hello', id: 5, url: null }];
+
+export const initialData = { issueIid: '123', projectId: 456 };
diff --git a/spec/frontend/vue_shared/components/metric_images/store/actions_spec.js b/spec/frontend/vue_shared/components/metric_images/store/actions_spec.js
new file mode 100644
index 00000000000..518cf354675
--- /dev/null
+++ b/spec/frontend/vue_shared/components/metric_images/store/actions_spec.js
@@ -0,0 +1,158 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import actionsFactory from '~/vue_shared/components/metric_images/store/actions';
+import * as types from '~/vue_shared/components/metric_images/store/mutation_types';
+import createStore from '~/vue_shared/components/metric_images/store';
+import testAction from 'helpers/vuex_action_helper';
+import createFlash from '~/flash';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { fileList, initialData } from '../mock_data';
+
+jest.mock('~/flash');
+const service = {
+ getMetricImages: jest.fn(),
+ uploadMetricImage: jest.fn(),
+ updateMetricImage: jest.fn(),
+ deleteMetricImage: jest.fn(),
+};
+
+const actions = actionsFactory(service);
+
+const defaultState = {
+ issueIid: 1,
+ projectId: '2',
+};
+
+Vue.use(Vuex);
+
+describe('Metrics tab store actions', () => {
+ let store;
+ let state;
+
+ beforeEach(() => {
+ store = createStore(defaultState);
+ state = store.state;
+ });
+
+ afterEach(() => {
+ createFlash.mockClear();
+ });
+
+ describe('fetching metric images', () => {
+ it('should call success action when fetching metric images', () => {
+ service.getMetricImages.mockImplementation(() => Promise.resolve(fileList));
+
+ testAction(actions.fetchImages, null, state, [
+ { type: types.REQUEST_METRIC_IMAGES },
+ {
+ type: types.RECEIVE_METRIC_IMAGES_SUCCESS,
+ payload: convertObjectPropsToCamelCase(fileList, { deep: true }),
+ },
+ ]);
+ });
+
+ it('should call error action when fetching metric images with an error', async () => {
+ service.getMetricImages.mockImplementation(() => Promise.reject());
+
+ await testAction(
+ actions.fetchImages,
+ null,
+ state,
+ [{ type: types.REQUEST_METRIC_IMAGES }, { type: types.RECEIVE_METRIC_IMAGES_ERROR }],
+ [],
+ );
+ expect(createFlash).toHaveBeenCalled();
+ });
+ });
+
+ describe('uploading metric images', () => {
+ const payload = {
+ // mock the FileList api
+ files: {
+ item() {
+ return fileList[0];
+ },
+ },
+ url: 'test_url',
+ };
+
+ it('should call success action when uploading an image', () => {
+ service.uploadMetricImage.mockImplementation(() => Promise.resolve(fileList[0]));
+
+ testAction(actions.uploadImage, payload, state, [
+ { type: types.REQUEST_METRIC_UPLOAD },
+ {
+ type: types.RECEIVE_METRIC_UPLOAD_SUCCESS,
+ payload: fileList[0],
+ },
+ ]);
+ });
+
+ it('should call error action when failing to upload an image', async () => {
+ service.uploadMetricImage.mockImplementation(() => Promise.reject());
+
+ await testAction(
+ actions.uploadImage,
+ payload,
+ state,
+ [{ type: types.REQUEST_METRIC_UPLOAD }, { type: types.RECEIVE_METRIC_UPLOAD_ERROR }],
+ [],
+ );
+ expect(createFlash).toHaveBeenCalled();
+ });
+ });
+
+ describe('updating metric images', () => {
+ const payload = {
+ url: 'test_url',
+ urlText: 'url text',
+ };
+
+ it('should call success action when updating an image', () => {
+ service.updateMetricImage.mockImplementation(() => Promise.resolve());
+
+ testAction(actions.updateImage, payload, state, [
+ { type: types.REQUEST_METRIC_UPLOAD },
+ {
+ type: types.RECEIVE_METRIC_UPDATE_SUCCESS,
+ },
+ ]);
+ });
+
+ it('should call error action when failing to update an image', async () => {
+ service.updateMetricImage.mockImplementation(() => Promise.reject());
+
+ await testAction(
+ actions.updateImage,
+ payload,
+ state,
+ [{ type: types.REQUEST_METRIC_UPLOAD }, { type: types.RECEIVE_METRIC_UPLOAD_ERROR }],
+ [],
+ );
+ expect(createFlash).toHaveBeenCalled();
+ });
+ });
+
+ describe('deleting a metric image', () => {
+ const payload = fileList[0].id;
+
+ it('should call success action when deleting an image', () => {
+ service.deleteMetricImage.mockImplementation(() => Promise.resolve());
+
+ testAction(actions.deleteImage, payload, state, [
+ {
+ type: types.RECEIVE_METRIC_DELETE_SUCCESS,
+ payload,
+ },
+ ]);
+ });
+ });
+
+ describe('initial data', () => {
+ it('should set the initial data correctly', () => {
+ testAction(actions.setInitialData, initialData, state, [
+ { type: types.SET_INITIAL_DATA, payload: initialData },
+ ]);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/metric_images/store/mutations_spec.js b/spec/frontend/vue_shared/components/metric_images/store/mutations_spec.js
new file mode 100644
index 00000000000..754f729e657
--- /dev/null
+++ b/spec/frontend/vue_shared/components/metric_images/store/mutations_spec.js
@@ -0,0 +1,147 @@
+import { cloneDeep } from 'lodash';
+import * as types from '~/vue_shared/components/metric_images/store/mutation_types';
+import mutations from '~/vue_shared/components/metric_images/store/mutations';
+import { initialData } from '../mock_data';
+
+const defaultState = {
+ metricImages: [],
+ isLoadingMetricImages: false,
+ isUploadingImage: false,
+};
+
+const testImages = [
+ { filename: 'test.filename', id: 5, filePath: 'test/file/path', url: null },
+ { filename: 'second.filename', id: 6, filePath: 'second/file/path', url: 'test/url' },
+ { filename: 'third.filename', id: 7, filePath: 'third/file/path', url: 'test/url' },
+];
+
+describe('Metric images mutations', () => {
+ let state;
+
+ const createState = (customState = {}) => {
+ state = {
+ ...cloneDeep(defaultState),
+ ...customState,
+ };
+ };
+
+ beforeEach(() => {
+ createState();
+ });
+
+ describe('REQUEST_METRIC_IMAGES', () => {
+ beforeEach(() => {
+ mutations[types.REQUEST_METRIC_IMAGES](state);
+ });
+
+ it('should set the loading state', () => {
+ expect(state.isLoadingMetricImages).toBe(true);
+ });
+ });
+
+ describe('RECEIVE_METRIC_IMAGES_SUCCESS', () => {
+ beforeEach(() => {
+ mutations[types.RECEIVE_METRIC_IMAGES_SUCCESS](state, testImages);
+ });
+
+ it('should unset the loading state', () => {
+ expect(state.isLoadingMetricImages).toBe(false);
+ });
+
+ it('should set the metric images', () => {
+ expect(state.metricImages).toEqual(testImages);
+ });
+ });
+
+ describe('RECEIVE_METRIC_IMAGES_ERROR', () => {
+ beforeEach(() => {
+ mutations[types.RECEIVE_METRIC_IMAGES_ERROR](state);
+ });
+
+ it('should unset the loading state', () => {
+ expect(state.isLoadingMetricImages).toBe(false);
+ });
+ });
+
+ describe('REQUEST_METRIC_UPLOAD', () => {
+ beforeEach(() => {
+ mutations[types.REQUEST_METRIC_UPLOAD](state);
+ });
+
+ it('should set the loading state', () => {
+ expect(state.isUploadingImage).toBe(true);
+ });
+ });
+
+ describe('RECEIVE_METRIC_UPLOAD_SUCCESS', () => {
+ const initialImage = testImages[0];
+ const newImage = testImages[1];
+
+ beforeEach(() => {
+ createState({ metricImages: [initialImage] });
+ mutations[types.RECEIVE_METRIC_UPLOAD_SUCCESS](state, newImage);
+ });
+
+ it('should unset the loading state', () => {
+ expect(state.isUploadingImage).toBe(false);
+ });
+
+ it('should add the new metric image after the existing one', () => {
+ expect(state.metricImages).toMatchObject([initialImage, newImage]);
+ });
+ });
+
+ describe('RECEIVE_METRIC_UPLOAD_ERROR', () => {
+ beforeEach(() => {
+ mutations[types.RECEIVE_METRIC_UPLOAD_ERROR](state);
+ });
+
+ it('should unset the loading state', () => {
+ expect(state.isUploadingImage).toBe(false);
+ });
+ });
+
+ describe('RECEIVE_METRIC_UPDATE_SUCCESS', () => {
+ const initialImage = testImages[0];
+ const newImage = testImages[0];
+ newImage.url = 'https://www.gitlab.com';
+
+ beforeEach(() => {
+ createState({ metricImages: [initialImage] });
+ mutations[types.RECEIVE_METRIC_UPDATE_SUCCESS](state, newImage);
+ });
+
+ it('should unset the loading state', () => {
+ expect(state.isUploadingImage).toBe(false);
+ });
+
+ it('should replace the existing image with the new one', () => {
+ expect(state.metricImages).toMatchObject([newImage]);
+ });
+ });
+
+ describe('RECEIVE_METRIC_DELETE_SUCCESS', () => {
+ const deletedImageId = testImages[1].id;
+ const expectedResult = [testImages[0], testImages[2]];
+
+ beforeEach(() => {
+ createState({ metricImages: [...testImages] });
+ mutations[types.RECEIVE_METRIC_DELETE_SUCCESS](state, deletedImageId);
+ });
+
+ it('should remove the correct metric image', () => {
+ expect(state.metricImages).toEqual(expectedResult);
+ });
+ });
+
+ describe('SET_INITIAL_DATA', () => {
+ beforeEach(() => {
+ mutations[types.SET_INITIAL_DATA](state, initialData);
+ });
+
+ it('should unset the loading state', () => {
+ expect(state.modelIid).toBe(initialData.modelIid);
+ expect(state.projectId).toBe(initialData.projectId);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js b/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js
index c8dab0204d3..6881cb79740 100644
--- a/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js
+++ b/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js
@@ -3,7 +3,7 @@ import Vue from 'vue';
import Vuex from 'vuex';
import IssuePlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
-import { userDataMock } from '../../../notes/mock_data';
+import { userDataMock } from 'jest/notes/mock_data';
Vue.use(Vuex);
diff --git a/spec/frontend/vue_shared/components/project_avatar/default_spec.js b/spec/frontend/vue_shared/components/project_avatar/default_spec.js
deleted file mode 100644
index d042db6051c..00000000000
--- a/spec/frontend/vue_shared/components/project_avatar/default_spec.js
+++ /dev/null
@@ -1,50 +0,0 @@
-import Vue, { nextTick } from 'vue';
-import mountComponent from 'helpers/vue_mount_component_helper';
-import { projectData } from 'jest/ide/mock_data';
-import { TEST_HOST } from 'spec/test_constants';
-import { getFirstCharacterCapitalized } from '~/lib/utils/text_utility';
-import ProjectAvatarDefault from '~/vue_shared/components/deprecated_project_avatar/default.vue';
-
-describe('ProjectAvatarDefault component', () => {
- const Component = Vue.extend(ProjectAvatarDefault);
- let vm;
-
- beforeEach(() => {
- vm = mountComponent(Component, {
- project: projectData,
- });
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- it('renders identicon if project has no avatar_url', async () => {
- const expectedText = getFirstCharacterCapitalized(projectData.name);
-
- vm.project = {
- ...vm.project,
- avatar_url: null,
- };
-
- await nextTick();
- const identiconEl = vm.$el.querySelector('.identicon');
-
- expect(identiconEl).not.toBe(null);
- expect(identiconEl.textContent.trim()).toEqual(expectedText);
- });
-
- it('renders avatar image if project has avatar_url', async () => {
- const avatarUrl = `${TEST_HOST}/images/home/nasa.svg`;
-
- vm.project = {
- ...vm.project,
- avatar_url: avatarUrl,
- };
-
- await nextTick();
- expect(vm.$el.querySelector('.avatar')).not.toBeNull();
- expect(vm.$el.querySelector('.identicon')).toBeNull();
- expect(vm.$el.querySelector('img')).toHaveAttr('src', avatarUrl);
- });
-});
diff --git a/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js b/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js
index 5afa017aa76..397ab2254b9 100644
--- a/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js
+++ b/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js
@@ -2,7 +2,7 @@ import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import mockProjects from 'test_fixtures_static/projects.json';
import { trimText } from 'helpers/text_helper';
-import ProjectAvatar from '~/vue_shared/components/deprecated_project_avatar/default.vue';
+import ProjectAvatar from '~/vue_shared/components/project_avatar.vue';
import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue';
describe('ProjectListItem component', () => {
@@ -52,8 +52,13 @@ describe('ProjectListItem component', () => {
it(`renders the project avatar`, () => {
wrapper = shallowMount(Component, options);
+ const avatar = wrapper.findComponent(ProjectAvatar);
- expect(wrapper.findComponent(ProjectAvatar).exists()).toBe(true);
+ expect(avatar.exists()).toBe(true);
+ expect(avatar.props()).toMatchObject({
+ projectAvatarUrl: '',
+ projectName: project.name_with_namespace,
+ });
});
it(`renders a simple namespace name with a trailing slash`, () => {
diff --git a/spec/frontend/vue_shared/components/registry/persisted_dropdown_selection_spec.js b/spec/frontend/vue_shared/components/registry/persisted_dropdown_selection_spec.js
index c65ded000d3..616fefe847e 100644
--- a/spec/frontend/vue_shared/components/registry/persisted_dropdown_selection_spec.js
+++ b/spec/frontend/vue_shared/components/registry/persisted_dropdown_selection_spec.js
@@ -36,10 +36,10 @@ describe('Persisted dropdown selection', () => {
});
describe('local storage sync', () => {
- it('uses the local storage sync component', () => {
+ it('uses the local storage sync component with the correct props', () => {
createComponent();
- expect(findLocalStorageSync().exists()).toBe(true);
+ expect(findLocalStorageSync().props('asString')).toBe(true);
});
it('passes the right props', () => {
diff --git a/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap b/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap
index 6954bd5ccff..ac313e556fc 100644
--- a/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap
+++ b/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap
@@ -42,7 +42,7 @@ exports[`RunnerAwsDeploymentsModal renders the modal 1`] = `
<gl-accordion-item-stub
class="gl-font-weight-normal"
title="More Details"
- title-visible="Less Details"
+ titlevisible="Less Details"
>
<p
class="gl-pt-2"
@@ -76,7 +76,7 @@ exports[`RunnerAwsDeploymentsModal renders the modal 1`] = `
<gl-accordion-item-stub
class="gl-font-weight-normal"
title="More Details"
- title-visible="Less Details"
+ titlevisible="Less Details"
>
<p
class="gl-pt-2"
@@ -110,7 +110,7 @@ exports[`RunnerAwsDeploymentsModal renders the modal 1`] = `
<gl-accordion-item-stub
class="gl-font-weight-normal"
title="More Details"
- title-visible="Less Details"
+ titlevisible="Less Details"
>
<p
class="gl-pt-2"
@@ -144,7 +144,7 @@ exports[`RunnerAwsDeploymentsModal renders the modal 1`] = `
<gl-accordion-item-stub
class="gl-font-weight-normal"
title="More Details"
- title-visible="Less Details"
+ titlevisible="Less Details"
>
<p
class="gl-pt-2"
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js
index 2e4c056df61..2bc513e87bf 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js
@@ -21,87 +21,81 @@ describe('LabelsSelect Actions', () => {
});
describe('setInitialState', () => {
- it('sets initial store state', (done) => {
- testAction(
+ it('sets initial store state', () => {
+ return testAction(
actions.setInitialState,
mockInitialState,
state,
[{ type: types.SET_INITIAL_STATE, payload: mockInitialState }],
[],
- done,
);
});
});
describe('toggleDropdownButton', () => {
- it('toggles dropdown button', (done) => {
- testAction(
+ it('toggles dropdown button', () => {
+ return testAction(
actions.toggleDropdownButton,
{},
state,
[{ type: types.TOGGLE_DROPDOWN_BUTTON }],
[],
- done,
);
});
});
describe('toggleDropdownContents', () => {
- it('toggles dropdown contents', (done) => {
- testAction(
+ it('toggles dropdown contents', () => {
+ return testAction(
actions.toggleDropdownContents,
{},
state,
[{ type: types.TOGGLE_DROPDOWN_CONTENTS }],
[],
- done,
);
});
});
describe('toggleDropdownContentsCreateView', () => {
- it('toggles dropdown create view', (done) => {
- testAction(
+ it('toggles dropdown create view', () => {
+ return testAction(
actions.toggleDropdownContentsCreateView,
{},
state,
[{ type: types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW }],
[],
- done,
);
});
});
describe('requestLabels', () => {
- it('sets value of `state.labelsFetchInProgress` to `true`', (done) => {
- testAction(actions.requestLabels, {}, state, [{ type: types.REQUEST_LABELS }], [], done);
+ it('sets value of `state.labelsFetchInProgress` to `true`', () => {
+ return testAction(actions.requestLabels, {}, state, [{ type: types.REQUEST_LABELS }], []);
});
});
describe('receiveLabelsSuccess', () => {
- it('sets provided labels to `state.labels`', (done) => {
+ it('sets provided labels to `state.labels`', () => {
const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
- testAction(
+ return testAction(
actions.receiveLabelsSuccess,
labels,
state,
[{ type: types.RECEIVE_SET_LABELS_SUCCESS, payload: labels }],
[],
- done,
);
});
});
describe('receiveLabelsFailure', () => {
- it('sets value `state.labelsFetchInProgress` to `false`', (done) => {
- testAction(
+ it('sets value `state.labelsFetchInProgress` to `false`', () => {
+ return testAction(
actions.receiveLabelsFailure,
{},
state,
[{ type: types.RECEIVE_SET_LABELS_FAILURE }],
[],
- done,
);
});
@@ -125,72 +119,67 @@ describe('LabelsSelect Actions', () => {
});
describe('on success', () => {
- it('dispatches `requestLabels` & `receiveLabelsSuccess` actions', (done) => {
+ it('dispatches `requestLabels` & `receiveLabelsSuccess` actions', () => {
const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
mock.onGet(/labels.json/).replyOnce(200, labels);
- testAction(
+ return testAction(
actions.fetchLabels,
{},
state,
[],
[{ type: 'requestLabels' }, { type: 'receiveLabelsSuccess', payload: labels }],
- done,
);
});
});
describe('on failure', () => {
- it('dispatches `requestLabels` & `receiveLabelsFailure` actions', (done) => {
+ it('dispatches `requestLabels` & `receiveLabelsFailure` actions', () => {
mock.onGet(/labels.json/).replyOnce(500, {});
- testAction(
+ return testAction(
actions.fetchLabels,
{},
state,
[],
[{ type: 'requestLabels' }, { type: 'receiveLabelsFailure' }],
- done,
);
});
});
});
describe('requestCreateLabel', () => {
- it('sets value `state.labelCreateInProgress` to `true`', (done) => {
- testAction(
+ it('sets value `state.labelCreateInProgress` to `true`', () => {
+ return testAction(
actions.requestCreateLabel,
{},
state,
[{ type: types.REQUEST_CREATE_LABEL }],
[],
- done,
);
});
});
describe('receiveCreateLabelSuccess', () => {
- it('sets value `state.labelCreateInProgress` to `false`', (done) => {
- testAction(
+ it('sets value `state.labelCreateInProgress` to `false`', () => {
+ return testAction(
actions.receiveCreateLabelSuccess,
{},
state,
[{ type: types.RECEIVE_CREATE_LABEL_SUCCESS }],
[],
- done,
);
});
});
describe('receiveCreateLabelFailure', () => {
- it('sets value `state.labelCreateInProgress` to `false`', (done) => {
- testAction(
+ it('sets value `state.labelCreateInProgress` to `false`', () => {
+ return testAction(
actions.receiveCreateLabelFailure,
{},
state,
[{ type: types.RECEIVE_CREATE_LABEL_FAILURE }],
[],
- done,
);
});
@@ -214,11 +203,11 @@ describe('LabelsSelect Actions', () => {
});
describe('on success', () => {
- it('dispatches `requestCreateLabel`, `fetchLabels` & `receiveCreateLabelSuccess` & `toggleDropdownContentsCreateView` actions', (done) => {
+ it('dispatches `requestCreateLabel`, `fetchLabels` & `receiveCreateLabelSuccess` & `toggleDropdownContentsCreateView` actions', () => {
const label = { id: 1 };
mock.onPost(/labels.json/).replyOnce(200, label);
- testAction(
+ return testAction(
actions.createLabel,
{},
state,
@@ -229,38 +218,35 @@ describe('LabelsSelect Actions', () => {
{ type: 'receiveCreateLabelSuccess' },
{ type: 'toggleDropdownContentsCreateView' },
],
- done,
);
});
});
describe('on failure', () => {
- it('dispatches `requestCreateLabel` & `receiveCreateLabelFailure` actions', (done) => {
+ it('dispatches `requestCreateLabel` & `receiveCreateLabelFailure` actions', () => {
mock.onPost(/labels.json/).replyOnce(500, {});
- testAction(
+ return testAction(
actions.createLabel,
{},
state,
[],
[{ type: 'requestCreateLabel' }, { type: 'receiveCreateLabelFailure' }],
- done,
);
});
});
});
describe('updateSelectedLabels', () => {
- it('updates `state.labels` based on provided `labels` param', (done) => {
+ it('updates `state.labels` based on provided `labels` param', () => {
const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
- testAction(
+ return testAction(
actions.updateSelectedLabels,
labels,
state,
[{ type: types.UPDATE_SELECTED_LABELS, payload: { labels } }],
[],
- done,
);
});
});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js
index 67e1a3ce932..1b27a294b90 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js
@@ -11,9 +11,15 @@ import DropdownValue from '~/vue_shared/components/sidebar/labels_select_widget/
import issueLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql';
import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql';
import updateMergeRequestLabelsMutation from '~/sidebar/queries/update_merge_request_labels.mutation.graphql';
+import issuableLabelsSubscription from 'ee_else_ce/sidebar/queries/issuable_labels.subscription.graphql';
import updateEpicLabelsMutation from '~/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql';
import LabelsSelectRoot from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue';
-import { mockConfig, issuableLabelsQueryResponse, updateLabelsMutationResponse } from './mock_data';
+import {
+ mockConfig,
+ issuableLabelsQueryResponse,
+ updateLabelsMutationResponse,
+ issuableLabelsSubscriptionResponse,
+} from './mock_data';
jest.mock('~/flash');
@@ -21,6 +27,7 @@ Vue.use(VueApollo);
const successfulQueryHandler = jest.fn().mockResolvedValue(issuableLabelsQueryResponse);
const successfulMutationHandler = jest.fn().mockResolvedValue(updateLabelsMutationResponse);
+const subscriptionHandler = jest.fn().mockResolvedValue(issuableLabelsSubscriptionResponse);
const errorQueryHandler = jest.fn().mockRejectedValue('Houston, we have a problem');
const updateLabelsMutation = {
@@ -42,10 +49,12 @@ describe('LabelsSelectRoot', () => {
issuableType = IssuableType.Issue,
queryHandler = successfulQueryHandler,
mutationHandler = successfulMutationHandler,
+ isRealtimeEnabled = false,
} = {}) => {
const mockApollo = createMockApollo([
[issueLabelsQuery, queryHandler],
[updateLabelsMutation[issuableType], mutationHandler],
+ [issuableLabelsSubscription, subscriptionHandler],
]);
wrapper = shallowMount(LabelsSelectRoot, {
@@ -65,6 +74,9 @@ describe('LabelsSelectRoot', () => {
allowLabelEdit: true,
allowLabelCreate: true,
labelsManagePath: 'test',
+ glFeatures: {
+ realtimeLabels: isRealtimeEnabled,
+ },
},
});
};
@@ -190,5 +202,26 @@ describe('LabelsSelectRoot', () => {
message: 'An error occurred while updating labels.',
});
});
+
+ it('does not emit `updateSelectedLabels` event when the subscription is triggered and FF is disabled', async () => {
+ createComponent();
+ await waitForPromises();
+
+ expect(wrapper.emitted('updateSelectedLabels')).toBeUndefined();
+ });
+
+ it('emits `updateSelectedLabels` event when the subscription is triggered and FF is enabled', async () => {
+ createComponent({ isRealtimeEnabled: true });
+ await waitForPromises();
+
+ expect(wrapper.emitted('updateSelectedLabels')).toEqual([
+ [
+ {
+ id: '1',
+ labels: issuableLabelsSubscriptionResponse.data.issuableLabelsUpdated.labels.nodes,
+ },
+ ],
+ ]);
+ });
});
});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js
index 49224fb915c..afad9314ace 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js
@@ -141,6 +141,34 @@ export const issuableLabelsQueryResponse = {
},
};
+export const issuableLabelsSubscriptionResponse = {
+ data: {
+ issuableLabelsUpdated: {
+ id: '1',
+ labels: {
+ nodes: [
+ {
+ __typename: 'Label',
+ color: '#330066',
+ description: null,
+ id: 'gid://gitlab/ProjectLabel/1',
+ title: 'Label1',
+ textColor: '#000000',
+ },
+ {
+ __typename: 'Label',
+ color: '#000000',
+ description: null,
+ id: 'gid://gitlab/ProjectLabel/2',
+ title: 'Label2',
+ textColor: '#ffffff',
+ },
+ ],
+ },
+ },
+ },
+};
+
export const updateLabelsMutationResponse = {
data: {
updateIssuableLabels: {
diff --git a/spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js b/spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js
new file mode 100644
index 00000000000..eb2eec92534
--- /dev/null
+++ b/spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js
@@ -0,0 +1,69 @@
+import { GlLink } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import ChunkLine from '~/vue_shared/components/source_viewer/components/chunk_line.vue';
+import {
+ BIDI_CHARS,
+ BIDI_CHARS_CLASS_LIST,
+ BIDI_CHAR_TOOLTIP,
+} from '~/vue_shared/components/source_viewer/constants';
+
+const DEFAULT_PROPS = {
+ number: 2,
+ content: '// Line content',
+ language: 'javascript',
+};
+
+describe('Chunk Line component', () => {
+ let wrapper;
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMountExtended(ChunkLine, { propsData: { ...DEFAULT_PROPS, ...props } });
+ };
+
+ const findLink = () => wrapper.findComponent(GlLink);
+ const findContent = () => wrapper.findByTestId('content');
+ const findWrappedBidiChars = () => wrapper.findAllByTestId('bidi-wrapper');
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => wrapper.destroy());
+
+ describe('rendering', () => {
+ it('wraps BiDi characters', () => {
+ const content = `// some content ${BIDI_CHARS.toString()} with BiDi chars`;
+ createComponent({ content });
+ const wrappedBidiChars = findWrappedBidiChars();
+
+ expect(wrappedBidiChars.length).toBe(BIDI_CHARS.length);
+
+ wrappedBidiChars.wrappers.forEach((_, i) => {
+ expect(wrappedBidiChars.at(i).text()).toBe(BIDI_CHARS[i]);
+ expect(wrappedBidiChars.at(i).attributes()).toMatchObject({
+ class: BIDI_CHARS_CLASS_LIST,
+ title: BIDI_CHAR_TOOLTIP,
+ });
+ });
+ });
+
+ it('renders a line number', () => {
+ expect(findLink().attributes()).toMatchObject({
+ 'data-line-number': `${DEFAULT_PROPS.number}`,
+ to: `#L${DEFAULT_PROPS.number}`,
+ id: `L${DEFAULT_PROPS.number}`,
+ });
+
+ expect(findLink().text()).toBe(DEFAULT_PROPS.number.toString());
+ });
+
+ it('renders content', () => {
+ expect(findContent().attributes()).toMatchObject({
+ id: `LC${DEFAULT_PROPS.number}`,
+ lang: DEFAULT_PROPS.language,
+ });
+
+ expect(findContent().text()).toBe(DEFAULT_PROPS.content);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js b/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js
new file mode 100644
index 00000000000..42c4f2eacb8
--- /dev/null
+++ b/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js
@@ -0,0 +1,82 @@
+import { GlIntersectionObserver } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import Chunk from '~/vue_shared/components/source_viewer/components/chunk.vue';
+import ChunkLine from '~/vue_shared/components/source_viewer/components/chunk_line.vue';
+
+const DEFAULT_PROPS = {
+ chunkIndex: 2,
+ isHighlighted: false,
+ content: '// Line 1 content \n // Line 2 content',
+ startingFrom: 140,
+ totalLines: 50,
+ language: 'javascript',
+};
+
+describe('Chunk component', () => {
+ let wrapper;
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMountExtended(Chunk, { propsData: { ...DEFAULT_PROPS, ...props } });
+ };
+
+ const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver);
+ const findChunkLines = () => wrapper.findAllComponents(ChunkLine);
+ const findLineNumbers = () => wrapper.findAllByTestId('line-number');
+ const findContent = () => wrapper.findByTestId('content');
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => wrapper.destroy());
+
+ describe('Intersection observer', () => {
+ it('renders an Intersection observer component', () => {
+ expect(findIntersectionObserver().exists()).toBe(true);
+ });
+
+ it('emits an appear event when intersection-observer appears', () => {
+ findIntersectionObserver().vm.$emit('appear');
+
+ expect(wrapper.emitted('appear')).toEqual([[DEFAULT_PROPS.chunkIndex]]);
+ });
+
+ it('does not emit an appear event is isHighlighted is true', () => {
+ createComponent({ isHighlighted: true });
+ findIntersectionObserver().vm.$emit('appear');
+
+ expect(wrapper.emitted('appear')).toEqual(undefined);
+ });
+ });
+
+ describe('rendering', () => {
+ it('does not render a Chunk Line component if isHighlighted is false', () => {
+ expect(findChunkLines().length).toBe(0);
+ });
+
+ it('renders simplified line numbers and content if isHighlighted is false', () => {
+ expect(findLineNumbers().length).toBe(DEFAULT_PROPS.totalLines);
+
+ expect(findLineNumbers().at(0).attributes()).toMatchObject({
+ 'data-line-number': `${DEFAULT_PROPS.startingFrom + 1}`,
+ href: `#L${DEFAULT_PROPS.startingFrom + 1}`,
+ id: `L${DEFAULT_PROPS.startingFrom + 1}`,
+ });
+
+ expect(findContent().text()).toBe(DEFAULT_PROPS.content);
+ });
+
+ it('renders Chunk Line components if isHighlighted is true', () => {
+ const splitContent = DEFAULT_PROPS.content.split('\n');
+ createComponent({ isHighlighted: true });
+
+ expect(findChunkLines().length).toBe(splitContent.length);
+
+ expect(findChunkLines().at(0).props()).toMatchObject({
+ number: DEFAULT_PROPS.startingFrom + 1,
+ content: splitContent[0],
+ language: DEFAULT_PROPS.language,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js
index ab579945e22..6a9ea75127d 100644
--- a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js
+++ b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js
@@ -1,24 +1,38 @@
import hljs from 'highlight.js/lib/core';
-import { GlLoadingIcon } from '@gitlab/ui';
-import Vue, { nextTick } from 'vue';
+import Vue from 'vue';
import VueRouter from 'vue-router';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import SourceViewer from '~/vue_shared/components/source_viewer/source_viewer.vue';
+import Chunk from '~/vue_shared/components/source_viewer/components/chunk.vue';
import { ROUGE_TO_HLJS_LANGUAGE_MAP } from '~/vue_shared/components/source_viewer/constants';
-import LineNumbers from '~/vue_shared/components/line_numbers.vue';
import waitForPromises from 'helpers/wait_for_promises';
-import * as sourceViewerUtils from '~/vue_shared/components/source_viewer/utils';
+import LineHighlighter from '~/blob/line_highlighter';
+import eventHub from '~/notes/event_hub';
+jest.mock('~/blob/line_highlighter');
jest.mock('highlight.js/lib/core');
Vue.use(VueRouter);
const router = new VueRouter();
+const generateContent = (content, totalLines = 1) => {
+ let generatedContent = '';
+ for (let i = 0; i < totalLines; i += 1) {
+ generatedContent += `Line: ${i + 1} = ${content}\n`;
+ }
+ return generatedContent;
+};
+
+const execImmediately = (callback) => callback();
+
describe('Source Viewer component', () => {
let wrapper;
const language = 'docker';
const mappedLanguage = ROUGE_TO_HLJS_LANGUAGE_MAP[language];
- const content = `// Some source code`;
- const DEFAULT_BLOB_DATA = { language, rawTextBlob: content };
+ const chunk1 = generateContent('// Some source code 1', 70);
+ const chunk2 = generateContent('// Some source code 2', 70);
+ const content = chunk1 + chunk2;
+ const path = 'some/path.js';
+ const DEFAULT_BLOB_DATA = { language, rawTextBlob: content, path };
const highlightedContent = `<span data-testid='test-highlighted' id='LC1'>${content}</span><span id='LC2'></span>`;
const createComponent = async (blob = {}) => {
@@ -29,15 +43,13 @@ describe('Source Viewer component', () => {
await waitForPromises();
};
- const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
- const findLineNumbers = () => wrapper.findComponent(LineNumbers);
- const findHighlightedContent = () => wrapper.findByTestId('test-highlighted');
- const findFirstLine = () => wrapper.find('#LC1');
+ const findChunks = () => wrapper.findAllComponents(Chunk);
beforeEach(() => {
hljs.highlight.mockImplementation(() => ({ value: highlightedContent }));
hljs.highlightAuto.mockImplementation(() => ({ value: highlightedContent }));
- jest.spyOn(sourceViewerUtils, 'wrapLines');
+ jest.spyOn(window, 'requestIdleCallback').mockImplementation(execImmediately);
+ jest.spyOn(eventHub, '$emit');
return createComponent();
});
@@ -45,6 +57,8 @@ describe('Source Viewer component', () => {
afterEach(() => wrapper.destroy());
describe('highlight.js', () => {
+ beforeEach(() => createComponent({ language: mappedLanguage }));
+
it('registers the language definition', async () => {
const languageDefinition = await import(`highlight.js/lib/languages/${mappedLanguage}`);
@@ -54,72 +68,51 @@ describe('Source Viewer component', () => {
);
});
- it('highlights the content', () => {
- expect(hljs.highlight).toHaveBeenCalledWith(content, { language: mappedLanguage });
+ it('highlights the first chunk', () => {
+ expect(hljs.highlight).toHaveBeenCalledWith(chunk1.trim(), { language: mappedLanguage });
});
describe('auto-detects if a language cannot be loaded', () => {
beforeEach(() => createComponent({ language: 'some_unknown_language' }));
it('highlights the content with auto-detection', () => {
- expect(hljs.highlightAuto).toHaveBeenCalledWith(content);
+ expect(hljs.highlightAuto).toHaveBeenCalledWith(chunk1.trim());
});
});
});
describe('rendering', () => {
- it('renders a loading icon if no highlighted content is available yet', async () => {
- hljs.highlight.mockImplementation(() => ({ value: null }));
- await createComponent();
-
- expect(findLoadingIcon().exists()).toBe(true);
- });
+ it('renders the first chunk', async () => {
+ const firstChunk = findChunks().at(0);
- it('calls the wrapLines helper method with highlightedContent and mappedLanguage', () => {
- expect(sourceViewerUtils.wrapLines).toHaveBeenCalledWith(highlightedContent, mappedLanguage);
- });
-
- it('renders Line Numbers', () => {
- expect(findLineNumbers().props('lines')).toBe(1);
- });
+ expect(firstChunk.props('content')).toContain(chunk1);
- it('renders the highlighted content', () => {
- expect(findHighlightedContent().exists()).toBe(true);
+ expect(firstChunk.props()).toMatchObject({
+ totalLines: 70,
+ startingFrom: 0,
+ });
});
- });
- describe('selecting a line', () => {
- let firstLine;
- let firstLineElement;
+ it('renders the second chunk', async () => {
+ const secondChunk = findChunks().at(1);
- beforeEach(() => {
- firstLine = findFirstLine();
- firstLineElement = firstLine.element;
+ expect(secondChunk.props('content')).toContain(chunk2.trim());
- jest.spyOn(firstLineElement, 'scrollIntoView');
- jest.spyOn(firstLineElement.classList, 'add');
- jest.spyOn(firstLineElement.classList, 'remove');
- });
-
- it('adds the highlight (hll) class', async () => {
- wrapper.vm.$router.push('#LC1');
- await nextTick();
-
- expect(firstLineElement.classList.add).toHaveBeenCalledWith('hll');
+ expect(secondChunk.props()).toMatchObject({
+ totalLines: 70,
+ startingFrom: 70,
+ });
});
+ });
- it('removes the highlight (hll) class from a previously highlighted line', async () => {
- wrapper.vm.$router.push('#LC2');
- await nextTick();
-
- expect(firstLineElement.classList.remove).toHaveBeenCalledWith('hll');
- });
+ it('emits showBlobInteractionZones on the eventHub when chunk appears', () => {
+ findChunks().at(0).vm.$emit('appear');
+ expect(eventHub.$emit).toBeCalledWith('showBlobInteractionZones', path);
+ });
- it('scrolls the line into view', () => {
- expect(firstLineElement.scrollIntoView).toHaveBeenCalledWith({
- behavior: 'smooth',
- block: 'center',
- });
+ describe('LineHighlighter', () => {
+ it('instantiates the lineHighlighter class', async () => {
+ expect(LineHighlighter).toHaveBeenCalledWith({ scrollBehavior: 'auto' });
});
});
});
diff --git a/spec/frontend/vue_shared/components/source_viewer/utils_spec.js b/spec/frontend/vue_shared/components/source_viewer/utils_spec.js
deleted file mode 100644
index 0631e7efd54..00000000000
--- a/spec/frontend/vue_shared/components/source_viewer/utils_spec.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import { wrapLines } from '~/vue_shared/components/source_viewer/utils';
-
-describe('Wrap lines', () => {
- it.each`
- content | language | output
- ${'line 1'} | ${'javascript'} | ${'<span id="LC1" lang="javascript" class="line">line 1</span>'}
- ${'line 1\nline 2'} | ${'html'} | ${`<span id="LC1" lang="html" class="line">line 1</span>\n<span id="LC2" lang="html" class="line">line 2</span>`}
- ${'<span class="hljs-code">line 1\nline 2</span>'} | ${'html'} | ${`<span id="LC1" lang="html" class="hljs-code">line 1\n<span id="LC2" lang="html" class="line">line 2</span></span>`}
- ${'<span class="hljs-code">```bash'} | ${'bash'} | ${'<span id="LC1" lang="bash" class="hljs-code">```bash'}
- ${'<span class="hljs-code">```bash'} | ${'valid-language1'} | ${'<span id="LC1" lang="valid-language1" class="hljs-code">```bash'}
- ${'<span class="hljs-code">```bash'} | ${'valid_language2'} | ${'<span id="LC1" lang="valid_language2" class="hljs-code">```bash'}
- `('returns lines wrapped in spans containing line numbers', ({ content, language, output }) => {
- expect(wrapLines(content, language)).toBe(output);
- });
-
- it.each`
- language
- ${'invalidLanguage>'}
- ${'"invalidLanguage"'}
- ${'<invalidLanguage'}
- `('returns lines safely without XSS language is not valid', ({ language }) => {
- expect(wrapLines('<span class="hljs-code">```bash', language)).toBe(
- '<span id="LC1" lang="" class="hljs-code">```bash',
- );
- });
-});
diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_new_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_new_spec.js
index f624f84eabd..5e05b54cb8c 100644
--- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_new_spec.js
+++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_new_spec.js
@@ -109,19 +109,33 @@ describe('User Avatar Image Component', () => {
default: ['Action!'],
};
- beforeEach(() => {
- wrapper = shallowMount(UserAvatarImage, {
- propsData: PROVIDED_PROPS,
- slots,
+ describe('when `tooltipText` is provided and no default slot', () => {
+ beforeEach(() => {
+ wrapper = shallowMount(UserAvatarImage, {
+ propsData: { ...PROVIDED_PROPS },
+ });
});
- });
- it('renders the tooltip slot', () => {
- expect(wrapper.findComponent(GlTooltip).exists()).toBe(true);
+ it('renders the tooltip with `tooltipText` as content', () => {
+ expect(wrapper.findComponent(GlTooltip).text()).toBe(PROVIDED_PROPS.tooltipText);
+ });
});
- it('renders the tooltip content', () => {
- expect(wrapper.findComponent(GlTooltip).text()).toContain(slots.default[0]);
+ describe('when `tooltipText` and default slot is provided', () => {
+ beforeEach(() => {
+ wrapper = shallowMount(UserAvatarImage, {
+ propsData: { ...PROVIDED_PROPS },
+ slots,
+ });
+ });
+
+ it('does not render `tooltipText` inside the tooltip', () => {
+ expect(wrapper.findComponent(GlTooltip).text()).not.toBe(PROVIDED_PROPS.tooltipText);
+ });
+
+ it('renders the content provided via default slot', () => {
+ expect(wrapper.findComponent(GlTooltip).text()).toContain(slots.default[0]);
+ });
});
});
});
diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_old_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_old_spec.js
index 5051b2b9cae..2c1be6ec47e 100644
--- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_old_spec.js
+++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_old_spec.js
@@ -90,33 +90,38 @@ describe('User Avatar Image Component', () => {
});
});
- describe('dynamic tooltip content', () => {
- const props = PROVIDED_PROPS;
+ describe('Dynamic tooltip content', () => {
const slots = {
default: ['Action!'],
};
- beforeEach(() => {
- wrapper = shallowMount(UserAvatarImage, {
- propsData: { props },
- slots,
+ describe('when `tooltipText` is provided and no default slot', () => {
+ beforeEach(() => {
+ wrapper = shallowMount(UserAvatarImage, {
+ propsData: { ...PROVIDED_PROPS },
+ });
});
- });
- it('renders the tooltip slot', () => {
- expect(wrapper.findComponent(GlTooltip).exists()).toBe(true);
+ it('renders the tooltip with `tooltipText` as content', () => {
+ expect(wrapper.findComponent(GlTooltip).text()).toBe(PROVIDED_PROPS.tooltipText);
+ });
});
- it('renders the tooltip content', () => {
- expect(wrapper.findComponent(GlTooltip).text()).toContain(slots.default[0]);
- });
+ describe('when `tooltipText` and default slot is provided', () => {
+ beforeEach(() => {
+ wrapper = shallowMount(UserAvatarImage, {
+ propsData: { ...PROVIDED_PROPS },
+ slots,
+ });
+ });
- it('does not render tooltip data attributes on avatar image', () => {
- const avatarImg = wrapper.find('img');
+ it('does not render `tooltipText` inside the tooltip', () => {
+ expect(wrapper.findComponent(GlTooltip).text()).not.toBe(PROVIDED_PROPS.tooltipText);
+ });
- expect(avatarImg.attributes('title')).toBeFalsy();
- expect(avatarImg.attributes('data-placement')).not.toBeDefined();
- expect(avatarImg.attributes('data-container')).not.toBeDefined();
+ it('renders the content provided via default slot', () => {
+ expect(wrapper.findComponent(GlTooltip).text()).toContain(slots.default[0]);
+ });
});
});
});
diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js
index 66bb234aef6..20ff0848cff 100644
--- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js
+++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js
@@ -153,4 +153,29 @@ describe('UserAvatarList', () => {
});
});
});
+
+ describe('additional styling for the image', () => {
+ it('should not add CSS class when feature flag `glAvatarForAllUserAvatars` is disabled', () => {
+ factory({
+ propsData: { items: createList(1) },
+ });
+
+ const link = wrapper.findComponent(UserAvatarLink);
+ expect(link.props('imgCssClasses')).not.toBe('gl-mr-3');
+ });
+
+ it('should add CSS class when feature flag `glAvatarForAllUserAvatars` is enabled', () => {
+ factory({
+ propsData: { items: createList(1) },
+ provide: {
+ glFeatures: {
+ glAvatarForAllUserAvatars: true,
+ },
+ },
+ });
+
+ const link = wrapper.findComponent(UserAvatarLink);
+ expect(link.props('imgCssClasses')).toBe('gl-mr-3');
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/user_select_spec.js b/spec/frontend/vue_shared/components/user_select_spec.js
index cb476910944..ec9128d5e38 100644
--- a/spec/frontend/vue_shared/components/user_select_spec.js
+++ b/spec/frontend/vue_shared/components/user_select_spec.js
@@ -16,7 +16,7 @@ import {
searchResponseOnMR,
projectMembersResponse,
participantsQueryResponse,
-} from '../../sidebar/mock_data';
+} from 'jest/sidebar/mock_data';
const assignee = {
id: 'gid://gitlab/User/4',
diff --git a/spec/frontend/vue_shared/components/web_ide_link_spec.js b/spec/frontend/vue_shared/components/web_ide_link_spec.js
index e79935f8fa6..040461f6be4 100644
--- a/spec/frontend/vue_shared/components/web_ide_link_spec.js
+++ b/spec/frontend/vue_shared/components/web_ide_link_spec.js
@@ -261,7 +261,10 @@ describe('Web IDE link component', () => {
});
it('should update local storage when selection changes', async () => {
- expect(findLocalStorageSync().props('value')).toBe(ACTION_WEB_IDE.key);
+ expect(findLocalStorageSync().props()).toMatchObject({
+ asString: true,
+ value: ACTION_WEB_IDE.key,
+ });
findActionsButton().vm.$emit('select', ACTION_GITPOD.key);
diff --git a/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js
index 64823cd4c6c..058cb30c1d5 100644
--- a/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js
+++ b/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js
@@ -1,4 +1,9 @@
-import { GlAlert, GlKeysetPagination, GlSkeletonLoading, GlPagination } from '@gitlab/ui';
+import {
+ GlAlert,
+ GlKeysetPagination,
+ GlDeprecatedSkeletonLoading as GlSkeletonLoading,
+ GlPagination,
+} from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import VueDraggable from 'vuedraggable';
diff --git a/spec/frontend/vue_shared/security_reports/store/modules/sast/actions_spec.js b/spec/frontend/vue_shared/security_reports/store/modules/sast/actions_spec.js
index 6af07273cf6..46bfd7eceb1 100644
--- a/spec/frontend/vue_shared/security_reports/store/modules/sast/actions_spec.js
+++ b/spec/frontend/vue_shared/security_reports/store/modules/sast/actions_spec.js
@@ -26,8 +26,8 @@ describe('sast report actions', () => {
});
describe('setDiffEndpoint', () => {
- it(`should commit ${types.SET_DIFF_ENDPOINT} with the correct path`, (done) => {
- testAction(
+ it(`should commit ${types.SET_DIFF_ENDPOINT} with the correct path`, () => {
+ return testAction(
actions.setDiffEndpoint,
diffEndpoint,
state,
@@ -38,20 +38,19 @@ describe('sast report actions', () => {
},
],
[],
- done,
);
});
});
describe('requestDiff', () => {
- it(`should commit ${types.REQUEST_DIFF}`, (done) => {
- testAction(actions.requestDiff, {}, state, [{ type: types.REQUEST_DIFF }], [], done);
+ it(`should commit ${types.REQUEST_DIFF}`, () => {
+ return testAction(actions.requestDiff, {}, state, [{ type: types.REQUEST_DIFF }], []);
});
});
describe('receiveDiffSuccess', () => {
- it(`should commit ${types.RECEIVE_DIFF_SUCCESS} with the correct response`, (done) => {
- testAction(
+ it(`should commit ${types.RECEIVE_DIFF_SUCCESS} with the correct response`, () => {
+ return testAction(
actions.receiveDiffSuccess,
reports,
state,
@@ -62,14 +61,13 @@ describe('sast report actions', () => {
},
],
[],
- done,
);
});
});
describe('receiveDiffError', () => {
- it(`should commit ${types.RECEIVE_DIFF_ERROR} with the correct response`, (done) => {
- testAction(
+ it(`should commit ${types.RECEIVE_DIFF_ERROR} with the correct response`, () => {
+ return testAction(
actions.receiveDiffError,
error,
state,
@@ -80,7 +78,6 @@ describe('sast report actions', () => {
},
],
[],
- done,
);
});
});
@@ -107,9 +104,9 @@ describe('sast report actions', () => {
.replyOnce(200, reports.enrichData);
});
- it('should dispatch the `receiveDiffSuccess` action', (done) => {
+ it('should dispatch the `receiveDiffSuccess` action', () => {
const { diff, enrichData } = reports;
- testAction(
+ return testAction(
actions.fetchDiff,
{},
{ ...rootState, ...state },
@@ -124,7 +121,6 @@ describe('sast report actions', () => {
},
},
],
- done,
);
});
});
@@ -135,10 +131,10 @@ describe('sast report actions', () => {
mock.onGet(diffEndpoint).replyOnce(200, reports.diff);
});
- it('should dispatch the `receiveDiffSuccess` action with empty enrich data', (done) => {
+ it('should dispatch the `receiveDiffSuccess` action with empty enrich data', () => {
const { diff } = reports;
const enrichData = [];
- testAction(
+ return testAction(
actions.fetchDiff,
{},
{ ...rootState, ...state },
@@ -153,7 +149,6 @@ describe('sast report actions', () => {
},
},
],
- done,
);
});
});
@@ -167,14 +162,13 @@ describe('sast report actions', () => {
.replyOnce(404);
});
- it('should dispatch the `receiveError` action', (done) => {
- testAction(
+ it('should dispatch the `receiveError` action', () => {
+ return testAction(
actions.fetchDiff,
{},
{ ...rootState, ...state },
[],
[{ type: 'requestDiff' }, { type: 'receiveDiffError' }],
- done,
);
});
});
@@ -188,14 +182,13 @@ describe('sast report actions', () => {
.replyOnce(200, reports.enrichData);
});
- it('should dispatch the `receiveDiffError` action', (done) => {
- testAction(
+ it('should dispatch the `receiveDiffError` action', () => {
+ return testAction(
actions.fetchDiff,
{},
{ ...rootState, ...state },
[],
[{ type: 'requestDiff' }, { type: 'receiveDiffError' }],
- done,
);
});
});
diff --git a/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/actions_spec.js b/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/actions_spec.js
index d22fee864e7..4f4f653bb72 100644
--- a/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/actions_spec.js
+++ b/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/actions_spec.js
@@ -26,8 +26,8 @@ describe('secret detection report actions', () => {
});
describe('setDiffEndpoint', () => {
- it(`should commit ${types.SET_DIFF_ENDPOINT} with the correct path`, (done) => {
- testAction(
+ it(`should commit ${types.SET_DIFF_ENDPOINT} with the correct path`, () => {
+ return testAction(
actions.setDiffEndpoint,
diffEndpoint,
state,
@@ -38,20 +38,19 @@ describe('secret detection report actions', () => {
},
],
[],
- done,
);
});
});
describe('requestDiff', () => {
- it(`should commit ${types.REQUEST_DIFF}`, (done) => {
- testAction(actions.requestDiff, {}, state, [{ type: types.REQUEST_DIFF }], [], done);
+ it(`should commit ${types.REQUEST_DIFF}`, () => {
+ return testAction(actions.requestDiff, {}, state, [{ type: types.REQUEST_DIFF }], []);
});
});
describe('receiveDiffSuccess', () => {
- it(`should commit ${types.RECEIVE_DIFF_SUCCESS} with the correct response`, (done) => {
- testAction(
+ it(`should commit ${types.RECEIVE_DIFF_SUCCESS} with the correct response`, () => {
+ return testAction(
actions.receiveDiffSuccess,
reports,
state,
@@ -62,14 +61,13 @@ describe('secret detection report actions', () => {
},
],
[],
- done,
);
});
});
describe('receiveDiffError', () => {
- it(`should commit ${types.RECEIVE_DIFF_ERROR} with the correct response`, (done) => {
- testAction(
+ it(`should commit ${types.RECEIVE_DIFF_ERROR} with the correct response`, () => {
+ return testAction(
actions.receiveDiffError,
error,
state,
@@ -80,7 +78,6 @@ describe('secret detection report actions', () => {
},
],
[],
- done,
);
});
});
@@ -107,9 +104,10 @@ describe('secret detection report actions', () => {
.replyOnce(200, reports.enrichData);
});
- it('should dispatch the `receiveDiffSuccess` action', (done) => {
+ it('should dispatch the `receiveDiffSuccess` action', () => {
const { diff, enrichData } = reports;
- testAction(
+
+ return testAction(
actions.fetchDiff,
{},
{ ...rootState, ...state },
@@ -124,7 +122,6 @@ describe('secret detection report actions', () => {
},
},
],
- done,
);
});
});
@@ -135,10 +132,10 @@ describe('secret detection report actions', () => {
mock.onGet(diffEndpoint).replyOnce(200, reports.diff);
});
- it('should dispatch the `receiveDiffSuccess` action with empty enrich data', (done) => {
+ it('should dispatch the `receiveDiffSuccess` action with empty enrich data', () => {
const { diff } = reports;
const enrichData = [];
- testAction(
+ return testAction(
actions.fetchDiff,
{},
{ ...rootState, ...state },
@@ -153,7 +150,6 @@ describe('secret detection report actions', () => {
},
},
],
- done,
);
});
});
@@ -167,14 +163,13 @@ describe('secret detection report actions', () => {
.replyOnce(404);
});
- it('should dispatch the `receiveDiffError` action', (done) => {
- testAction(
+ it('should dispatch the `receiveDiffError` action', () => {
+ return testAction(
actions.fetchDiff,
{},
{ ...rootState, ...state },
[],
[{ type: 'requestDiff' }, { type: 'receiveDiffError' }],
- done,
);
});
});
@@ -188,14 +183,13 @@ describe('secret detection report actions', () => {
.replyOnce(200, reports.enrichData);
});
- it('should dispatch the `receiveDiffError` action', (done) => {
- testAction(
+ it('should dispatch the `receiveDiffError` action', () => {
+ return testAction(
actions.fetchDiff,
{},
{ ...rootState, ...state },
[],
[{ type: 'requestDiff' }, { type: 'receiveDiffError' }],
- done,
);
});
});
diff --git a/spec/frontend/vuex_shared/modules/modal/actions_spec.js b/spec/frontend/vuex_shared/modules/modal/actions_spec.js
index c151049df2d..928ed7d0d5f 100644
--- a/spec/frontend/vuex_shared/modules/modal/actions_spec.js
+++ b/spec/frontend/vuex_shared/modules/modal/actions_spec.js
@@ -4,28 +4,28 @@ import * as types from '~/vuex_shared/modules/modal/mutation_types';
describe('Vuex ModalModule actions', () => {
describe('open', () => {
- it('works', (done) => {
+ it('works', () => {
const data = { id: 7 };
- testAction(actions.open, data, {}, [{ type: types.OPEN, payload: data }], [], done);
+ return testAction(actions.open, data, {}, [{ type: types.OPEN, payload: data }], []);
});
});
describe('close', () => {
- it('works', (done) => {
- testAction(actions.close, null, {}, [{ type: types.CLOSE }], [], done);
+ it('works', () => {
+ return testAction(actions.close, null, {}, [{ type: types.CLOSE }], []);
});
});
describe('show', () => {
- it('works', (done) => {
- testAction(actions.show, null, {}, [{ type: types.SHOW }], [], done);
+ it('works', () => {
+ return testAction(actions.show, null, {}, [{ type: types.SHOW }], []);
});
});
describe('hide', () => {
- it('works', (done) => {
- testAction(actions.hide, null, {}, [{ type: types.HIDE }], [], done);
+ it('works', () => {
+ return testAction(actions.hide, null, {}, [{ type: types.HIDE }], []);
});
});
});
diff --git a/spec/frontend/work_items/components/item_title_spec.js b/spec/frontend/work_items/components/item_title_spec.js
index 0f6e7091c59..0d85df25b4f 100644
--- a/spec/frontend/work_items/components/item_title_spec.js
+++ b/spec/frontend/work_items/components/item_title_spec.js
@@ -4,10 +4,10 @@ import ItemTitle from '~/work_items/components/item_title.vue';
jest.mock('lodash/escape', () => jest.fn((fn) => fn));
-const createComponent = ({ initialTitle = 'Sample title', disabled = false } = {}) =>
+const createComponent = ({ title = 'Sample title', disabled = false } = {}) =>
shallowMount(ItemTitle, {
propsData: {
- initialTitle,
+ title,
disabled,
},
});
diff --git a/spec/frontend/work_items/components/work_item_actions_spec.js b/spec/frontend/work_items/components/work_item_actions_spec.js
new file mode 100644
index 00000000000..d0e9cfee353
--- /dev/null
+++ b/spec/frontend/work_items/components/work_item_actions_spec.js
@@ -0,0 +1,103 @@
+import { GlDropdownItem, GlModal } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import waitForPromises from 'helpers/wait_for_promises';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import WorkItemActions from '~/work_items/components/work_item_actions.vue';
+import deleteWorkItem from '~/work_items/graphql/delete_work_item.mutation.graphql';
+import { deleteWorkItemResponse, deleteWorkItemFailureResponse } from '../mock_data';
+
+describe('WorkItemActions component', () => {
+ let wrapper;
+ let glModalDirective;
+
+ Vue.use(VueApollo);
+
+ const findModal = () => wrapper.findComponent(GlModal);
+ const findDeleteButton = () => wrapper.findComponent(GlDropdownItem);
+
+ const createComponent = ({
+ canUpdate = true,
+ deleteWorkItemHandler = jest.fn().mockResolvedValue(deleteWorkItemResponse),
+ } = {}) => {
+ glModalDirective = jest.fn();
+ wrapper = shallowMount(WorkItemActions, {
+ apolloProvider: createMockApollo([[deleteWorkItem, deleteWorkItemHandler]]),
+ propsData: { workItemId: '123', canUpdate },
+ directives: {
+ glModal: {
+ bind(_, { value }) {
+ glModalDirective(value);
+ },
+ },
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders modal', () => {
+ createComponent();
+
+ expect(findModal().exists()).toBe(true);
+ expect(findModal().props('visible')).toBe(false);
+ });
+
+ it('shows confirm modal when clicking Delete work item', () => {
+ createComponent();
+
+ findDeleteButton().vm.$emit('click');
+
+ expect(glModalDirective).toHaveBeenCalled();
+ });
+
+ it('calls delete mutation when clicking OK button', () => {
+ const deleteWorkItemHandler = jest.fn().mockResolvedValue(deleteWorkItemResponse);
+
+ createComponent({
+ deleteWorkItemHandler,
+ });
+
+ findModal().vm.$emit('ok');
+
+ expect(deleteWorkItemHandler).toHaveBeenCalled();
+ expect(wrapper.emitted('error')).toBeUndefined();
+ });
+
+ it('emits event after delete success', async () => {
+ createComponent();
+
+ findModal().vm.$emit('ok');
+
+ await waitForPromises();
+
+ expect(wrapper.emitted('workItemDeleted')).not.toBeUndefined();
+ expect(wrapper.emitted('error')).toBeUndefined();
+ });
+
+ it('emits error event after delete failure', async () => {
+ createComponent({
+ deleteWorkItemHandler: jest.fn().mockResolvedValue(deleteWorkItemFailureResponse),
+ });
+
+ findModal().vm.$emit('ok');
+
+ await waitForPromises();
+
+ expect(wrapper.emitted('error')[0]).toEqual([
+ "The resource that you are attempting to access does not exist or you don't have permission to perform this action",
+ ]);
+ expect(wrapper.emitted('workItemDeleted')).toBeUndefined();
+ });
+
+ it('does not render when canUpdate is false', () => {
+ createComponent({
+ canUpdate: false,
+ });
+
+ expect(wrapper.html()).toBe('');
+ });
+});
diff --git a/spec/frontend/work_items/components/work_item_detail_modal_spec.js b/spec/frontend/work_items/components/work_item_detail_modal_spec.js
new file mode 100644
index 00000000000..9f35ccb853b
--- /dev/null
+++ b/spec/frontend/work_items/components/work_item_detail_modal_spec.js
@@ -0,0 +1,58 @@
+import { GlModal } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import WorkItemDetail from '~/work_items/components/work_item_detail.vue';
+import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
+import WorkItemActions from '~/work_items/components/work_item_actions.vue';
+
+describe('WorkItemDetailModal component', () => {
+ let wrapper;
+
+ Vue.use(VueApollo);
+
+ const findModal = () => wrapper.findComponent(GlModal);
+ const findWorkItemActions = () => wrapper.findComponent(WorkItemActions);
+ const findWorkItemDetail = () => wrapper.findComponent(WorkItemDetail);
+
+ const createComponent = ({ visible = true, workItemId = '1', canUpdate = false } = {}) => {
+ wrapper = shallowMount(WorkItemDetailModal, {
+ propsData: { visible, workItemId, canUpdate },
+ stubs: {
+ GlModal,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe.each([true, false])('when visible=%s', (visible) => {
+ it(`${visible ? 'renders' : 'does not render'} modal`, () => {
+ createComponent({ visible });
+
+ expect(findModal().props('visible')).toBe(visible);
+ });
+ });
+
+ it('renders heading', () => {
+ createComponent();
+
+ expect(wrapper.find('h2').text()).toBe('Work Item');
+ });
+
+ it('renders WorkItemDetail', () => {
+ createComponent();
+
+ expect(findWorkItemDetail().props()).toEqual({ workItemId: '1' });
+ });
+
+ it('shows work item actions', () => {
+ createComponent({
+ canUpdate: true,
+ });
+
+ expect(findWorkItemActions().exists()).toBe(true);
+ });
+});
diff --git a/spec/frontend/work_items/components/work_item_detail_spec.js b/spec/frontend/work_items/components/work_item_detail_spec.js
deleted file mode 100644
index 305f43ad8ba..00000000000
--- a/spec/frontend/work_items/components/work_item_detail_spec.js
+++ /dev/null
@@ -1,40 +0,0 @@
-import { GlModal } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
-import Vue from 'vue';
-import VueApollo from 'vue-apollo';
-import createMockApollo from 'helpers/mock_apollo_helper';
-import WorkItemTitle from '~/work_items/components/item_title.vue';
-import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue';
-import { resolvers } from '~/work_items/graphql/resolvers';
-
-describe('WorkItemDetailModal component', () => {
- let wrapper;
-
- Vue.use(VueApollo);
-
- const findModal = () => wrapper.findComponent(GlModal);
- const findWorkItemTitle = () => wrapper.findComponent(WorkItemTitle);
-
- const createComponent = () => {
- wrapper = shallowMount(WorkItemDetailModal, {
- apolloProvider: createMockApollo([], resolvers),
- propsData: { visible: true },
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- it('renders modal', () => {
- createComponent();
-
- expect(findModal().props()).toMatchObject({ visible: true });
- });
-
- it('renders work item title', () => {
- createComponent();
-
- expect(findWorkItemTitle().exists()).toBe(true);
- });
-});
diff --git a/spec/frontend/work_items/components/work_item_title_spec.js b/spec/frontend/work_items/components/work_item_title_spec.js
new file mode 100644
index 00000000000..9b1ef2d14e4
--- /dev/null
+++ b/spec/frontend/work_items/components/work_item_title_spec.js
@@ -0,0 +1,117 @@
+import { GlLoadingIcon } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { mockTracking } from 'helpers/tracking_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import ItemTitle from '~/work_items/components/item_title.vue';
+import WorkItemTitle from '~/work_items/components/work_item_title.vue';
+import { i18n } from '~/work_items/constants';
+import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
+import { updateWorkItemMutationResponse, workItemQueryResponse } from '../mock_data';
+
+describe('WorkItemTitle component', () => {
+ let wrapper;
+
+ Vue.use(VueApollo);
+
+ const mutationSuccessHandler = jest.fn().mockResolvedValue(updateWorkItemMutationResponse);
+
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findItemTitle = () => wrapper.findComponent(ItemTitle);
+
+ const createComponent = ({ loading = false, mutationHandler = mutationSuccessHandler } = {}) => {
+ const { id, title, workItemType } = workItemQueryResponse.data.workItem;
+ wrapper = shallowMount(WorkItemTitle, {
+ apolloProvider: createMockApollo([[updateWorkItemMutation, mutationHandler]]),
+ propsData: {
+ loading,
+ workItemId: id,
+ workItemTitle: title,
+ workItemType: workItemType.name,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('when loading', () => {
+ beforeEach(() => {
+ createComponent({ loading: true });
+ });
+
+ it('renders loading spinner', () => {
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+
+ it('does not render title', () => {
+ expect(findItemTitle().exists()).toBe(false);
+ });
+ });
+
+ describe('when loaded', () => {
+ beforeEach(() => {
+ createComponent({ loading: false });
+ });
+
+ it('does not render loading spinner', () => {
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+
+ it('renders title', () => {
+ expect(findItemTitle().props('title')).toBe(workItemQueryResponse.data.workItem.title);
+ });
+ });
+
+ describe('when updating the title', () => {
+ it('calls a mutation', () => {
+ const title = 'new title!';
+
+ createComponent();
+
+ findItemTitle().vm.$emit('title-changed', title);
+
+ expect(mutationSuccessHandler).toHaveBeenCalledWith({
+ input: {
+ id: workItemQueryResponse.data.workItem.id,
+ title,
+ },
+ });
+ });
+
+ it('does not call a mutation when the title has not changed', () => {
+ createComponent();
+
+ findItemTitle().vm.$emit('title-changed', workItemQueryResponse.data.workItem.title);
+
+ expect(mutationSuccessHandler).not.toHaveBeenCalled();
+ });
+
+ it('emits an error message when the mutation was unsuccessful', async () => {
+ createComponent({ mutationHandler: jest.fn().mockRejectedValue('Error!') });
+
+ findItemTitle().vm.$emit('title-changed', 'new title');
+ await waitForPromises();
+
+ expect(wrapper.emitted('error')).toEqual([[i18n.updateError]]);
+ });
+
+ it('tracks editing the title', async () => {
+ const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+
+ createComponent();
+
+ findItemTitle().vm.$emit('title-changed', 'new title');
+ await waitForPromises();
+
+ expect(trackingSpy).toHaveBeenCalledWith('workItems:show', 'updated_title', {
+ category: 'workItems:show',
+ label: 'item_title',
+ property: 'type_Task',
+ });
+ });
+ });
+});
diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js
index 832795fc4ac..722e1708c15 100644
--- a/spec/frontend/work_items/mock_data.js
+++ b/spec/frontend/work_items/mock_data.js
@@ -1,21 +1,14 @@
export const workItemQueryResponse = {
- workItem: {
- __typename: 'WorkItem',
- id: '1',
- title: 'Test',
- workItemType: {
- __typename: 'WorkItemType',
- id: 'work-item-type-1',
- },
- widgets: {
- __typename: 'LocalWorkItemWidgetConnection',
- nodes: [
- {
- __typename: 'LocalTitleWidget',
- type: 'TITLE',
- contentText: 'Test',
- },
- ],
+ data: {
+ workItem: {
+ __typename: 'WorkItem',
+ id: 'gid://gitlab/WorkItem/1',
+ title: 'Test',
+ workItemType: {
+ __typename: 'WorkItemType',
+ id: 'gid://gitlab/WorkItems::Type/5',
+ name: 'Task',
+ },
},
},
};
@@ -23,25 +16,15 @@ export const workItemQueryResponse = {
export const updateWorkItemMutationResponse = {
data: {
workItemUpdate: {
- __typename: 'LocalUpdateWorkItemPayload',
+ __typename: 'WorkItemUpdatePayload',
workItem: {
- __typename: 'LocalWorkItem',
- id: '1',
+ __typename: 'WorkItem',
+ id: 'gid://gitlab/WorkItem/1',
title: 'Updated title',
workItemType: {
__typename: 'WorkItemType',
- id: 'work-item-type-1',
- },
- widgets: {
- __typename: 'LocalWorkItemWidgetConnection',
- nodes: [
- {
- __typename: 'LocalTitleWidget',
- type: 'TITLE',
- enabled: true,
- contentText: 'Updated title',
- },
- ],
+ id: 'gid://gitlab/WorkItems::Type/5',
+ name: 'Task',
},
},
},
@@ -51,11 +34,11 @@ export const updateWorkItemMutationResponse = {
export const projectWorkItemTypesQueryResponse = {
data: {
workspace: {
- id: '1',
+ id: 'gid://gitlab/WorkItem/1',
workItemTypes: {
nodes: [
- { id: 'work-item-1', name: 'Issue' },
- { id: 'work-item-2', name: 'Incident' },
+ { id: 'gid://gitlab/WorkItems::Type/1', name: 'Issue' },
+ { id: 'gid://gitlab/WorkItems::Type/2', name: 'Incident' },
],
},
},
@@ -68,13 +51,53 @@ export const createWorkItemMutationResponse = {
__typename: 'WorkItemCreatePayload',
workItem: {
__typename: 'WorkItem',
- id: '1',
+ id: 'gid://gitlab/WorkItem/1',
title: 'Updated title',
workItemType: {
__typename: 'WorkItemType',
- id: 'work-item-type-1',
+ id: 'gid://gitlab/WorkItems::Type/5',
+ name: 'Task',
},
},
},
},
};
+
+export const createWorkItemFromTaskMutationResponse = {
+ data: {
+ workItemCreateFromTask: {
+ __typename: 'WorkItemCreateFromTaskPayload',
+ errors: [],
+ workItem: {
+ descriptionHtml: '<p>New description</p>',
+ id: 'gid://gitlab/WorkItem/13',
+ __typename: 'WorkItem',
+ },
+ },
+ },
+};
+
+export const deleteWorkItemResponse = {
+ data: { workItemDelete: { errors: [], __typename: 'WorkItemDeletePayload' } },
+};
+
+export const deleteWorkItemFailureResponse = {
+ data: { workItemDelete: null },
+ errors: [
+ {
+ message:
+ "The resource that you are attempting to access does not exist or you don't have permission to perform this action",
+ locations: [{ line: 2, column: 3 }],
+ path: ['workItemDelete'],
+ },
+ ],
+};
+
+export const workItemTitleSubscriptionResponse = {
+ data: {
+ issuableTitleUpdated: {
+ id: 'gid://gitlab/WorkItem/1',
+ title: 'new title',
+ },
+ },
+};
diff --git a/spec/frontend/work_items/pages/create_work_item_spec.js b/spec/frontend/work_items/pages/create_work_item_spec.js
index 185b05c5191..fb1f1d56356 100644
--- a/spec/frontend/work_items/pages/create_work_item_spec.js
+++ b/spec/frontend/work_items/pages/create_work_item_spec.js
@@ -1,15 +1,19 @@
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
-import { GlAlert, GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { GlAlert, GlFormSelect } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import CreateWorkItem from '~/work_items/pages/create_work_item.vue';
import ItemTitle from '~/work_items/components/item_title.vue';
-import { resolvers } from '~/work_items/graphql/resolvers';
import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql';
import createWorkItemMutation from '~/work_items/graphql/create_work_item.mutation.graphql';
-import { projectWorkItemTypesQueryResponse, createWorkItemMutationResponse } from '../mock_data';
+import createWorkItemFromTaskMutation from '~/work_items/graphql/create_work_item_from_task.mutation.graphql';
+import {
+ projectWorkItemTypesQueryResponse,
+ createWorkItemMutationResponse,
+ createWorkItemFromTaskMutationResponse,
+} from '../mock_data';
jest.mock('~/lib/utils/uuids', () => ({ uuids: () => ['testuuid'] }));
@@ -20,12 +24,15 @@ describe('Create work item component', () => {
let fakeApollo;
const querySuccessHandler = jest.fn().mockResolvedValue(projectWorkItemTypesQueryResponse);
- const mutationSuccessHandler = jest.fn().mockResolvedValue(createWorkItemMutationResponse);
+ const createWorkItemSuccessHandler = jest.fn().mockResolvedValue(createWorkItemMutationResponse);
+ const createWorkItemFromTaskSuccessHandler = jest
+ .fn()
+ .mockResolvedValue(createWorkItemFromTaskMutationResponse);
+ const errorHandler = jest.fn().mockRejectedValue('Houston, we have a problem');
const findAlert = () => wrapper.findComponent(GlAlert);
const findTitleInput = () => wrapper.findComponent(ItemTitle);
- const findDropdown = () => wrapper.findComponent(GlDropdown);
- const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
+ const findSelect = () => wrapper.findComponent(GlFormSelect);
const findCreateButton = () => wrapper.find('[data-testid="create-button"]');
const findCancelButton = () => wrapper.find('[data-testid="cancel-button"]');
@@ -36,15 +43,13 @@ describe('Create work item component', () => {
data = {},
props = {},
queryHandler = querySuccessHandler,
- mutationHandler = mutationSuccessHandler,
+ mutationHandler = createWorkItemSuccessHandler,
} = {}) => {
- fakeApollo = createMockApollo(
- [
- [projectWorkItemTypesQuery, queryHandler],
- [createWorkItemMutation, mutationHandler],
- ],
- resolvers,
- );
+ fakeApollo = createMockApollo([
+ [projectWorkItemTypesQuery, queryHandler],
+ [createWorkItemMutation, mutationHandler],
+ [createWorkItemFromTaskMutation, mutationHandler],
+ ]);
wrapper = shallowMount(CreateWorkItem, {
apolloProvider: fakeApollo,
data() {
@@ -123,6 +128,7 @@ describe('Create work item component', () => {
props: {
isModal: true,
},
+ mutationHandler: createWorkItemFromTaskSuccessHandler,
});
});
@@ -133,14 +139,12 @@ describe('Create work item component', () => {
});
it('emits `onCreate` on successful mutation', async () => {
- const mockTitle = 'Test title';
findTitleInput().vm.$emit('title-input', 'Test title');
wrapper.find('form').trigger('submit');
await waitForPromises();
- const expected = { id: '1', title: mockTitle };
- expect(wrapper.emitted('onCreate')).toEqual([[expected]]);
+ expect(wrapper.emitted('onCreate')).toEqual([['<p>New description</p>']]);
});
it('does not right margin for create button', () => {
@@ -177,16 +181,14 @@ describe('Create work item component', () => {
});
it('displays a list of work item types', () => {
- expect(findDropdownItems()).toHaveLength(2);
- expect(findDropdownItems().at(0).text()).toContain('Issue');
+ expect(findSelect().attributes('options').split(',')).toHaveLength(3);
});
it('selects a work item type on click', async () => {
- expect(findDropdown().props('text')).toBe('Type');
- findDropdownItems().at(0).vm.$emit('click');
+ const mockId = 'work-item-1';
+ findSelect().vm.$emit('input', mockId);
await nextTick();
-
- expect(findDropdown().props('text')).toBe('Issue');
+ expect(findSelect().attributes('value')).toBe(mockId);
});
});
@@ -206,21 +208,36 @@ describe('Create work item component', () => {
createComponent({
props: { initialTitle },
});
- expect(findTitleInput().props('initialTitle')).toBe(initialTitle);
+ expect(findTitleInput().props('title')).toBe(initialTitle);
});
describe('when title input field has a text', () => {
- beforeEach(() => {
+ beforeEach(async () => {
const mockTitle = 'Test title';
createComponent();
+ await waitForPromises();
findTitleInput().vm.$emit('title-input', mockTitle);
});
- it('renders a non-disabled Create button', () => {
+ it('renders a disabled Create button', () => {
+ expect(findCreateButton().props('disabled')).toBe(true);
+ });
+
+ it('renders a non-disabled Create button when work item type is selected', async () => {
+ findSelect().vm.$emit('input', 'work-item-1');
+ await nextTick();
expect(findCreateButton().props('disabled')).toBe(false);
});
+ });
+
+ it('shows an alert on mutation error', async () => {
+ createComponent({ mutationHandler: errorHandler });
+ await waitForPromises();
+ findTitleInput().vm.$emit('title-input', 'some title');
+ findSelect().vm.$emit('input', 'work-item-1');
+ wrapper.find('form').trigger('submit');
+ await waitForPromises();
- // TODO: write a proper test here when we have a backend implementation
- it.todo('shows an alert on mutation error');
+ expect(findAlert().text()).toBe(CreateWorkItem.createErrorText);
});
});
diff --git a/spec/frontend/work_items/pages/work_item_detail_spec.js b/spec/frontend/work_items/pages/work_item_detail_spec.js
new file mode 100644
index 00000000000..1eb6c0145e7
--- /dev/null
+++ b/spec/frontend/work_items/pages/work_item_detail_spec.js
@@ -0,0 +1,99 @@
+import { GlAlert } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import WorkItemDetail from '~/work_items/components/work_item_detail.vue';
+import WorkItemTitle from '~/work_items/components/work_item_title.vue';
+import { i18n } from '~/work_items/constants';
+import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
+import workItemTitleSubscription from '~/work_items/graphql/work_item_title.subscription.graphql';
+import { workItemTitleSubscriptionResponse, workItemQueryResponse } from '../mock_data';
+
+describe('WorkItemDetail component', () => {
+ let wrapper;
+
+ Vue.use(VueApollo);
+
+ const successHandler = jest.fn().mockResolvedValue(workItemQueryResponse);
+ const initialSubscriptionHandler = jest.fn().mockResolvedValue(workItemTitleSubscriptionResponse);
+
+ const findAlert = () => wrapper.findComponent(GlAlert);
+ const findWorkItemTitle = () => wrapper.findComponent(WorkItemTitle);
+
+ const createComponent = ({
+ workItemId = workItemQueryResponse.data.workItem.id,
+ handler = successHandler,
+ subscriptionHandler = initialSubscriptionHandler,
+ } = {}) => {
+ wrapper = shallowMount(WorkItemDetail, {
+ apolloProvider: createMockApollo([
+ [workItemQuery, handler],
+ [workItemTitleSubscription, subscriptionHandler],
+ ]),
+ propsData: { workItemId },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('when there is no `workItemId` prop', () => {
+ beforeEach(() => {
+ createComponent({ workItemId: null });
+ });
+
+ it('skips the work item query', () => {
+ expect(successHandler).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when loading', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders WorkItemTitle in loading state', () => {
+ expect(findWorkItemTitle().props('loading')).toBe(true);
+ });
+ });
+
+ describe('when loaded', () => {
+ beforeEach(() => {
+ createComponent();
+ return waitForPromises();
+ });
+
+ it('does not render WorkItemTitle in loading state', () => {
+ expect(findWorkItemTitle().props('loading')).toBe(false);
+ });
+ });
+
+ it('shows an error message when the work item query was unsuccessful', async () => {
+ const errorHandler = jest.fn().mockRejectedValue('Oops');
+ createComponent({ handler: errorHandler });
+ await waitForPromises();
+
+ expect(errorHandler).toHaveBeenCalled();
+ expect(findAlert().text()).toBe(i18n.fetchError);
+ });
+
+ it('shows an error message when WorkItemTitle emits an `error` event', async () => {
+ createComponent();
+
+ findWorkItemTitle().vm.$emit('error', i18n.updateError);
+ await waitForPromises();
+
+ expect(findAlert().text()).toBe(i18n.updateError);
+ });
+
+ it('calls the subscription', () => {
+ createComponent();
+
+ expect(initialSubscriptionHandler).toHaveBeenCalledWith({
+ issuableId: workItemQueryResponse.data.workItem.id,
+ });
+ });
+});
diff --git a/spec/frontend/work_items/pages/work_item_root_spec.js b/spec/frontend/work_items/pages/work_item_root_spec.js
index 728495e0e23..2803724b9af 100644
--- a/spec/frontend/work_items/pages/work_item_root_spec.js
+++ b/spec/frontend/work_items/pages/work_item_root_spec.js
@@ -1,108 +1,31 @@
-import Vue from 'vue';
import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import createMockApollo from 'helpers/mock_apollo_helper';
-import waitForPromises from 'helpers/wait_for_promises';
-import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
-import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
-import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
+import WorkItemDetail from '~/work_items/components/work_item_detail.vue';
import WorkItemsRoot from '~/work_items/pages/work_item_root.vue';
-import ItemTitle from '~/work_items/components/item_title.vue';
-import { resolvers } from '~/work_items/graphql/resolvers';
-import { workItemQueryResponse, updateWorkItemMutationResponse } from '../mock_data';
Vue.use(VueApollo);
-const WORK_ITEM_ID = '1';
-const WORK_ITEM_GID = `gid://gitlab/WorkItem/${WORK_ITEM_ID}`;
-
describe('Work items root component', () => {
- const mockUpdatedTitle = 'Updated title';
let wrapper;
- let fakeApollo;
-
- const findTitle = () => wrapper.findComponent(ItemTitle);
- const createComponent = ({ queryResponse = workItemQueryResponse } = {}) => {
- fakeApollo = createMockApollo(
- [[updateWorkItemMutation, jest.fn().mockResolvedValue(updateWorkItemMutationResponse)]],
- resolvers,
- {
- possibleTypes: {
- LocalWorkItemWidget: ['LocalTitleWidget'],
- },
- },
- );
- fakeApollo.clients.defaultClient.cache.writeQuery({
- query: workItemQuery,
- variables: {
- id: WORK_ITEM_GID,
- },
- data: queryResponse,
- });
+ const findWorkItemDetail = () => wrapper.findComponent(WorkItemDetail);
+ const createComponent = () => {
wrapper = shallowMount(WorkItemsRoot, {
propsData: {
- id: WORK_ITEM_ID,
+ id: '1',
},
- apolloProvider: fakeApollo,
});
};
afterEach(() => {
wrapper.destroy();
- fakeApollo = null;
});
- it('renders the title', () => {
+ it('renders WorkItemDetail', () => {
createComponent();
- expect(findTitle().exists()).toBe(true);
- expect(findTitle().props('initialTitle')).toBe('Test');
- });
-
- it('updates the title when it is edited', async () => {
- createComponent();
- jest.spyOn(wrapper.vm.$apollo, 'mutate');
-
- await findTitle().vm.$emit('title-changed', mockUpdatedTitle);
-
- expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
- mutation: updateWorkItemMutation,
- variables: {
- input: {
- id: WORK_ITEM_GID,
- title: mockUpdatedTitle,
- },
- },
- });
- });
-
- describe('tracking', () => {
- let trackingSpy;
-
- beforeEach(() => {
- trackingSpy = mockTracking('_category_', undefined, jest.spyOn);
-
- createComponent();
- });
-
- afterEach(() => {
- unmockTracking();
- });
-
- it('tracks item title updates', async () => {
- await findTitle().vm.$emit('title-changed', mockUpdatedTitle);
-
- await waitForPromises();
-
- expect(trackingSpy).toHaveBeenCalledTimes(1);
- expect(trackingSpy).toHaveBeenCalledWith('workItems:show', undefined, {
- action: 'updated_title',
- category: 'workItems:show',
- label: 'item_title',
- property: '[type_work_item]',
- });
- });
+ expect(findWorkItemDetail().props()).toEqual({ workItemId: 'gid://gitlab/WorkItem/1' });
});
});
diff --git a/spec/frontend/work_items/router_spec.js b/spec/frontend/work_items/router_spec.js
index 8c9054920a8..7e68c5e4f0e 100644
--- a/spec/frontend/work_items/router_spec.js
+++ b/spec/frontend/work_items/router_spec.js
@@ -37,7 +37,7 @@ describe('Work items router', () => {
it('renders work item on `/1` route', async () => {
await createComponent('/1');
- expect(wrapper.find(WorkItemsRoot).exists()).toBe(true);
+ expect(wrapper.findComponent(WorkItemsRoot).exists()).toBe(true);
});
it('renders create work item page on `/new` route', async () => {
diff --git a/spec/frontend_integration/content_editor/content_editor_integration_spec.js b/spec/frontend_integration/content_editor/content_editor_integration_spec.js
new file mode 100644
index 00000000000..1b45c0d43a3
--- /dev/null
+++ b/spec/frontend_integration/content_editor/content_editor_integration_spec.js
@@ -0,0 +1,63 @@
+import { nextTick } from 'vue';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import { ContentEditor } from '~/content_editor';
+
+/**
+ * This spec exercises some workflows in the Content Editor without mocking
+ * any component.
+ *
+ */
+describe('content_editor', () => {
+ let wrapper;
+ let renderMarkdown;
+ let contentEditorService;
+
+ const buildWrapper = () => {
+ renderMarkdown = jest.fn();
+ wrapper = mountExtended(ContentEditor, {
+ propsData: {
+ renderMarkdown,
+ uploadsPath: '/',
+ },
+ listeners: {
+ initialized(contentEditor) {
+ contentEditorService = contentEditor;
+ },
+ },
+ });
+ };
+
+ describe('when loading initial content', () => {
+ describe('when the initial content is empty', () => {
+ it('still hides the loading indicator', async () => {
+ buildWrapper();
+
+ renderMarkdown.mockResolvedValue('');
+
+ await contentEditorService.setSerializedContent('');
+ await nextTick();
+
+ expect(wrapper.findByTestId('content-editor-loading-indicator').exists()).toBe(false);
+ });
+ });
+
+ describe('when the initial content is not empty', () => {
+ const initialContent = '<p><strong>bold text</strong></p>';
+ beforeEach(async () => {
+ buildWrapper();
+
+ renderMarkdown.mockResolvedValue(initialContent);
+
+ await contentEditorService.setSerializedContent('**bold text**');
+ await nextTick();
+ });
+ it('hides the loading indicator', async () => {
+ expect(wrapper.findByTestId('content-editor-loading-indicator').exists()).toBe(false);
+ });
+
+ it('displays the initial content', async () => {
+ expect(wrapper.html()).toContain(initialContent);
+ });
+ });
+ });
+});
diff --git a/spec/graphql/graphql_triggers_spec.rb b/spec/graphql/graphql_triggers_spec.rb
index 2d83edca363..84af33a5cb3 100644
--- a/spec/graphql/graphql_triggers_spec.rb
+++ b/spec/graphql/graphql_triggers_spec.rb
@@ -31,4 +31,20 @@ RSpec.describe GraphqlTriggers do
GraphqlTriggers.issuable_title_updated(work_item)
end
end
+
+ describe '.issuable_labels_updated' do
+ it 'triggers the issuableLabelsUpdated subscription' do
+ project = create(:project)
+ labels = create_list(:label, 3, project: project)
+ issue = create(:issue, labels: labels)
+
+ expect(GitlabSchema.subscriptions).to receive(:trigger).with(
+ 'issuableLabelsUpdated',
+ { issuable_id: issue.to_gid },
+ issue
+ )
+
+ GraphqlTriggers.issuable_labels_updated(issue)
+ end
+ end
end
diff --git a/spec/graphql/mutations/ci/runner/delete_spec.rb b/spec/graphql/mutations/ci/runner/delete_spec.rb
index c0f979e43cc..ee640b21918 100644
--- a/spec/graphql/mutations/ci/runner/delete_spec.rb
+++ b/spec/graphql/mutations/ci/runner/delete_spec.rb
@@ -37,7 +37,9 @@ RSpec.describe Mutations::Ci::Runner::Delete do
it 'raises an error' do
mutation_params[:id] = two_projects_runner.to_global_id
- expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ResourceNotAvailable) do
+ subject
+ end
end
end
end
@@ -115,7 +117,10 @@ RSpec.describe Mutations::Ci::Runner::Delete do
allow_next_instance_of(::Ci::Runners::UnregisterRunnerService) do |service|
expect(service).not_to receive(:execute)
end
- expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+
+ expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ResourceNotAvailable) do
+ subject
+ end
end
end
end
diff --git a/spec/graphql/mutations/environments/canary_ingress/update_spec.rb b/spec/graphql/mutations/environments/canary_ingress/update_spec.rb
index 48e55828a6b..fdf9cbaf25b 100644
--- a/spec/graphql/mutations/environments/canary_ingress/update_spec.rb
+++ b/spec/graphql/mutations/environments/canary_ingress/update_spec.rb
@@ -36,6 +36,20 @@ RSpec.describe Mutations::Environments::CanaryIngress::Update do
it 'returns no errors' do
expect(subject[:errors]).to be_empty
end
+
+ context 'with certificate_based_clusters disabled' do
+ before do
+ stub_feature_flags(certificate_based_clusters: false)
+ end
+
+ it 'returns notice about feature removal' do
+ expect(subject[:errors]).to match_array([
+ 'This endpoint was deactivated as part of the certificate-based' \
+ 'kubernetes integration removal. See Epic:' \
+ 'https://gitlab.com/groups/gitlab-org/configure/-/epics/8'
+ ])
+ end
+ end
end
context 'when service encounters a problem' do
diff --git a/spec/graphql/mutations/saved_replies/destroy_spec.rb b/spec/graphql/mutations/saved_replies/destroy_spec.rb
new file mode 100644
index 00000000000..6cff28ec0b2
--- /dev/null
+++ b/spec/graphql/mutations/saved_replies/destroy_spec.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::SavedReplies::Destroy do
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:saved_reply) { create(:saved_reply, user: current_user) }
+
+ let(:mutation) { described_class.new(object: nil, context: { current_user: current_user }, field: nil) }
+
+ describe '#resolve' do
+ subject(:resolve) do
+ mutation.resolve(id: saved_reply.to_global_id)
+ end
+
+ context 'when feature is disabled' do
+ before do
+ stub_feature_flags(saved_replies: false)
+ end
+
+ it 'raises Gitlab::Graphql::Errors::ResourceNotAvailable' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled')
+ end
+ end
+
+ context 'when feature is enabled for current user' do
+ before do
+ stub_feature_flags(saved_replies: current_user)
+ end
+
+ context 'when service fails to delete a new saved reply' do
+ before do
+ saved_reply.destroy!
+ end
+
+ it 'raises Gitlab::Graphql::Errors::ResourceNotAvailable' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+
+ context 'when service successfully deletes the saved reply' do
+ it { expect(subject[:errors]).to be_empty }
+ end
+ end
+ end
+end
diff --git a/spec/graphql/resolvers/blobs_resolver_spec.rb b/spec/graphql/resolvers/blobs_resolver_spec.rb
index 4b75351147c..a666ed2a9fc 100644
--- a/spec/graphql/resolvers/blobs_resolver_spec.rb
+++ b/spec/graphql/resolvers/blobs_resolver_spec.rb
@@ -75,10 +75,9 @@ RSpec.describe Resolvers::BlobsResolver do
let(:ref) { 'ma:in' }
it 'raises an ArgumentError' do
- expect { resolve_blobs }.to raise_error(
- Gitlab::Graphql::Errors::ArgumentError,
- 'Ref is not valid'
- )
+ expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError, 'Ref is not valid') do
+ resolve_blobs
+ end
end
end
@@ -86,10 +85,9 @@ RSpec.describe Resolvers::BlobsResolver do
let(:ref) { '' }
it 'raises an ArgumentError' do
- expect { resolve_blobs }.to raise_error(
- Gitlab::Graphql::Errors::ArgumentError,
- 'Ref is not valid'
- )
+ expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError, 'Ref is not valid') do
+ resolve_blobs
+ end
end
end
end
diff --git a/spec/graphql/resolvers/group_members/notification_email_resolver_spec.rb b/spec/graphql/resolvers/group_members/notification_email_resolver_spec.rb
index fcf67120b0e..8d0b8f9398d 100644
--- a/spec/graphql/resolvers/group_members/notification_email_resolver_spec.rb
+++ b/spec/graphql/resolvers/group_members/notification_email_resolver_spec.rb
@@ -35,7 +35,9 @@ RSpec.describe Resolvers::GroupMembers::NotificationEmailResolver do
let(:current_user) { create(:user) }
it 'raises ResourceNotAvailable error' do
- expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ResourceNotAvailable) do
+ subject
+ end
end
end
end
diff --git a/spec/graphql/resolvers/issues_resolver_spec.rb b/spec/graphql/resolvers/issues_resolver_spec.rb
index 5e9a3d0a68b..81aeee0a3d2 100644
--- a/spec/graphql/resolvers/issues_resolver_spec.rb
+++ b/spec/graphql/resolvers/issues_resolver_spec.rb
@@ -522,11 +522,53 @@ RSpec.describe Resolvers::IssuesResolver do
end
end
+ context 'when sorting by escalation status' do
+ let_it_be(:project) { create(:project, :public) }
+ let_it_be(:triggered_incident) { create(:incident, :with_escalation_status, project: project) }
+ let_it_be(:issue_no_status) { create(:issue, project: project) }
+ let_it_be(:resolved_incident) do
+ create(:incident, :with_escalation_status, project: project)
+ .tap { |issue| issue.escalation_status.resolve }
+ end
+
+ it 'sorts issues ascending' do
+ issues = resolve_issues(sort: :escalation_status_asc).to_a
+ expect(issues).to eq([triggered_incident, resolved_incident, issue_no_status])
+ end
+
+ it 'sorts issues descending' do
+ issues = resolve_issues(sort: :escalation_status_desc).to_a
+ expect(issues).to eq([resolved_incident, triggered_incident, issue_no_status])
+ end
+
+ it 'sorts issues created_at' do
+ issues = resolve_issues(sort: :created_desc).to_a
+ expect(issues).to eq([resolved_incident, issue_no_status, triggered_incident])
+ end
+
+ context 'when incident_escalations feature flag is disabled' do
+ before do
+ stub_feature_flags(incident_escalations: false)
+ end
+
+ it 'defaults ascending status sort to created_desc' do
+ issues = resolve_issues(sort: :escalation_status_asc).to_a
+ expect(issues).to eq([resolved_incident, issue_no_status, triggered_incident])
+ end
+
+ it 'defaults descending status sort to created_desc' do
+ issues = resolve_issues(sort: :escalation_status_desc).to_a
+ expect(issues).to eq([resolved_incident, issue_no_status, triggered_incident])
+ end
+ end
+ end
+
context 'when sorting with non-stable cursors' do
%i[priority_asc priority_desc
popularity_asc popularity_desc
label_priority_asc label_priority_desc
- milestone_due_asc milestone_due_desc].each do |sort_by|
+ milestone_due_asc milestone_due_desc
+ escalation_status_asc escalation_status_desc].each do |sort_by|
it "uses offset-pagination when sorting by #{sort_by}" do
resolved = resolve_issues(sort: sort_by)
diff --git a/spec/graphql/resolvers/project_jobs_resolver_spec.rb b/spec/graphql/resolvers/project_jobs_resolver_spec.rb
index 94df2999163..bb711a4c857 100644
--- a/spec/graphql/resolvers/project_jobs_resolver_spec.rb
+++ b/spec/graphql/resolvers/project_jobs_resolver_spec.rb
@@ -9,9 +9,10 @@ RSpec.describe Resolvers::ProjectJobsResolver do
let_it_be(:irrelevant_project) { create(:project, :repository) }
let_it_be(:pipeline) { create(:ci_pipeline, project: project) }
let_it_be(:irrelevant_pipeline) { create(:ci_pipeline, project: irrelevant_project) }
- let_it_be(:build_one) { create(:ci_build, :success, name: 'Build One', pipeline: pipeline) }
- let_it_be(:build_two) { create(:ci_build, :success, name: 'Build Two', pipeline: pipeline) }
- let_it_be(:build_three) { create(:ci_build, :failed, name: 'Build Three', pipeline: pipeline) }
+ let_it_be(:successful_build) { create(:ci_build, :success, name: 'Build One', pipeline: pipeline) }
+ let_it_be(:successful_build_two) { create(:ci_build, :success, name: 'Build Two', pipeline: pipeline) }
+ let_it_be(:failed_build) { create(:ci_build, :failed, name: 'Build Three', pipeline: pipeline) }
+ let_it_be(:pending_build) { create(:ci_build, :pending, name: 'Build Three', pipeline: pipeline) }
let(:irrelevant_build) { create(:ci_build, name: 'Irrelevant Build', pipeline: irrelevant_pipeline)}
let(:args) { {} }
@@ -28,11 +29,17 @@ RSpec.describe Resolvers::ProjectJobsResolver do
context 'with statuses argument' do
let(:args) { { statuses: [Types::Ci::JobStatusEnum.coerce_isolated_input('SUCCESS')] } }
- it { is_expected.to contain_exactly(build_one, build_two) }
+ it { is_expected.to contain_exactly(successful_build, successful_build_two) }
+ end
+
+ context 'with multiple statuses' do
+ let(:args) { { statuses: [Types::Ci::JobStatusEnum.coerce_isolated_input('SUCCESS'), Types::Ci::JobStatusEnum.coerce_isolated_input('FAILED')] } }
+
+ it { is_expected.to contain_exactly(successful_build, successful_build_two, failed_build) }
end
context 'without statuses argument' do
- it { is_expected.to contain_exactly(build_one, build_two, build_three) }
+ it { is_expected.to contain_exactly(successful_build, successful_build_two, failed_build, pending_build) }
end
end
diff --git a/spec/graphql/resolvers/users_resolver_spec.rb b/spec/graphql/resolvers/users_resolver_spec.rb
index b01cc0d43e3..1ba296912a3 100644
--- a/spec/graphql/resolvers/users_resolver_spec.rb
+++ b/spec/graphql/resolvers/users_resolver_spec.rb
@@ -74,7 +74,9 @@ RSpec.describe Resolvers::UsersResolver do
let_it_be(:current_user) { nil }
it 'prohibits search without usernames passed' do
- expect { resolve_users }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ResourceNotAvailable) do
+ resolve_users
+ end
end
it 'allows to search by username' do
diff --git a/spec/graphql/resolvers/work_item_resolver_spec.rb b/spec/graphql/resolvers/work_item_resolver_spec.rb
index c7e2beecb51..bfa0cf1d8a2 100644
--- a/spec/graphql/resolvers/work_item_resolver_spec.rb
+++ b/spec/graphql/resolvers/work_item_resolver_spec.rb
@@ -22,7 +22,9 @@ RSpec.describe Resolvers::WorkItemResolver do
let(:current_user) { create(:user) }
it 'raises a resource not available error' do
- expect { resolved_work_item }.to raise_error(::Gitlab::Graphql::Errors::ResourceNotAvailable)
+ expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ResourceNotAvailable) do
+ resolved_work_item
+ end
end
end
diff --git a/spec/graphql/resolvers/work_items/types_resolver_spec.rb b/spec/graphql/resolvers/work_items/types_resolver_spec.rb
index f7aeed30fd3..868f4566ad6 100644
--- a/spec/graphql/resolvers/work_items/types_resolver_spec.rb
+++ b/spec/graphql/resolvers/work_items/types_resolver_spec.rb
@@ -53,5 +53,15 @@ RSpec.describe Resolvers::WorkItems::TypesResolver do
it_behaves_like 'a work item type resolver'
end
+
+ context 'when parent is not a group or project' do
+ let(:object) { 'not a project/group' }
+
+ it 'returns nil because of feature flag check' do
+ result = resolve(described_class, obj: object, args: {})
+
+ expect(result).to be_nil
+ end
+ end
end
end
diff --git a/spec/graphql/types/base_object_spec.rb b/spec/graphql/types/base_object_spec.rb
index d8f2ef58ea5..45dc885ecba 100644
--- a/spec/graphql/types/base_object_spec.rb
+++ b/spec/graphql/types/base_object_spec.rb
@@ -428,5 +428,25 @@ RSpec.describe Types::BaseObject do
expect(result.dig('data', 'users', 'nodes'))
.to contain_exactly({ 'name' => active_users.first.name })
end
+
+ describe '.authorize' do
+ let_it_be(:read_only_type) do
+ Class.new(described_class) do
+ authorize :read_only
+ end
+ end
+
+ let_it_be(:inherited_read_only_type) { Class.new(read_only_type) }
+
+ it 'keeps track of the specified value' do
+ expect(described_class.authorize).to be_nil
+ expect(read_only_type.authorize).to match_array [:read_only]
+ expect(inherited_read_only_type.authorize).to match_array [:read_only]
+ end
+
+ it 'can not redefine the authorize value' do
+ expect { read_only_type.authorize(:write_only) }.to raise_error('Cannot redefine authorize')
+ end
+ end
end
end
diff --git a/spec/graphql/types/ci/job_kind_enum_spec.rb b/spec/graphql/types/ci/job_kind_enum_spec.rb
new file mode 100644
index 00000000000..b48d20b71e2
--- /dev/null
+++ b/spec/graphql/types/ci/job_kind_enum_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['CiJobKind'] do
+ it 'exposes some job type values' do
+ expect(described_class.values.keys).to match_array(
+ (%w[BRIDGE BUILD])
+ )
+ end
+end
diff --git a/spec/graphql/types/ci/job_type_spec.rb b/spec/graphql/types/ci/job_type_spec.rb
index 47d697ab8b8..655c3636883 100644
--- a/spec/graphql/types/ci/job_type_spec.rb
+++ b/spec/graphql/types/ci/job_type_spec.rb
@@ -21,6 +21,7 @@ RSpec.describe Types::Ci::JobType do
downstreamPipeline
finished_at
id
+ kind
manual_job
name
needs
diff --git a/spec/graphql/types/container_repository_details_type_spec.rb b/spec/graphql/types/container_repository_details_type_spec.rb
index aa770284f89..d94516c6fce 100644
--- a/spec/graphql/types/container_repository_details_type_spec.rb
+++ b/spec/graphql/types/container_repository_details_type_spec.rb
@@ -3,7 +3,9 @@
require 'spec_helper'
RSpec.describe GitlabSchema.types['ContainerRepositoryDetails'] do
- fields = %i[id name path location created_at updated_at expiration_policy_started_at status tags_count can_delete expiration_policy_cleanup_status tags size project]
+ fields = %i[id name path location created_at updated_at expiration_policy_started_at
+ status tags_count can_delete expiration_policy_cleanup_status tags size
+ project migration_state]
it { expect(described_class.graphql_name).to eq('ContainerRepositoryDetails') }
diff --git a/spec/graphql/types/container_repository_type_spec.rb b/spec/graphql/types/container_repository_type_spec.rb
index 87e1c11ce19..9815449dd68 100644
--- a/spec/graphql/types/container_repository_type_spec.rb
+++ b/spec/graphql/types/container_repository_type_spec.rb
@@ -3,7 +3,9 @@
require 'spec_helper'
RSpec.describe GitlabSchema.types['ContainerRepository'] do
- fields = %i[id name path location created_at updated_at expiration_policy_started_at status tags_count can_delete expiration_policy_cleanup_status project]
+ fields = %i[id name path location created_at updated_at expiration_policy_started_at
+ status tags_count can_delete expiration_policy_cleanup_status project
+ migration_state]
it { expect(described_class.graphql_name).to eq('ContainerRepository') }
diff --git a/spec/graphql/types/dependency_proxy/manifest_type_spec.rb b/spec/graphql/types/dependency_proxy/manifest_type_spec.rb
index b251ca63c4f..f688b085b10 100644
--- a/spec/graphql/types/dependency_proxy/manifest_type_spec.rb
+++ b/spec/graphql/types/dependency_proxy/manifest_type_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
RSpec.describe GitlabSchema.types['DependencyProxyManifest'] do
it 'includes dependency proxy manifest fields' do
expected_fields = %w[
- id file_name image_name size created_at updated_at digest
+ id file_name image_name size created_at updated_at digest status
]
expect(described_class).to include_graphql_fields(*expected_fields)
diff --git a/spec/graphql/types/issue_sort_enum_spec.rb b/spec/graphql/types/issue_sort_enum_spec.rb
index 4433709d193..95184477e75 100644
--- a/spec/graphql/types/issue_sort_enum_spec.rb
+++ b/spec/graphql/types/issue_sort_enum_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe GitlabSchema.types['IssueSort'] do
it 'exposes all the existing issue sort values' do
expect(described_class.values.keys).to include(
- *%w[DUE_DATE_ASC DUE_DATE_DESC RELATIVE_POSITION_ASC SEVERITY_ASC SEVERITY_DESC]
+ *%w[DUE_DATE_ASC DUE_DATE_DESC RELATIVE_POSITION_ASC SEVERITY_ASC SEVERITY_DESC ESCALATION_STATUS_ASC ESCALATION_STATUS_DESC]
)
end
end
diff --git a/spec/graphql/types/range_input_type_spec.rb b/spec/graphql/types/range_input_type_spec.rb
index fc9126247fa..dbfcf4a41c7 100644
--- a/spec/graphql/types/range_input_type_spec.rb
+++ b/spec/graphql/types/range_input_type_spec.rb
@@ -24,7 +24,7 @@ RSpec.describe ::Types::RangeInputType do
it 'follows expected subtyping relationships for instances' do
context = GraphQL::Query::Context.new(
- query: double('query', schema: nil),
+ query: GraphQL::Query.new(GitlabSchema),
values: {},
object: nil
)
diff --git a/spec/graphql/types/repository/blob_type_spec.rb b/spec/graphql/types/repository/blob_type_spec.rb
index a813ef85e6e..787b5f4a311 100644
--- a/spec/graphql/types/repository/blob_type_spec.rb
+++ b/spec/graphql/types/repository/blob_type_spec.rb
@@ -34,7 +34,6 @@ RSpec.describe Types::Repository::BlobType do
:environment_external_url_for_route_map,
:code_navigation_path,
:project_blob_path_root,
- :code_owners,
:simple_viewer,
:rich_viewer,
:plain_data,
@@ -47,6 +46,6 @@ RSpec.describe Types::Repository::BlobType do
:ide_fork_and_edit_path,
:fork_and_view_path,
:language
- )
+ ).at_least
end
end
diff --git a/spec/graphql/types/subscription_type_spec.rb b/spec/graphql/types/subscription_type_spec.rb
index 593795de004..1a2629ed422 100644
--- a/spec/graphql/types/subscription_type_spec.rb
+++ b/spec/graphql/types/subscription_type_spec.rb
@@ -8,6 +8,7 @@ RSpec.describe GitlabSchema.types['Subscription'] do
issuable_assignees_updated
issue_crm_contacts_updated
issuable_title_updated
+ issuable_labels_updated
]
expect(described_class).to have_graphql_fields(*expected_fields).only
diff --git a/spec/haml_lint/linter/documentation_links_spec.rb b/spec/haml_lint/linter/documentation_links_spec.rb
index 75002097d69..f2aab4304c1 100644
--- a/spec/haml_lint/linter/documentation_links_spec.rb
+++ b/spec/haml_lint/linter/documentation_links_spec.rb
@@ -43,6 +43,12 @@ RSpec.describe HamlLint::Linter::DocumentationLinks do
let(:haml) { "= link_to 'Description', #{link_pattern}('wrong.md'), target: '_blank'" }
it { is_expected.to report_lint }
+
+ context 'when haml ends with block definition' do
+ let(:haml) { "= link_to 'Description', #{link_pattern}('wrong.md') do" }
+
+ it { is_expected.to report_lint }
+ end
end
context 'when link with wrong file path is assigned to a variable' do
diff --git a/spec/helpers/admin/background_migrations_helper_spec.rb b/spec/helpers/admin/background_migrations_helper_spec.rb
index 9c1bb0b9c55..e3639ef778e 100644
--- a/spec/helpers/admin/background_migrations_helper_spec.rb
+++ b/spec/helpers/admin/background_migrations_helper_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe Admin::BackgroundMigrationsHelper do
describe '#batched_migration_status_badge_variant' do
using RSpec::Parameterized::TableSyntax
- where(:status, :variant) do
+ where(:status_name, :variant) do
:active | :info
:paused | :warning
:failed | :danger
@@ -16,7 +16,7 @@ RSpec.describe Admin::BackgroundMigrationsHelper do
subject { helper.batched_migration_status_badge_variant(migration) }
with_them do
- let(:migration) { build(:batched_background_migration, status: status) }
+ let(:migration) { build(:batched_background_migration, status_name) }
it { is_expected.to eq(variant) }
end
@@ -25,7 +25,7 @@ RSpec.describe Admin::BackgroundMigrationsHelper do
describe '#batched_migration_progress' do
subject { helper.batched_migration_progress(migration, completed_rows) }
- let(:migration) { build(:batched_background_migration, status: :active, total_tuple_count: 100) }
+ let(:migration) { build(:batched_background_migration, :active, total_tuple_count: 100) }
let(:completed_rows) { 25 }
it 'returns completion percentage' do
@@ -33,7 +33,7 @@ RSpec.describe Admin::BackgroundMigrationsHelper do
end
context 'when migration is finished' do
- let(:migration) { build(:batched_background_migration, status: :finished, total_tuple_count: nil) }
+ let(:migration) { build(:batched_background_migration, :finished, total_tuple_count: nil) }
it 'returns 100 percent' do
expect(subject).to eq(100)
@@ -41,7 +41,7 @@ RSpec.describe Admin::BackgroundMigrationsHelper do
end
context 'when total_tuple_count is nil' do
- let(:migration) { build(:batched_background_migration, status: :active, total_tuple_count: nil) }
+ let(:migration) { build(:batched_background_migration, :active, total_tuple_count: nil) }
it 'returns nil' do
expect(subject).to eq(nil)
diff --git a/spec/helpers/application_settings_helper_spec.rb b/spec/helpers/application_settings_helper_spec.rb
index 26d48bef24e..c93762416f5 100644
--- a/spec/helpers/application_settings_helper_spec.rb
+++ b/spec/helpers/application_settings_helper_spec.rb
@@ -149,7 +149,7 @@ RSpec.describe ApplicationSettingsHelper do
end
end
- describe '.storage_weights' do
+ describe '#storage_weights' do
let(:application_setting) { build(:application_setting) }
before do
@@ -158,12 +158,13 @@ RSpec.describe ApplicationSettingsHelper do
stub_application_setting(repository_storages_weighted: { 'default' => 100, 'storage_1' => 50, 'storage_2' => nil })
end
- it 'returns storages correctly' do
- expect(helper.storage_weights).to eq(OpenStruct.new(
- default: 100,
- storage_1: 50,
- storage_2: 0
- ))
+ it 'returns storage objects with assigned weights' do
+ expect(helper.storage_weights)
+ .to have_attributes(
+ default: 100,
+ storage_1: 50,
+ storage_2: 0
+ )
end
end
diff --git a/spec/helpers/boards_helper_spec.rb b/spec/helpers/boards_helper_spec.rb
index ec949fde30e..8d5dc3fb4be 100644
--- a/spec/helpers/boards_helper_spec.rb
+++ b/spec/helpers/boards_helper_spec.rb
@@ -102,6 +102,7 @@ RSpec.describe BoardsHelper do
allow(helper).to receive(:can?).with(user, :create_non_backlog_issues, project_board).and_return(true)
allow(helper).to receive(:can?).with(user, :admin_issue, project_board).and_return(true)
allow(helper).to receive(:can?).with(user, :admin_issue_board_list, project).and_return(false)
+ allow(helper).to receive(:can?).with(user, :admin_issue_board, project).and_return(false)
end
it 'returns a board_lists_path as lists_endpoint' do
@@ -129,12 +130,23 @@ RSpec.describe BoardsHelper do
it 'returns can_admin_list as false by default' do
expect(helper.board_data[:can_admin_list]).to eq('false')
end
- it 'returns can_admin_list as true when user can admin the board' do
+ it 'returns can_admin_list as true when user can admin the board lists' do
allow(helper).to receive(:can?).with(user, :admin_issue_board_list, project).and_return(true)
expect(helper.board_data[:can_admin_list]).to eq('true')
end
end
+
+ context 'can_admin_board' do
+ it 'returns can_admin_board as false by default' do
+ expect(helper.board_data[:can_admin_board]).to eq('false')
+ end
+ it 'returns can_admin_board as true when user can admin the board' do
+ allow(helper).to receive(:can?).with(user, :admin_issue_board, project).and_return(true)
+
+ expect(helper.board_data[:can_admin_board]).to eq('true')
+ end
+ end
end
context 'group board' do
@@ -146,6 +158,7 @@ RSpec.describe BoardsHelper do
allow(helper).to receive(:can?).with(user, :create_non_backlog_issues, group_board).and_return(true)
allow(helper).to receive(:can?).with(user, :admin_issue, group_board).and_return(true)
allow(helper).to receive(:can?).with(user, :admin_issue_board_list, base_group).and_return(false)
+ allow(helper).to receive(:can?).with(user, :admin_issue_board, base_group).and_return(false)
end
it 'returns correct path for base group' do
@@ -165,7 +178,7 @@ RSpec.describe BoardsHelper do
it 'returns can_admin_list as false by default' do
expect(helper.board_data[:can_admin_list]).to eq('false')
end
- it 'returns can_admin_list as true when user can admin the board' do
+ it 'returns can_admin_list as true when user can admin the board lists' do
allow(helper).to receive(:can?).with(user, :admin_issue_board_list, base_group).and_return(true)
expect(helper.board_data[:can_admin_list]).to eq('true')
diff --git a/spec/helpers/broadcast_messages_helper_spec.rb b/spec/helpers/broadcast_messages_helper_spec.rb
index e721a3fdc95..d4021a2eb59 100644
--- a/spec/helpers/broadcast_messages_helper_spec.rb
+++ b/spec/helpers/broadcast_messages_helper_spec.rb
@@ -115,37 +115,8 @@ RSpec.describe BroadcastMessagesHelper do
end
it 'includes the current message' do
- allow(helper).to receive(:broadcast_message_style).and_return(nil)
-
expect(helper.broadcast_message(current_broadcast_message)).to include 'Current Message'
end
-
- it 'includes custom style' do
- allow(helper).to receive(:broadcast_message_style).and_return('foo')
-
- expect(helper.broadcast_message(current_broadcast_message)).to include 'style="foo"'
- end
- end
-
- describe 'broadcast_message_style' do
- it 'defaults to no style' do
- broadcast_message = spy
-
- expect(helper.broadcast_message_style(broadcast_message)).to eq ''
- end
-
- it 'allows custom style for banner messages' do
- broadcast_message = BroadcastMessage.new(color: '#f2dede', font: '#b94a48', broadcast_type: "banner")
-
- expect(helper.broadcast_message_style(broadcast_message))
- .to match('background-color: #f2dede; color: #b94a48')
- end
-
- it 'does not add style for notification messages' do
- broadcast_message = BroadcastMessage.new(color: '#f2dede', broadcast_type: "notification")
-
- expect(helper.broadcast_message_style(broadcast_message)).to eq ''
- end
end
describe 'broadcast_message_status' do
diff --git a/spec/helpers/button_helper_spec.rb b/spec/helpers/button_helper_spec.rb
index 851e13d908f..a7f65aa3134 100644
--- a/spec/helpers/button_helper_spec.rb
+++ b/spec/helpers/button_helper_spec.rb
@@ -164,7 +164,7 @@ RSpec.describe ButtonHelper do
context 'with default options' do
context 'when no `text` attribute is not provided' do
it 'shows copy to clipboard button with default configuration and no text set to copy' do
- expect(element.attr('class')).to eq('btn btn-clipboard btn-transparent')
+ expect(element.attr('class')).to eq('btn btn-clipboard gl-button btn-default-tertiary btn-icon btn-sm')
expect(element.attr('type')).to eq('button')
expect(element.attr('aria-label')).to eq('Copy')
expect(element.attr('aria-live')).to eq('polite')
diff --git a/spec/helpers/ci/pipeline_editor_helper_spec.rb b/spec/helpers/ci/pipeline_editor_helper_spec.rb
index b844cc2e22b..12456deb538 100644
--- a/spec/helpers/ci/pipeline_editor_helper_spec.rb
+++ b/spec/helpers/ci/pipeline_editor_helper_spec.rb
@@ -45,8 +45,8 @@ RSpec.describe Ci::PipelineEditorHelper do
"default-branch" => project.default_branch_or_main,
"empty-state-illustration-path" => 'foo',
"initial-branch-name" => nil,
- "lint-help-page-path" => help_page_path('ci/lint', anchor: 'validate-basic-logic-and-syntax'),
- "lint-unavailable-help-page-path" => help_page_path('ci/pipeline_editor/index', anchor: 'configuration-validation-currently-not-available'),
+ "lint-help-page-path" => help_page_path('ci/lint', anchor: 'check-cicd-syntax'),
+ "lint-unavailable-help-page-path" => help_page_path('ci/pipeline_editor/index', anchor: 'configuration-validation-currently-not-available-message'),
"needs-help-page-path" => help_page_path('ci/yaml/index', anchor: 'needs'),
"new-merge-request-path" => '/mock/project/-/merge_requests/new',
"pipeline_etag" => graphql_etag_pipeline_sha_path(project.commit.sha),
@@ -72,8 +72,8 @@ RSpec.describe Ci::PipelineEditorHelper do
"default-branch" => project.default_branch_or_main,
"empty-state-illustration-path" => 'foo',
"initial-branch-name" => nil,
- "lint-help-page-path" => help_page_path('ci/lint', anchor: 'validate-basic-logic-and-syntax'),
- "lint-unavailable-help-page-path" => help_page_path('ci/pipeline_editor/index', anchor: 'configuration-validation-currently-not-available'),
+ "lint-help-page-path" => help_page_path('ci/lint', anchor: 'check-cicd-syntax'),
+ "lint-unavailable-help-page-path" => help_page_path('ci/pipeline_editor/index', anchor: 'configuration-validation-currently-not-available-message'),
"needs-help-page-path" => help_page_path('ci/yaml/index', anchor: 'needs'),
"new-merge-request-path" => '/mock/project/-/merge_requests/new',
"pipeline_etag" => '',
diff --git a/spec/helpers/ci/pipelines_helper_spec.rb b/spec/helpers/ci/pipelines_helper_spec.rb
index 2b76eaa87bc..c473e1e4ab6 100644
--- a/spec/helpers/ci/pipelines_helper_spec.rb
+++ b/spec/helpers/ci/pipelines_helper_spec.rb
@@ -151,5 +151,46 @@ RSpec.describe Ci::PipelinesHelper do
end
end
end
+
+ describe 'the `registration_token` attribute' do
+ subject { data[:registration_token] }
+
+ describe 'when the project is eligible for the `ios_specific_templates` experiment' do
+ let_it_be(:project) { create(:project, :auto_devops_disabled) }
+ let_it_be(:user) { create(:user) }
+
+ before do
+ allow(helper).to receive(:current_user).and_return(user)
+ project.add_developer(user)
+ create(:project_setting, project: project, target_platforms: %w(ios))
+ end
+
+ context 'when the `ios_specific_templates` experiment variant is control' do
+ before do
+ stub_experiments(ios_specific_templates: :control)
+ end
+
+ it { is_expected.to be_nil }
+ end
+
+ context 'when the `ios_specific_templates` experiment variant is candidate' do
+ before do
+ stub_experiments(ios_specific_templates: :candidate)
+ end
+
+ context 'when the user cannot register project runners' do
+ before do
+ allow(helper).to receive(:can?).with(user, :register_project_runners, project).and_return(false)
+ end
+
+ it { is_expected.to be_nil }
+ end
+
+ context 'when the user can register project runners' do
+ it { is_expected.to eq(project.runners_token) }
+ end
+ end
+ end
+ end
end
end
diff --git a/spec/helpers/ci/runners_helper_spec.rb b/spec/helpers/ci/runners_helper_spec.rb
index 832b4da0e20..0046d481282 100644
--- a/spec/helpers/ci/runners_helper_spec.rb
+++ b/spec/helpers/ci/runners_helper_spec.rb
@@ -10,24 +10,31 @@ RSpec.describe Ci::RunnersHelper do
end
describe '#runner_status_icon', :clean_gitlab_redis_cache do
- it "returns - not contacted yet" do
+ it "returns online text" do
+ runner = create(:ci_runner, contacted_at: 1.second.ago)
+ expect(helper.runner_status_icon(runner)).to include("is online")
+ end
+
+ it "returns never contacted" do
runner = create(:ci_runner)
- expect(helper.runner_status_icon(runner)).to include("not contacted yet")
+ expect(helper.runner_status_icon(runner)).to include("never contacted")
end
it "returns offline text" do
- runner = create(:ci_runner, contacted_at: 1.day.ago, active: true)
- expect(helper.runner_status_icon(runner)).to include("Runner is offline")
+ runner = create(:ci_runner, contacted_at: 1.day.ago)
+ expect(helper.runner_status_icon(runner)).to include("is offline")
end
- it "returns online text" do
- runner = create(:ci_runner, contacted_at: 1.second.ago, active: true)
- expect(helper.runner_status_icon(runner)).to include("Runner is online")
+ it "returns stale text" do
+ runner = create(:ci_runner, created_at: 4.months.ago, contacted_at: 4.months.ago)
+ expect(helper.runner_status_icon(runner)).to include("is stale")
+ expect(helper.runner_status_icon(runner)).to include("last contact was")
end
- it "returns paused text" do
- runner = create(:ci_runner, contacted_at: 1.second.ago, active: false)
- expect(helper.runner_status_icon(runner)).to include("Runner is paused")
+ it "returns stale text, when runner never contacted" do
+ runner = create(:ci_runner, created_at: 4.months.ago)
+ expect(helper.runner_status_icon(runner)).to include("is stale")
+ expect(helper.runner_status_icon(runner)).to include("never contacted")
end
end
@@ -79,7 +86,9 @@ RSpec.describe Ci::RunnersHelper do
it 'returns the data in format' do
expect(helper.admin_runners_data_attributes).to eq({
runner_install_help_page: 'https://docs.gitlab.com/runner/install/',
- registration_token: Gitlab::CurrentSettings.runners_registration_token
+ registration_token: Gitlab::CurrentSettings.runners_registration_token,
+ online_contact_timeout_secs: 7200,
+ stale_timeout_secs: 7889238
})
end
end
@@ -121,12 +130,14 @@ RSpec.describe Ci::RunnersHelper do
let(:group) { create(:group) }
it 'returns group data to render a runner list' do
- data = helper.group_runners_data_attributes(group)
-
- expect(data[:registration_token]).to eq(group.runners_token)
- expect(data[:group_id]).to eq(group.id)
- expect(data[:group_full_path]).to eq(group.full_path)
- expect(data[:runner_install_help_page]).to eq('https://docs.gitlab.com/runner/install/')
+ expect(helper.group_runners_data_attributes(group)).to eq({
+ registration_token: group.runners_token,
+ group_id: group.id,
+ group_full_path: group.full_path,
+ runner_install_help_page: 'https://docs.gitlab.com/runner/install/',
+ online_contact_timeout_secs: 7200,
+ stale_timeout_secs: 7889238
+ })
end
end
diff --git a/spec/helpers/clusters_helper_spec.rb b/spec/helpers/clusters_helper_spec.rb
index 53d33f2875f..4feb9d1a2cd 100644
--- a/spec/helpers/clusters_helper_spec.rb
+++ b/spec/helpers/clusters_helper_spec.rb
@@ -74,6 +74,10 @@ RSpec.describe ClustersHelper do
expect(subject[:add_cluster_path]).to eq("#{project_path(project)}/-/clusters/connect")
end
+ it 'displays create cluster path' do
+ expect(subject[:new_cluster_docs_path]).to eq("#{project_path(project)}/-/clusters/new_cluster_docs")
+ end
+
it 'displays project default branch' do
expect(subject[:default_branch_name]).to eq(project.default_branch)
end
diff --git a/spec/helpers/colors_helper_spec.rb b/spec/helpers/colors_helper_spec.rb
new file mode 100644
index 00000000000..ca5cafb7ebe
--- /dev/null
+++ b/spec/helpers/colors_helper_spec.rb
@@ -0,0 +1,89 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ColorsHelper do
+ using RSpec::Parameterized::TableSyntax
+
+ describe '#hex_color_to_rgb_array' do
+ context 'valid hex color' do
+ where(:hex_color, :rgb_array) do
+ '#000000' | [0, 0, 0]
+ '#aaaaaa' | [170, 170, 170]
+ '#cCcCcC' | [204, 204, 204]
+ '#FFFFFF' | [255, 255, 255]
+ '#000abc' | [0, 10, 188]
+ '#123456' | [18, 52, 86]
+ '#a1b2c3' | [161, 178, 195]
+ '#000' | [0, 0, 0]
+ '#abc' | [170, 187, 204]
+ '#321' | [51, 34, 17]
+ '#7E2' | [119, 238, 34]
+ '#fFf' | [255, 255, 255]
+ end
+
+ with_them do
+ it 'returns correct RGB array' do
+ expect(helper.hex_color_to_rgb_array(hex_color)).to eq(rgb_array)
+ end
+ end
+ end
+
+ context 'invalid hex color' do
+ where(:hex_color) { ['', '0', '#00', '#ffff', '#1234567', 'invalid', [], 1, nil] }
+
+ with_them do
+ it 'raise ArgumentError' do
+ expect { helper.hex_color_to_rgb_array(hex_color) }.to raise_error(ArgumentError)
+ end
+ end
+ end
+ end
+
+ describe '#rgb_array_to_hex_color' do
+ context 'valid RGB array' do
+ where(:rgb_array, :hex_color) do
+ [0, 0, 0] | '#000000'
+ [0, 0, 255] | '#0000ff'
+ [0, 255, 0] | '#00ff00'
+ [255, 0, 0] | '#ff0000'
+ [12, 34, 56] | '#0c2238'
+ [222, 111, 88] | '#de6f58'
+ [255, 255, 255] | '#ffffff'
+ end
+
+ with_them do
+ it 'returns correct hex color' do
+ expect(helper.rgb_array_to_hex_color(rgb_array)).to eq(hex_color)
+ end
+ end
+ end
+
+ context 'invalid RGB array' do
+ where(:rgb_array) do
+ [
+ '',
+ '#000000',
+ 0,
+ nil,
+ [],
+ [0],
+ [0, 0],
+ [0, 0, 0, 0],
+ [-1, 0, 0],
+ [0, -1, 0],
+ [0, 0, -1],
+ [256, 0, 0],
+ [0, 256, 0],
+ [0, 0, 256]
+ ]
+ end
+
+ with_them do
+ it 'raise ArgumentError' do
+ expect { helper.rgb_array_to_hex_color(rgb_array) }.to raise_error(ArgumentError)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/helpers/commits_helper_spec.rb b/spec/helpers/commits_helper_spec.rb
index 98db185c180..961e7688202 100644
--- a/spec/helpers/commits_helper_spec.rb
+++ b/spec/helpers/commits_helper_spec.rb
@@ -163,13 +163,7 @@ RSpec.describe CommitsHelper do
end
end
- let(:params) do
- {
- page: page
- }
- end
-
- subject { helper.conditionally_paginate_diff_files(diffs_collection, paginate: paginate, per: Projects::CommitController::COMMIT_DIFFS_PER_PAGE) }
+ subject { helper.conditionally_paginate_diff_files(diffs_collection, paginate: paginate, page: page, per: Projects::CommitController::COMMIT_DIFFS_PER_PAGE) }
before do
allow(helper).to receive(:params).and_return(params)
@@ -183,7 +177,7 @@ RSpec.describe CommitsHelper do
end
it "can change the number of items per page" do
- commits = helper.conditionally_paginate_diff_files(diffs_collection, paginate: paginate, per: 10)
+ commits = helper.conditionally_paginate_diff_files(diffs_collection, page: page, paginate: paginate, per: 10)
expect(commits).to be_an(Array)
expect(commits.size).to eq(10)
diff --git a/spec/helpers/diff_helper_spec.rb b/spec/helpers/diff_helper_spec.rb
index 29708f10de4..84e702cd6a9 100644
--- a/spec/helpers/diff_helper_spec.rb
+++ b/spec/helpers/diff_helper_spec.rb
@@ -290,6 +290,53 @@ RSpec.describe DiffHelper do
end
end
+ describe "#diff_nomappinginraw_line" do
+ using RSpec::Parameterized::TableSyntax
+
+ let(:line) { double("line") }
+ let(:line_type) { 'line_type' }
+
+ before do
+ allow(line).to receive(:rich_text).and_return('line_text')
+ allow(line).to receive(:type).and_return(line_type)
+ end
+
+ it 'generates only single line num' do
+ output = diff_nomappinginraw_line(line, ['line_num_1'], nil, ['line_content'])
+
+ expect(output).to be_html_safe
+ expect(output).to have_css 'td:nth-child(1).line_num_1'
+ expect(output).to have_css 'td:nth-child(2).line_content', text: 'line_text'
+ expect(output).not_to have_css 'td:nth-child(3)'
+ end
+
+ it 'generates only both line nums' do
+ output = diff_nomappinginraw_line(line, ['line_num_1'], ['line_num_2'], ['line_content'])
+
+ expect(output).to be_html_safe
+ expect(output).to have_css 'td:nth-child(1).line_num_1'
+ expect(output).to have_css 'td:nth-child(2).line_num_2'
+ expect(output).to have_css 'td:nth-child(3).line_content', text: 'line_text'
+ end
+
+ where(:line_type, :added_class) do
+ 'old-nomappinginraw' | '.old'
+ 'new-nomappinginraw' | '.new'
+ 'unchanged-nomappinginraw' | ''
+ end
+
+ with_them do
+ it "appends the correct class" do
+ output = diff_nomappinginraw_line(line, ['line_num_1'], ['line_num_2'], ['line_content'])
+
+ expect(output).to be_html_safe
+ expect(output).to have_css 'td:nth-child(1).line_num_1' + added_class
+ expect(output).to have_css 'td:nth-child(2).line_num_2' + added_class
+ expect(output).to have_css 'td:nth-child(3).line_content' + added_class, text: 'line_text'
+ end
+ end
+ end
+
describe '#render_overflow_warning?' do
using RSpec::Parameterized::TableSyntax
@@ -378,16 +425,6 @@ RSpec.describe DiffHelper do
end
end
- describe '#diff_file_path_text' do
- it 'returns full path by default' do
- expect(diff_file_path_text(diff_file)).to eq(diff_file.new_path)
- end
-
- it 'returns truncated path' do
- expect(diff_file_path_text(diff_file, max: 10)).to eq("...open.rb")
- end
- end
-
describe "#collapsed_diff_url" do
let(:params) do
{
diff --git a/spec/helpers/environment_helper_spec.rb b/spec/helpers/environment_helper_spec.rb
index 8e5f38cd95a..1fcbcd8c4f9 100644
--- a/spec/helpers/environment_helper_spec.rb
+++ b/spec/helpers/environment_helper_spec.rb
@@ -55,7 +55,7 @@ RSpec.describe EnvironmentHelper do
can_destroy_environment: true,
can_stop_environment: true,
can_admin_environment: true,
- environment_metrics_path: environment_metrics_path(environment),
+ environment_metrics_path: project_metrics_dashboard_path(project, environment: environment),
environments_fetch_path: project_environments_path(project, format: :json),
environment_edit_path: edit_project_environment_path(project, environment),
environment_stop_path: stop_project_environment_path(project, environment),
diff --git a/spec/helpers/environments_helper_spec.rb b/spec/helpers/environments_helper_spec.rb
index 38f06b19b94..52f02fba4ec 100644
--- a/spec/helpers/environments_helper_spec.rb
+++ b/spec/helpers/environments_helper_spec.rb
@@ -20,7 +20,7 @@ RSpec.describe EnvironmentsHelper do
expect(metrics_data).to include(
'settings_path' => edit_project_integration_path(project, 'prometheus'),
'clusters_path' => project_clusters_path(project),
- 'metrics_dashboard_base_path' => environment_metrics_path(environment),
+ 'metrics_dashboard_base_path' => project_metrics_dashboard_path(project, environment: environment),
'current_environment_name' => environment.name,
'documentation_path' => help_page_path('administration/monitoring/prometheus/index.md'),
'add_dashboard_documentation_path' => help_page_path('operations/metrics/dashboards/index.md', anchor: 'add-a-new-dashboard-to-your-project'),
diff --git a/spec/helpers/groups/group_members_helper_spec.rb b/spec/helpers/groups/group_members_helper_spec.rb
index f5bc587bce3..ab11bc1f5fd 100644
--- a/spec/helpers/groups/group_members_helper_spec.rb
+++ b/spec/helpers/groups/group_members_helper_spec.rb
@@ -38,7 +38,9 @@ RSpec.describe Groups::GroupMembersHelper do
shared_group,
members: present_members(members_collection),
invited: present_members(invited),
- access_requests: present_members(access_requests)
+ access_requests: present_members(access_requests),
+ include_relations: [:inherited, :direct],
+ search: nil
)
end
@@ -96,6 +98,64 @@ RSpec.describe Groups::GroupMembersHelper do
it 'sets `member_path` property' do
expect(subject[:group][:member_path]).to eq('/groups/foo-bar/-/group_links/:id')
end
+
+ context 'inherited' do
+ let_it_be(:sub_shared_group) { create(:group, parent: shared_group) }
+ let_it_be(:sub_shared_with_group) { create(:group) }
+ let_it_be(:sub_group_group_link) { create(:group_group_link, shared_group: sub_shared_group, shared_with_group: sub_shared_with_group) }
+
+ let_it_be(:subject_group) { sub_shared_group }
+
+ before do
+ allow(helper).to receive(:group_group_member_path).with(sub_shared_group, ':id').and_return('/groups/foo-bar/-/group_members/:id')
+ allow(helper).to receive(:group_group_link_path).with(sub_shared_group, ':id').and_return('/groups/foo-bar/-/group_links/:id')
+ allow(helper).to receive(:can?).with(current_user, :admin_group_member, sub_shared_group).and_return(true)
+ allow(helper).to receive(:can?).with(current_user, :export_group_memberships, sub_shared_group).and_return(true)
+ end
+
+ subject do
+ helper.group_members_app_data(
+ sub_shared_group,
+ members: present_members(members_collection),
+ invited: present_members(invited),
+ access_requests: present_members(access_requests),
+ include_relations: include_relations,
+ search: nil
+ )
+ end
+
+ using RSpec::Parameterized::TableSyntax
+
+ where(:include_relations, :result) do
+ [:inherited, :direct] | lazy { [group_group_link, sub_group_group_link].map(&:id) }
+ [:inherited] | lazy { [group_group_link].map(&:id) }
+ [:direct] | lazy { [sub_group_group_link].map(&:id) }
+ end
+
+ with_them do
+ it 'returns correct group links' do
+ expect(subject[:group][:members].map { |link| link[:id] }).to match_array(result)
+ end
+ end
+
+ context 'when group_member_inherited_group disabled' do
+ before do
+ stub_feature_flags(group_member_inherited_group: false)
+ end
+
+ where(:include_relations, :result) do
+ [:inherited, :direct] | lazy { [sub_group_group_link.id] }
+ [:inherited] | lazy { [sub_group_group_link.id] }
+ [:direct] | lazy { [sub_group_group_link.id] }
+ end
+
+ with_them do
+ it 'always returns direct member links' do
+ expect(subject[:group][:members].map { |link| link[:id] }).to match_array(result)
+ end
+ end
+ end
+ end
end
context 'when pagination is not available' do
diff --git a/spec/helpers/invite_members_helper_spec.rb b/spec/helpers/invite_members_helper_spec.rb
index 796d68e290e..859d145eb53 100644
--- a/spec/helpers/invite_members_helper_spec.rb
+++ b/spec/helpers/invite_members_helper_spec.rb
@@ -19,6 +19,7 @@ RSpec.describe InviteMembersHelper do
it 'has expected common attributes' do
attributes = {
id: project.id,
+ root_id: project.root_ancestor.id,
name: project.name,
default_access_level: Gitlab::Access::GUEST,
invalid_groups: project.related_group_ids,
@@ -35,6 +36,7 @@ RSpec.describe InviteMembersHelper do
it 'has expected common attributes' do
attributes = {
id: project.id,
+ root_id: project.root_ancestor.id,
name: project.name,
default_access_level: Gitlab::Access::GUEST
}
diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb
index ed50a4daae8..ee5b0145d13 100644
--- a/spec/helpers/issuables_helper_spec.rb
+++ b/spec/helpers/issuables_helper_spec.rb
@@ -288,7 +288,7 @@ RSpec.describe IssuablesHelper do
canUpdate: true,
canDestroy: true,
issuableRef: "##{issue.iid}",
- markdownPreviewPath: "/#{@project.full_path}/preview_markdown",
+ markdownPreviewPath: "/#{@project.full_path}/preview_markdown?target_id=#{issue.iid}&target_type=Issue",
markdownDocsPath: '/help/user/markdown',
lockVersion: issue.lock_version,
projectPath: @project.path,
diff --git a/spec/helpers/issues_helper_spec.rb b/spec/helpers/issues_helper_spec.rb
index a85b1bd0a48..0f653fdd282 100644
--- a/spec/helpers/issues_helper_spec.rb
+++ b/spec/helpers/issues_helper_spec.rb
@@ -74,8 +74,8 @@ RSpec.describe IssuesHelper do
expect(helper.award_state_class(awardable, AwardEmoji.all, build(:user))).to eq('disabled')
end
- it 'returns active string for author' do
- expect(helper.award_state_class(awardable, AwardEmoji.all, upvote.user)).to eq('active')
+ it 'returns selected class for author' do
+ expect(helper.award_state_class(awardable, AwardEmoji.all, upvote.user)).to eq('selected')
end
it 'is blank for a user that has access to the awardable' do
@@ -368,6 +368,16 @@ RSpec.describe IssuesHelper do
end
end
+ describe '#issues_form_data' do
+ it 'returns expected result' do
+ expected = {
+ new_issue_path: new_project_issue_path(project)
+ }
+
+ expect(helper.issues_form_data(project)).to include(expected)
+ end
+ end
+
describe '#issue_manual_ordering_class' do
context 'when sorting by relative position' do
before do
diff --git a/spec/helpers/namespaces_helper_spec.rb b/spec/helpers/namespaces_helper_spec.rb
index 00aa0fd1cba..52c1130e818 100644
--- a/spec/helpers/namespaces_helper_spec.rb
+++ b/spec/helpers/namespaces_helper_spec.rb
@@ -268,4 +268,15 @@ RSpec.describe NamespacesHelper do
end
end
end
+
+ describe '#pipeline_usage_quota_app_data' do
+ it 'returns a hash with necessary data for the frontend' do
+ expect(helper.pipeline_usage_quota_app_data(user_group)).to eql({
+ namespace_actual_plan_name: user_group.actual_plan_name,
+ namespace_path: user_group.full_path,
+ namespace_id: user_group.id,
+ page_size: Kaminari.config.default_per_page
+ })
+ end
+ end
end
diff --git a/spec/helpers/packages_helper_spec.rb b/spec/helpers/packages_helper_spec.rb
index d7be4194e67..fc69aee4e04 100644
--- a/spec/helpers/packages_helper_spec.rb
+++ b/spec/helpers/packages_helper_spec.rb
@@ -65,11 +65,11 @@ RSpec.describe PackagesHelper do
end
end
- describe '#show_cleanup_policy_on_alert' do
+ describe '#show_cleanup_policy_link' do
let_it_be(:user) { create(:user) }
let_it_be_with_reload(:container_repository) { create(:container_repository) }
- subject { helper.show_cleanup_policy_on_alert(project.reload) }
+ subject { helper.show_cleanup_policy_link(project.reload) }
where(:com, :config_registry, :project_registry, :nil_policy, :container_repositories_exist, :expected_result) do
false | false | false | false | false | false
diff --git a/spec/helpers/preferences_helper_spec.rb b/spec/helpers/preferences_helper_spec.rb
index 8c13afc2b45..01235c7bb51 100644
--- a/spec/helpers/preferences_helper_spec.rb
+++ b/spec/helpers/preferences_helper_spec.rb
@@ -145,6 +145,67 @@ RSpec.describe PreferencesHelper do
end
end
+ describe '#user_diffs_colors' do
+ context 'with a user' do
+ it "returns user's diffs colors" do
+ stub_user(diffs_addition_color: '#123456', diffs_deletion_color: '#abcdef')
+
+ expect(helper.user_diffs_colors).to eq({ addition: '#123456', deletion: '#abcdef' })
+ end
+
+ it 'omits property if nil' do
+ stub_user(diffs_addition_color: '#123456', diffs_deletion_color: nil)
+
+ expect(helper.user_diffs_colors).to eq({ addition: '#123456' })
+ end
+
+ it 'omits property if blank' do
+ stub_user(diffs_addition_color: '', diffs_deletion_color: '#abcdef')
+
+ expect(helper.user_diffs_colors).to eq({ deletion: '#abcdef' })
+ end
+ end
+
+ context 'without a user' do
+ it 'returns no properties' do
+ stub_user
+
+ expect(helper.user_diffs_colors).to eq({})
+ end
+ end
+ end
+
+ describe '#custom_diff_color_classes' do
+ context 'with a user' do
+ it 'returns color classes' do
+ stub_user(diffs_addition_color: '#123456', diffs_deletion_color: '#abcdef')
+
+ expect(helper.custom_diff_color_classes)
+ .to match_array(%w[diff-custom-addition-color diff-custom-deletion-color])
+ end
+
+ it 'omits property if nil' do
+ stub_user(diffs_addition_color: '#123456', diffs_deletion_color: nil)
+
+ expect(helper.custom_diff_color_classes).to match_array(['diff-custom-addition-color'])
+ end
+
+ it 'omits property if blank' do
+ stub_user(diffs_addition_color: '', diffs_deletion_color: '#abcdef')
+
+ expect(helper.custom_diff_color_classes).to match_array(['diff-custom-deletion-color'])
+ end
+ end
+
+ context 'without a user' do
+ it 'returns no classes' do
+ stub_user
+
+ expect(helper.custom_diff_color_classes).to match_array([])
+ end
+ end
+ end
+
describe '#language_choices' do
include StubLanguagesTranslationPercentage
diff --git a/spec/helpers/projects/alert_management_helper_spec.rb b/spec/helpers/projects/alert_management_helper_spec.rb
index 0a5c4bedaa6..a78a8add336 100644
--- a/spec/helpers/projects/alert_management_helper_spec.rb
+++ b/spec/helpers/projects/alert_management_helper_spec.rb
@@ -110,15 +110,34 @@ RSpec.describe Projects::AlertManagementHelper do
describe '#alert_management_detail_data' do
let(:alert_id) { 1 }
let(:issues_path) { project_issues_path(project) }
+ let(:can_update_alert) { true }
+
+ before do
+ allow(helper)
+ .to receive(:can?)
+ .with(current_user, :update_alert_management_alert, project)
+ .and_return(can_update_alert)
+ end
it 'returns detail page configuration' do
- expect(helper.alert_management_detail_data(project, alert_id)).to eq(
+ expect(helper.alert_management_detail_data(current_user, project, alert_id)).to eq(
'alert-id' => alert_id,
'project-path' => project_path,
'project-id' => project_id,
'project-issues-path' => issues_path,
- 'page' => 'OPERATIONS'
+ 'page' => 'OPERATIONS',
+ 'can-update' => 'true'
)
end
+
+ context 'when user cannot update alert' do
+ let(:can_update_alert) { false }
+
+ it 'shows error tracking enablement as disabled' do
+ expect(helper.alert_management_detail_data(current_user, project, alert_id)).to include(
+ 'can-update' => 'false'
+ )
+ end
+ end
end
end
diff --git a/spec/helpers/projects/pipeline_helper_spec.rb b/spec/helpers/projects/pipeline_helper_spec.rb
new file mode 100644
index 00000000000..67405ee3b21
--- /dev/null
+++ b/spec/helpers/projects/pipeline_helper_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::PipelineHelper do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:raw_pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) }
+ let_it_be(:pipeline) { Ci::PipelinePresenter.new(raw_pipeline, current_user: user)}
+
+ describe '#js_pipeline_tabs_data' do
+ subject(:pipeline_tabs_data) { helper.js_pipeline_tabs_data(project, pipeline) }
+
+ it 'returns pipeline tabs data' do
+ expect(pipeline_tabs_data).to include({
+ can_generate_codequality_reports: pipeline.can_generate_codequality_reports?.to_json,
+ graphql_resource_etag: graphql_etag_pipeline_path(pipeline),
+ metrics_path: namespace_project_ci_prometheus_metrics_histograms_path(namespace_id: project.namespace, project_id: project, format: :json),
+ pipeline_project_path: project.full_path
+ })
+ end
+ end
+end
diff --git a/spec/helpers/projects/security/configuration_helper_spec.rb b/spec/helpers/projects/security/configuration_helper_spec.rb
index 4c30ba87897..034bfd27844 100644
--- a/spec/helpers/projects/security/configuration_helper_spec.rb
+++ b/spec/helpers/projects/security/configuration_helper_spec.rb
@@ -10,4 +10,10 @@ RSpec.describe Projects::Security::ConfigurationHelper do
it { is_expected.to eq("https://#{ApplicationHelper.promo_host}/pricing/") }
end
+
+ describe 'vulnerability_training_docs_path' do
+ subject { helper.vulnerability_training_docs_path }
+
+ it { is_expected.to eq(help_page_path('user/application_security/vulnerabilities/index', anchor: 'enable-security-training-for-vulnerabilities')) }
+ end
end
diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb
index 24d908a5dd3..1cf36fd69cf 100644
--- a/spec/helpers/projects_helper_spec.rb
+++ b/spec/helpers/projects_helper_spec.rb
@@ -1000,6 +1000,54 @@ RSpec.describe ProjectsHelper do
end
end
+ context 'fork security helpers' do
+ using RSpec::Parameterized::TableSyntax
+
+ describe "#able_to_see_merge_requests?" do
+ subject { helper.able_to_see_merge_requests?(project, user) }
+
+ where(:can_read_merge_request, :merge_requests_enabled, :expected) do
+ false | false | false
+ true | false | false
+ false | true | false
+ true | true | true
+ end
+
+ with_them do
+ before do
+ allow(project).to receive(:merge_requests_enabled?).and_return(merge_requests_enabled)
+ allow(helper).to receive(:can?).with(user, :read_merge_request, project).and_return(can_read_merge_request)
+ end
+
+ it 'returns the correct response' do
+ expect(subject).to eq(expected)
+ end
+ end
+ end
+
+ describe "#able_to_see_issues?" do
+ subject { helper.able_to_see_issues?(project, user) }
+
+ where(:can_read_issues, :issues_enabled, :expected) do
+ false | false | false
+ true | false | false
+ false | true | false
+ true | true | true
+ end
+
+ with_them do
+ before do
+ allow(project).to receive(:issues_enabled?).and_return(issues_enabled)
+ allow(helper).to receive(:can?).with(user, :read_issue, project).and_return(can_read_issues)
+ end
+
+ it 'returns the correct response' do
+ expect(subject).to eq(expected)
+ end
+ end
+ end
+ end
+
describe '#fork_button_disabled_tooltip' do
using RSpec::Parameterized::TableSyntax
diff --git a/spec/helpers/routing/pseudonymization_helper_spec.rb b/spec/helpers/routing/pseudonymization_helper_spec.rb
index 1221917e6b7..cf716931fe2 100644
--- a/spec/helpers/routing/pseudonymization_helper_spec.rb
+++ b/spec/helpers/routing/pseudonymization_helper_spec.rb
@@ -180,7 +180,7 @@ RSpec.describe ::Routing::PseudonymizationHelper do
end
context 'when some query params are not required to be masked' do
- let(:masked_url) { "http://localhost/dashboard/issues?author_username=masked_author_username&scope=all&state=masked_state" }
+ let(:masked_url) { "http://localhost/dashboard/issues?author_username=masked_author_username&scope=all&state=masked_state&tab=2" }
let(:request) do
double(:Request,
path_parameters: {
@@ -189,11 +189,11 @@ RSpec.describe ::Routing::PseudonymizationHelper do
},
protocol: 'http',
host: 'localhost',
- query_string: 'author_username=root&scope=all&state=opened')
+ query_string: 'author_username=root&scope=all&state=opened&tab=2')
end
before do
- stub_const('Routing::PseudonymizationHelper::MaskHelper::QUERY_PARAMS_TO_NOT_MASK', %w[scope].freeze)
+ stub_const('Routing::PseudonymizationHelper::MaskHelper::QUERY_PARAMS_TO_NOT_MASK', %w[scope tab].freeze)
allow(helper).to receive(:request).and_return(request)
end
diff --git a/spec/helpers/search_helper_spec.rb b/spec/helpers/search_helper_spec.rb
index 78cc1dcee01..d1be451a759 100644
--- a/spec/helpers/search_helper_spec.rb
+++ b/spec/helpers/search_helper_spec.rb
@@ -71,7 +71,7 @@ RSpec.describe SearchHelper do
create(:group).add_owner(user)
result = search_autocomplete_opts("gro").first
- expect(result.keys).to match_array(%i[category id label url avatar_url])
+ expect(result.keys).to match_array(%i[category id value label url avatar_url])
end
it 'includes the users recently viewed issues', :aggregate_failures do
@@ -467,6 +467,12 @@ RSpec.describe SearchHelper do
describe '#show_user_search_tab?' do
subject { show_user_search_tab? }
+ let(:current_user) { build(:user) }
+
+ before do
+ allow(self).to receive(:current_user).and_return(current_user)
+ end
+
context 'when project search' do
before do
@project = :some_project
@@ -481,20 +487,48 @@ RSpec.describe SearchHelper do
end
end
- context 'when not project search' do
+ context 'when group search' do
+ before do
+ @group = :some_group
+ end
+
+ context 'when current_user can read_users_list' do
+ before do
+ allow(self).to receive(:can?).with(current_user, :read_users_list).and_return(true)
+ end
+
+ it { is_expected.to eq(true) }
+ end
+
+ context 'when current_user cannot read_users_list' do
+ before do
+ allow(self).to receive(:can?).with(current_user, :read_users_list).and_return(false)
+ end
+
+ it { is_expected.to eq(false) }
+ end
+ end
+
+ context 'when global search' do
context 'when current_user can read_users_list' do
before do
- allow(self).to receive(:current_user).and_return(:the_current_user)
- allow(self).to receive(:can?).with(:the_current_user, :read_users_list).and_return(true)
+ allow(self).to receive(:can?).with(current_user, :read_users_list).and_return(true)
end
it { is_expected.to eq(true) }
+
+ context 'when global_search_user_tab feature flag is disabled' do
+ before do
+ stub_feature_flags(global_search_users_tab: false)
+ end
+
+ it { is_expected.to eq(false) }
+ end
end
context 'when current_user cannot read_users_list' do
before do
- allow(self).to receive(:current_user).and_return(:the_current_user)
- allow(self).to receive(:can?).with(:the_current_user, :read_users_list).and_return(false)
+ allow(self).to receive(:can?).with(current_user, :read_users_list).and_return(false)
end
it { is_expected.to eq(false) }
diff --git a/spec/helpers/timeboxes_helper_spec.rb b/spec/helpers/timeboxes_helper_spec.rb
index 1b9442c0a09..e31f2df7372 100644
--- a/spec/helpers/timeboxes_helper_spec.rb
+++ b/spec/helpers/timeboxes_helper_spec.rb
@@ -24,34 +24,6 @@ RSpec.describe TimeboxesHelper do
end
end
- describe '#milestone_counts' do
- let(:project) { create(:project) }
- let(:counts) { helper.milestone_counts(project.milestones) }
-
- context 'when there are milestones' do
- it 'returns the correct counts' do
- create_list(:active_milestone, 2, project: project)
- create(:closed_milestone, project: project)
-
- expect(counts).to eq(opened: 2, closed: 1, all: 3)
- end
- end
-
- context 'when there are only milestones of one type' do
- it 'returns the correct counts' do
- create_list(:active_milestone, 2, project: project)
-
- expect(counts).to eq(opened: 2, closed: 0, all: 2)
- end
- end
-
- context 'when there are no milestones' do
- it 'returns the correct counts' do
- expect(counts).to eq(opened: 0, closed: 0, all: 0)
- end
- end
- end
-
describe "#group_milestone_route" do
let(:group) { build_stubbed(:group) }
let(:subgroup) { build_stubbed(:group, parent: group, name: "Test Subgrp") }
diff --git a/spec/helpers/wiki_helper_spec.rb b/spec/helpers/wiki_helper_spec.rb
index 0d04ca2b876..5adcbe3334d 100644
--- a/spec/helpers/wiki_helper_spec.rb
+++ b/spec/helpers/wiki_helper_spec.rb
@@ -145,4 +145,8 @@ RSpec.describe WikiHelper do
expect(subject).to include('wiki-directory-nest-level' => 0)
end
end
+
+ it_behaves_like 'wiki endpoint helpers' do
+ let_it_be(:page) { create(:wiki_page) }
+ end
end
diff --git a/spec/initializers/mail_encoding_patch_spec.rb b/spec/initializers/mail_encoding_patch_spec.rb
index 52a0d041f48..12539c9ca52 100644
--- a/spec/initializers/mail_encoding_patch_spec.rb
+++ b/spec/initializers/mail_encoding_patch_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
+# rubocop:disable RSpec/VariableDefinition, RSpec/VariableName
require 'fast_spec_helper'
-
require 'mail'
require_relative '../../config/initializers/mail_encoding_patch'
@@ -205,3 +205,4 @@ RSpec.describe 'Mail quoted-printable transfer encoding patch and Unicode charac
end
end
end
+# rubocop:enable RSpec/VariableDefinition, RSpec/VariableName
diff --git a/spec/initializers/omniauth_spec.rb b/spec/initializers/omniauth_spec.rb
new file mode 100644
index 00000000000..928eac8c533
--- /dev/null
+++ b/spec/initializers/omniauth_spec.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'OmniAuth initializer for GitLab' do
+ let(:load_omniauth_initializer) do
+ load Rails.root.join('config/initializers/omniauth.rb')
+ end
+
+ describe '#full_host' do
+ subject { OmniAuth.config.full_host }
+
+ let(:base_url) { 'http://localhost/test' }
+
+ before do
+ allow(Settings).to receive(:gitlab).and_return({ 'base_url' => base_url })
+ allow(Gitlab::OmniauthInitializer).to receive(:full_host).and_return('proc')
+ end
+
+ context 'with feature flags not available' do
+ before do
+ expect(Feature).to receive(:feature_flags_available?).and_return(false)
+ load_omniauth_initializer
+ end
+
+ it { is_expected.to eq(base_url) }
+ end
+
+ context 'with the omniauth_initializer_fullhost_proc FF disabled' do
+ before do
+ stub_feature_flags(omniauth_initializer_fullhost_proc: false)
+ load_omniauth_initializer
+ end
+
+ it { is_expected.to eq(base_url) }
+ end
+
+ context 'with the omniauth_initializer_fullhost_proc FF disabled' do
+ before do
+ load_omniauth_initializer
+ end
+
+ it { is_expected.to eq('proc') }
+ end
+ end
+end
diff --git a/spec/lib/api/entities/application_setting_spec.rb b/spec/lib/api/entities/application_setting_spec.rb
new file mode 100644
index 00000000000..5adb825672c
--- /dev/null
+++ b/spec/lib/api/entities/application_setting_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Entities::ApplicationSetting do
+ let_it_be(:application_setting, reload: true) { create(:application_setting) }
+
+ subject(:output) { described_class.new(application_setting).as_json }
+
+ context 'housekeeping_bitmaps_enabled usage is deprecated and always enabled' do
+ before do
+ application_setting.housekeeping_bitmaps_enabled = housekeeping_bitmaps_enabled
+ end
+
+ context 'when housekeeping_bitmaps_enabled db column is false' do
+ let(:housekeeping_bitmaps_enabled) { false }
+
+ it 'returns true' do
+ expect(subject[:housekeeping_bitmaps_enabled]).to eq(true)
+ end
+ end
+
+ context 'when housekeeping_bitmaps_enabled db column is true' do
+ let(:housekeeping_bitmaps_enabled) { false }
+
+ it 'returns true' do
+ expect(subject[:housekeeping_bitmaps_enabled]).to eq(true)
+ end
+ end
+ end
+end
diff --git a/spec/lib/api/validations/validators/limit_spec.rb b/spec/lib/api/validations/validators/limit_spec.rb
index d71dde470cc..0c10e2f74d2 100644
--- a/spec/lib/api/validations/validators/limit_spec.rb
+++ b/spec/lib/api/validations/validators/limit_spec.rb
@@ -22,4 +22,10 @@ RSpec.describe API::Validations::Validators::Limit do
expect_validation_error('test' => "#{'a' * 256}")
end
end
+
+ context 'value is nil' do
+ it 'does not raise a validation error' do
+ expect_no_validation_error('test' => nil)
+ end
+ end
end
diff --git a/spec/lib/backup/artifacts_spec.rb b/spec/lib/backup/artifacts_spec.rb
deleted file mode 100644
index d830692d96b..00000000000
--- a/spec/lib/backup/artifacts_spec.rb
+++ /dev/null
@@ -1,24 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Backup::Artifacts do
- let(:progress) { StringIO.new }
-
- subject(:backup) { described_class.new(progress) }
-
- describe '#dump' do
- before do
- allow(File).to receive(:realpath).with('/var/gitlab-artifacts').and_return('/var/gitlab-artifacts')
- allow(File).to receive(:realpath).with('/var/gitlab-artifacts/..').and_return('/var')
- allow(JobArtifactUploader).to receive(:root) { '/var/gitlab-artifacts' }
- end
-
- it 'excludes tmp from backup tar' do
- expect(backup).to receive(:tar).and_return('blabla-tar')
- expect(backup).to receive(:run_pipeline!).with([%w(blabla-tar --exclude=lost+found --exclude=./tmp -C /var/gitlab-artifacts -cf - .), 'gzip -c -1'], any_args).and_return([[true, true], ''])
- expect(backup).to receive(:pipeline_succeeded?).and_return(true)
- backup.dump('artifacts.tar.gz')
- end
- end
-end
diff --git a/spec/lib/backup/files_spec.rb b/spec/lib/backup/files_spec.rb
index bbc465a26c9..f98b5e1414f 100644
--- a/spec/lib/backup/files_spec.rb
+++ b/spec/lib/backup/files_spec.rb
@@ -39,7 +39,7 @@ RSpec.describe Backup::Files do
end
describe '#restore' do
- subject { described_class.new(progress, 'registry', '/var/gitlab-registry') }
+ subject { described_class.new(progress, '/var/gitlab-registry') }
let(:timestamp) { Time.utc(2017, 3, 22) }
@@ -110,7 +110,7 @@ RSpec.describe Backup::Files do
end
describe '#dump' do
- subject { described_class.new(progress, 'pages', '/var/gitlab-pages', excludes: ['@pages.tmp']) }
+ subject { described_class.new(progress, '/var/gitlab-pages', excludes: ['@pages.tmp']) }
before do
allow(subject).to receive(:run_pipeline!).and_return([[true, true], ''])
@@ -118,14 +118,14 @@ RSpec.describe Backup::Files do
end
it 'raises no errors' do
- expect { subject.dump('registry.tar.gz') }.not_to raise_error
+ expect { subject.dump('registry.tar.gz', 'backup_id') }.not_to raise_error
end
it 'excludes tmp dirs from archive' do
expect(subject).to receive(:tar).and_return('blabla-tar')
expect(subject).to receive(:run_pipeline!).with([%w(blabla-tar --exclude=lost+found --exclude=./@pages.tmp -C /var/gitlab-pages -cf - .), 'gzip -c -1'], any_args)
- subject.dump('registry.tar.gz')
+ subject.dump('registry.tar.gz', 'backup_id')
end
it 'raises an error on failure' do
@@ -133,7 +133,7 @@ RSpec.describe Backup::Files do
expect(subject).to receive(:pipeline_succeeded?).and_return(false)
expect do
- subject.dump('registry.tar.gz')
+ subject.dump('registry.tar.gz', 'backup_id')
end.to raise_error(/Failed to create compressed file/)
end
@@ -149,7 +149,7 @@ RSpec.describe Backup::Files do
.with(%w(rsync -a --delete --exclude=lost+found --exclude=/gitlab-pages/@pages.tmp /var/gitlab-pages /var/gitlab-backup))
.and_return(['', 0])
- subject.dump('registry.tar.gz')
+ subject.dump('registry.tar.gz', 'backup_id')
end
it 'retries if rsync fails due to vanishing files' do
@@ -158,7 +158,7 @@ RSpec.describe Backup::Files do
.and_return(['rsync failed', 24], ['', 0])
expect do
- subject.dump('registry.tar.gz')
+ subject.dump('registry.tar.gz', 'backup_id')
end.to output(/files vanished during rsync, retrying/).to_stdout
end
@@ -168,7 +168,7 @@ RSpec.describe Backup::Files do
.and_return(['rsync failed', 1])
expect do
- subject.dump('registry.tar.gz')
+ subject.dump('registry.tar.gz', 'backup_id')
end.to output(/rsync failed/).to_stdout
.and raise_error(/Failed to create compressed file/)
end
@@ -176,7 +176,7 @@ RSpec.describe Backup::Files do
end
describe '#exclude_dirs' do
- subject { described_class.new(progress, 'pages', '/var/gitlab-pages', excludes: ['@pages.tmp']) }
+ subject { described_class.new(progress, '/var/gitlab-pages', excludes: ['@pages.tmp']) }
it 'prepends a leading dot slash to tar excludes' do
expect(subject.exclude_dirs(:tar)).to eq(['--exclude=lost+found', '--exclude=./@pages.tmp'])
@@ -188,7 +188,7 @@ RSpec.describe Backup::Files do
end
describe '#run_pipeline!' do
- subject { described_class.new(progress, 'registry', '/var/gitlab-registry') }
+ subject { described_class.new(progress, '/var/gitlab-registry') }
it 'executes an Open3.pipeline for cmd_list' do
expect(Open3).to receive(:pipeline).with(%w[whew command], %w[another cmd], any_args)
@@ -222,7 +222,7 @@ RSpec.describe Backup::Files do
end
describe '#pipeline_succeeded?' do
- subject { described_class.new(progress, 'registry', '/var/gitlab-registry') }
+ subject { described_class.new(progress, '/var/gitlab-registry') }
it 'returns true if both tar and gzip succeeeded' do
expect(
@@ -262,7 +262,7 @@ RSpec.describe Backup::Files do
end
describe '#tar_ignore_non_success?' do
- subject { described_class.new(progress, 'registry', '/var/gitlab-registry') }
+ subject { described_class.new(progress, '/var/gitlab-registry') }
context 'if `tar` command exits with 1 exitstatus' do
it 'returns true' do
@@ -310,7 +310,7 @@ RSpec.describe Backup::Files do
end
describe '#noncritical_warning?' do
- subject { described_class.new(progress, 'registry', '/var/gitlab-registry') }
+ subject { described_class.new(progress, '/var/gitlab-registry') }
it 'returns true if given text matches noncritical warnings list' do
expect(
diff --git a/spec/lib/backup/gitaly_backup_spec.rb b/spec/lib/backup/gitaly_backup_spec.rb
index f5295c2b04c..399e4ffa72b 100644
--- a/spec/lib/backup/gitaly_backup_spec.rb
+++ b/spec/lib/backup/gitaly_backup_spec.rb
@@ -25,11 +25,11 @@ RSpec.describe Backup::GitalyBackup do
progress.close
end
- subject { described_class.new(progress, max_parallelism: max_parallelism, storage_parallelism: storage_parallelism, backup_id: backup_id) }
+ subject { described_class.new(progress, max_parallelism: max_parallelism, storage_parallelism: storage_parallelism) }
context 'unknown' do
it 'fails to start unknown' do
- expect { subject.start(:unknown, destination) }.to raise_error(::Backup::Error, 'unknown backup type: unknown')
+ expect { subject.start(:unknown, destination, backup_id: backup_id) }.to raise_error(::Backup::Error, 'unknown backup type: unknown')
end
end
@@ -44,7 +44,7 @@ RSpec.describe Backup::GitalyBackup do
expect(Open3).to receive(:popen2).with(expected_env, anything, 'create', '-path', anything, '-layout', 'pointer', '-id', backup_id).and_call_original
- subject.start(:create, destination)
+ subject.start(:create, destination, backup_id: backup_id)
subject.enqueue(project, Gitlab::GlRepository::PROJECT)
subject.enqueue(project, Gitlab::GlRepository::WIKI)
subject.enqueue(project, Gitlab::GlRepository::DESIGN)
@@ -65,7 +65,7 @@ RSpec.describe Backup::GitalyBackup do
it 'passes parallel option through' do
expect(Open3).to receive(:popen2).with(expected_env, anything, 'create', '-path', anything, '-parallel', '3', '-layout', 'pointer', '-id', backup_id).and_call_original
- subject.start(:create, destination)
+ subject.start(:create, destination, backup_id: backup_id)
subject.finish!
end
end
@@ -76,7 +76,7 @@ RSpec.describe Backup::GitalyBackup do
it 'passes parallel option through' do
expect(Open3).to receive(:popen2).with(expected_env, anything, 'create', '-path', anything, '-parallel-storage', '3', '-layout', 'pointer', '-id', backup_id).and_call_original
- subject.start(:create, destination)
+ subject.start(:create, destination, backup_id: backup_id)
subject.finish!
end
end
@@ -84,10 +84,16 @@ RSpec.describe Backup::GitalyBackup do
it 'raises when the exit code not zero' do
expect(subject).to receive(:bin_path).and_return(Gitlab::Utils.which('false'))
- subject.start(:create, destination)
+ subject.start(:create, destination, backup_id: backup_id)
expect { subject.finish! }.to raise_error(::Backup::Error, 'gitaly-backup exit status 1')
end
+ it 'raises when gitaly_backup_path is not set' do
+ stub_backup_setting(gitaly_backup_path: nil)
+
+ expect { subject.start(:create, destination, backup_id: backup_id) }.to raise_error(::Backup::Error, 'gitaly-backup binary not found and gitaly_backup_path is not configured')
+ end
+
context 'feature flag incremental_repository_backup disabled' do
before do
stub_feature_flags(incremental_repository_backup: false)
@@ -102,7 +108,7 @@ RSpec.describe Backup::GitalyBackup do
expect(Open3).to receive(:popen2).with(expected_env, anything, 'create', '-path', anything).and_call_original
- subject.start(:create, destination)
+ subject.start(:create, destination, backup_id: backup_id)
subject.enqueue(project, Gitlab::GlRepository::PROJECT)
subject.enqueue(project, Gitlab::GlRepository::WIKI)
subject.enqueue(project, Gitlab::GlRepository::DESIGN)
@@ -146,7 +152,7 @@ RSpec.describe Backup::GitalyBackup do
it 'passes through SSL envs' do
expect(Open3).to receive(:popen2).with(ssl_env, anything, 'create', '-path', anything, '-layout', 'pointer', '-id', backup_id).and_call_original
- subject.start(:create, destination)
+ subject.start(:create, destination, backup_id: backup_id)
subject.finish!
end
end
@@ -171,7 +177,7 @@ RSpec.describe Backup::GitalyBackup do
expect(Open3).to receive(:popen2).with(expected_env, anything, 'restore', '-path', anything, '-layout', 'pointer').and_call_original
- subject.start(:restore, destination)
+ subject.start(:restore, destination, backup_id: backup_id)
subject.enqueue(project, Gitlab::GlRepository::PROJECT)
subject.enqueue(project, Gitlab::GlRepository::WIKI)
subject.enqueue(project, Gitlab::GlRepository::DESIGN)
@@ -194,7 +200,7 @@ RSpec.describe Backup::GitalyBackup do
it 'passes parallel option through' do
expect(Open3).to receive(:popen2).with(expected_env, anything, 'restore', '-path', anything, '-parallel', '3', '-layout', 'pointer').and_call_original
- subject.start(:restore, destination)
+ subject.start(:restore, destination, backup_id: backup_id)
subject.finish!
end
end
@@ -205,7 +211,7 @@ RSpec.describe Backup::GitalyBackup do
it 'passes parallel option through' do
expect(Open3).to receive(:popen2).with(expected_env, anything, 'restore', '-path', anything, '-parallel-storage', '3', '-layout', 'pointer').and_call_original
- subject.start(:restore, destination)
+ subject.start(:restore, destination, backup_id: backup_id)
subject.finish!
end
end
@@ -224,7 +230,7 @@ RSpec.describe Backup::GitalyBackup do
expect(Open3).to receive(:popen2).with(expected_env, anything, 'restore', '-path', anything).and_call_original
- subject.start(:restore, destination)
+ subject.start(:restore, destination, backup_id: backup_id)
subject.enqueue(project, Gitlab::GlRepository::PROJECT)
subject.enqueue(project, Gitlab::GlRepository::WIKI)
subject.enqueue(project, Gitlab::GlRepository::DESIGN)
@@ -245,8 +251,14 @@ RSpec.describe Backup::GitalyBackup do
it 'raises when the exit code not zero' do
expect(subject).to receive(:bin_path).and_return(Gitlab::Utils.which('false'))
- subject.start(:restore, destination)
+ subject.start(:restore, destination, backup_id: backup_id)
expect { subject.finish! }.to raise_error(::Backup::Error, 'gitaly-backup exit status 1')
end
+
+ it 'raises when gitaly_backup_path is not set' do
+ stub_backup_setting(gitaly_backup_path: nil)
+
+ expect { subject.start(:restore, destination, backup_id: backup_id) }.to raise_error(::Backup::Error, 'gitaly-backup binary not found and gitaly_backup_path is not configured')
+ end
end
end
diff --git a/spec/lib/backup/gitaly_rpc_backup_spec.rb b/spec/lib/backup/gitaly_rpc_backup_spec.rb
deleted file mode 100644
index 6cba8c5c9b1..00000000000
--- a/spec/lib/backup/gitaly_rpc_backup_spec.rb
+++ /dev/null
@@ -1,154 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Backup::GitalyRpcBackup do
- let(:progress) { spy(:stdout) }
- let(:destination) { File.join(Gitlab.config.backup.path, 'repositories') }
-
- subject { described_class.new(progress) }
-
- after do
- # make sure we do not leave behind any backup files
- FileUtils.rm_rf(File.join(Gitlab.config.backup.path, 'repositories'))
- end
-
- context 'unknown' do
- it 'fails to start unknown' do
- expect { subject.start(:unknown, destination) }.to raise_error(::Backup::Error, 'unknown backup type: unknown')
- end
- end
-
- context 'create' do
- RSpec.shared_examples 'creates a repository backup' do
- it 'creates repository bundles', :aggregate_failures do
- # Add data to the wiki, design repositories, and snippets, so they will be included in the dump.
- create(:wiki_page, container: project)
- create(:design, :with_file, issue: create(:issue, project: project))
- project_snippet = create(:project_snippet, :repository, project: project)
- personal_snippet = create(:personal_snippet, :repository, author: project.first_owner)
-
- subject.start(:create, destination)
- subject.enqueue(project, Gitlab::GlRepository::PROJECT)
- subject.enqueue(project, Gitlab::GlRepository::WIKI)
- subject.enqueue(project, Gitlab::GlRepository::DESIGN)
- subject.enqueue(personal_snippet, Gitlab::GlRepository::SNIPPET)
- subject.enqueue(project_snippet, Gitlab::GlRepository::SNIPPET)
- subject.finish!
-
- expect(File).to exist(File.join(destination, project.disk_path + '.bundle'))
- expect(File).to exist(File.join(destination, project.disk_path + '.wiki.bundle'))
- expect(File).to exist(File.join(destination, project.disk_path + '.design.bundle'))
- expect(File).to exist(File.join(destination, personal_snippet.disk_path + '.bundle'))
- expect(File).to exist(File.join(destination, project_snippet.disk_path + '.bundle'))
- end
-
- context 'failure' do
- before do
- allow_next_instance_of(Repository) do |repository|
- allow(repository).to receive(:bundle_to_disk) { raise 'Fail in tests' }
- end
- end
-
- it 'logs an appropriate message', :aggregate_failures do
- subject.start(:create, destination)
- subject.enqueue(project, Gitlab::GlRepository::PROJECT)
- subject.finish!
-
- expect(progress).to have_received(:puts).with("[Failed] backing up #{project.full_path} (#{project.disk_path})")
- expect(progress).to have_received(:puts).with("Error Fail in tests")
- end
- end
- end
-
- context 'hashed storage' do
- let_it_be(:project) { create(:project, :repository) }
-
- it_behaves_like 'creates a repository backup'
- end
-
- context 'legacy storage' do
- let_it_be(:project) { create(:project, :repository, :legacy_storage) }
-
- it_behaves_like 'creates a repository backup'
- end
- end
-
- context 'restore' do
- let_it_be(:project) { create(:project, :repository) }
- let_it_be(:personal_snippet) { create(:personal_snippet, author: project.first_owner) }
- let_it_be(:project_snippet) { create(:project_snippet, project: project, author: project.first_owner) }
-
- def copy_bundle_to_backup_path(bundle_name, destination)
- FileUtils.mkdir_p(File.join(Gitlab.config.backup.path, 'repositories', File.dirname(destination)))
- FileUtils.cp(Rails.root.join('spec/fixtures/lib/backup', bundle_name), File.join(Gitlab.config.backup.path, 'repositories', destination))
- end
-
- it 'restores from repository bundles', :aggregate_failures do
- copy_bundle_to_backup_path('project_repo.bundle', project.disk_path + '.bundle')
- copy_bundle_to_backup_path('wiki_repo.bundle', project.disk_path + '.wiki.bundle')
- copy_bundle_to_backup_path('design_repo.bundle', project.disk_path + '.design.bundle')
- copy_bundle_to_backup_path('personal_snippet_repo.bundle', personal_snippet.disk_path + '.bundle')
- copy_bundle_to_backup_path('project_snippet_repo.bundle', project_snippet.disk_path + '.bundle')
-
- subject.start(:restore, destination)
- subject.enqueue(project, Gitlab::GlRepository::PROJECT)
- subject.enqueue(project, Gitlab::GlRepository::WIKI)
- subject.enqueue(project, Gitlab::GlRepository::DESIGN)
- subject.enqueue(personal_snippet, Gitlab::GlRepository::SNIPPET)
- subject.enqueue(project_snippet, Gitlab::GlRepository::SNIPPET)
- subject.finish!
-
- collect_commit_shas = -> (repo) { repo.commits('master', limit: 10).map(&:sha) }
-
- expect(collect_commit_shas.call(project.repository)).to eq(['393a7d860a5a4c3cc736d7eb00604e3472bb95ec'])
- expect(collect_commit_shas.call(project.wiki.repository)).to eq(['c74b9948d0088d703ee1fafeddd9ed9add2901ea'])
- expect(collect_commit_shas.call(project.design_repository)).to eq(['c3cd4d7bd73a51a0f22045c3a4c871c435dc959d'])
- expect(collect_commit_shas.call(personal_snippet.repository)).to eq(['3b3c067a3bc1d1b695b51e2be30c0f8cf698a06e'])
- expect(collect_commit_shas.call(project_snippet.repository)).to eq(['6e44ba56a4748be361a841e759c20e421a1651a1'])
- end
-
- it 'cleans existing repositories', :aggregate_failures do
- expect_next_instance_of(DesignManagement::Repository) do |repository|
- expect(repository).to receive(:remove)
- end
-
- # 4 times = project repo + wiki repo + project_snippet repo + personal_snippet repo
- expect(Repository).to receive(:new).exactly(4).times.and_wrap_original do |method, *original_args|
- full_path, container, kwargs = original_args
-
- repository = method.call(full_path, container, **kwargs)
-
- expect(repository).to receive(:remove)
-
- repository
- end
-
- subject.start(:restore, destination)
- subject.enqueue(project, Gitlab::GlRepository::PROJECT)
- subject.enqueue(project, Gitlab::GlRepository::WIKI)
- subject.enqueue(project, Gitlab::GlRepository::DESIGN)
- subject.enqueue(personal_snippet, Gitlab::GlRepository::SNIPPET)
- subject.enqueue(project_snippet, Gitlab::GlRepository::SNIPPET)
- subject.finish!
- end
-
- context 'failure' do
- before do
- allow_next_instance_of(Repository) do |repository|
- allow(repository).to receive(:create_repository) { raise 'Fail in tests' }
- allow(repository).to receive(:create_from_bundle) { raise 'Fail in tests' }
- end
- end
-
- it 'logs an appropriate message', :aggregate_failures do
- subject.start(:restore, destination)
- subject.enqueue(project, Gitlab::GlRepository::PROJECT)
- subject.finish!
-
- expect(progress).to have_received(:puts).with("[Failed] restoring #{project.full_path} (#{project.disk_path})")
- expect(progress).to have_received(:puts).with("Error Fail in tests")
- end
- end
- end
-end
diff --git a/spec/lib/backup/lfs_spec.rb b/spec/lib/backup/lfs_spec.rb
deleted file mode 100644
index a27f60f20d0..00000000000
--- a/spec/lib/backup/lfs_spec.rb
+++ /dev/null
@@ -1,26 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Backup::Lfs do
- let(:progress) { StringIO.new }
-
- subject(:backup) { described_class.new(progress) }
-
- describe '#dump' do
- before do
- allow(File).to receive(:realpath).and_call_original
- allow(File).to receive(:realpath).with('/var/lfs-objects').and_return('/var/lfs-objects')
- allow(File).to receive(:realpath).with('/var/lfs-objects/..').and_return('/var')
- allow(Settings.lfs).to receive(:storage_path).and_return('/var/lfs-objects')
- end
-
- it 'uses the correct lfs dir in tar command', :aggregate_failures do
- expect(backup).to receive(:tar).and_return('blabla-tar')
- expect(backup).to receive(:run_pipeline!).with([%w(blabla-tar --exclude=lost+found -C /var/lfs-objects -cf - .), 'gzip -c -1'], any_args).and_return([[true, true], ''])
- expect(backup).to receive(:pipeline_succeeded?).and_return(true)
-
- backup.dump('lfs.tar.gz')
- end
- end
-end
diff --git a/spec/lib/backup/manager_spec.rb b/spec/lib/backup/manager_spec.rb
index 9cf78a11bc7..192739d05a7 100644
--- a/spec/lib/backup/manager_spec.rb
+++ b/spec/lib/backup/manager_spec.rb
@@ -22,13 +22,13 @@ RSpec.describe Backup::Manager do
describe '#run_create_task' do
let(:enabled) { true }
- let(:task) { instance_double(Backup::Task, human_name: 'my task', enabled: enabled) }
- let(:definitions) { { 'my_task' => Backup::Manager::TaskDefinition.new(task: task, destination_path: 'my_task.tar.gz') } }
+ let(:task) { instance_double(Backup::Task) }
+ let(:definitions) { { 'my_task' => Backup::Manager::TaskDefinition.new(task: task, enabled: enabled, destination_path: 'my_task.tar.gz', human_name: 'my task') } }
it 'calls the named task' do
expect(task).to receive(:dump)
expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Dumping my task ... ')
- expect(Gitlab::BackupLogger).to receive(:info).with(message: 'done')
+ expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Dumping my task ... done')
subject.run_create_task('my_task')
end
@@ -37,8 +37,7 @@ RSpec.describe Backup::Manager do
let(:enabled) { false }
it 'informs the user' do
- expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Dumping my task ... ')
- expect(Gitlab::BackupLogger).to receive(:info).with(message: '[DISABLED]')
+ expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Dumping my task ... [DISABLED]')
subject.run_create_task('my_task')
end
@@ -48,8 +47,7 @@ RSpec.describe Backup::Manager do
it 'informs the user' do
stub_env('SKIP', 'my_task')
- expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Dumping my task ... ')
- expect(Gitlab::BackupLogger).to receive(:info).with(message: '[SKIPPED]')
+ expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Dumping my task ... [SKIPPED]')
subject.run_create_task('my_task')
end
@@ -60,12 +58,10 @@ RSpec.describe Backup::Manager do
let(:enabled) { true }
let(:pre_restore_warning) { nil }
let(:post_restore_warning) { nil }
- let(:definitions) { { 'my_task' => Backup::Manager::TaskDefinition.new(task: task, destination_path: 'my_task.tar.gz') } }
+ let(:definitions) { { 'my_task' => Backup::Manager::TaskDefinition.new(task: task, enabled: enabled, human_name: 'my task', destination_path: 'my_task.tar.gz') } }
let(:backup_information) { {} }
let(:task) do
instance_double(Backup::Task,
- human_name: 'my task',
- enabled: enabled,
pre_restore_warning: pre_restore_warning,
post_restore_warning: post_restore_warning)
end
@@ -78,7 +74,7 @@ RSpec.describe Backup::Manager do
it 'calls the named task' do
expect(task).to receive(:restore)
expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Restoring my task ... ').ordered
- expect(Gitlab::BackupLogger).to receive(:info).with(message: 'done').ordered
+ expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Restoring my task ... done').ordered
subject.run_restore_task('my_task')
end
@@ -87,8 +83,7 @@ RSpec.describe Backup::Manager do
let(:enabled) { false }
it 'informs the user' do
- expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Restoring my task ... ').ordered
- expect(Gitlab::BackupLogger).to receive(:info).with(message: '[DISABLED]').ordered
+ expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Restoring my task ... [DISABLED]').ordered
subject.run_restore_task('my_task')
end
@@ -100,7 +95,7 @@ RSpec.describe Backup::Manager do
it 'displays and waits for the user' do
expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Restoring my task ... ').ordered
expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Watch out!').ordered
- expect(Gitlab::BackupLogger).to receive(:info).with(message: 'done').ordered
+ expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Restoring my task ... done').ordered
expect(Gitlab::TaskHelpers).to receive(:ask_to_continue)
expect(task).to receive(:restore)
@@ -124,7 +119,7 @@ RSpec.describe Backup::Manager do
it 'displays and waits for the user' do
expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Restoring my task ... ').ordered
- expect(Gitlab::BackupLogger).to receive(:info).with(message: 'done').ordered
+ expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Restoring my task ... done').ordered
expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Watch out!').ordered
expect(Gitlab::TaskHelpers).to receive(:ask_to_continue)
expect(task).to receive(:restore)
@@ -134,7 +129,7 @@ RSpec.describe Backup::Manager do
it 'does not continue when the user quits' do
expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Restoring my task ... ').ordered
- expect(Gitlab::BackupLogger).to receive(:info).with(message: 'done').ordered
+ expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Restoring my task ... done').ordered
expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Watch out!').ordered
expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Quitting...').ordered
expect(task).to receive(:restore)
@@ -148,8 +143,10 @@ RSpec.describe Backup::Manager do
end
describe '#create' do
+ let(:incremental_env) { 'false' }
let(:expected_backup_contents) { %w{backup_information.yml task1.tar.gz task2.tar.gz} }
- let(:tar_file) { '1546300800_2019_01_01_12.3_gitlab_backup.tar' }
+ let(:backup_id) { '1546300800_2019_01_01_12.3' }
+ let(:tar_file) { "#{backup_id}_gitlab_backup.tar" }
let(:tar_system_options) { { out: [tar_file, 'w', Gitlab.config.backup.archive_permissions] } }
let(:tar_cmdline) { ['tar', '-cf', '-', *expected_backup_contents, tar_system_options] }
let(:backup_information) do
@@ -159,24 +156,27 @@ RSpec.describe Backup::Manager do
}
end
- let(:task1) { instance_double(Backup::Task, human_name: 'task 1', enabled: true) }
- let(:task2) { instance_double(Backup::Task, human_name: 'task 2', enabled: true) }
+ let(:task1) { instance_double(Backup::Task) }
+ let(:task2) { instance_double(Backup::Task) }
let(:definitions) do
{
- 'task1' => Backup::Manager::TaskDefinition.new(task: task1, destination_path: 'task1.tar.gz'),
- 'task2' => Backup::Manager::TaskDefinition.new(task: task2, destination_path: 'task2.tar.gz')
+ 'task1' => Backup::Manager::TaskDefinition.new(task: task1, human_name: 'task 1', destination_path: 'task1.tar.gz'),
+ 'task2' => Backup::Manager::TaskDefinition.new(task: task2, human_name: 'task 2', destination_path: 'task2.tar.gz')
}
end
before do
+ stub_env('INCREMENTAL', incremental_env)
allow(ActiveRecord::Base.connection).to receive(:reconnect!)
+ allow(Gitlab::BackupLogger).to receive(:info)
allow(Kernel).to receive(:system).and_return(true)
+ allow(YAML).to receive(:load_file).and_call_original
allow(YAML).to receive(:load_file).with(File.join(Gitlab.config.backup.path, 'backup_information.yml'))
.and_return(backup_information)
allow(subject).to receive(:backup_information).and_return(backup_information)
- allow(task1).to receive(:dump).with(File.join(Gitlab.config.backup.path, 'task1.tar.gz'))
- allow(task2).to receive(:dump).with(File.join(Gitlab.config.backup.path, 'task2.tar.gz'))
+ allow(task1).to receive(:dump).with(File.join(Gitlab.config.backup.path, 'task1.tar.gz'), backup_id)
+ allow(task2).to receive(:dump).with(File.join(Gitlab.config.backup.path, 'task2.tar.gz'), backup_id)
end
it 'executes tar' do
@@ -185,8 +185,22 @@ RSpec.describe Backup::Manager do
expect(Kernel).to have_received(:system).with(*tar_cmdline)
end
+ context 'tar fails' do
+ before do
+ expect(Kernel).to receive(:system).with(*tar_cmdline).and_return(false)
+ end
+
+ it 'logs a failure' do
+ expect do
+ subject.create # rubocop:disable Rails/SaveBang
+ end.to raise_error(Backup::Error, 'Backup failed')
+
+ expect(Gitlab::BackupLogger).to have_received(:info).with(message: "Creating archive #{tar_file} failed")
+ end
+ end
+
context 'when BACKUP is set' do
- let(:tar_file) { 'custom_gitlab_backup.tar' }
+ let(:backup_id) { 'custom' }
it 'uses the given value as tar file name' do
stub_env('BACKUP', '/ignored/path/custom')
@@ -213,6 +227,20 @@ RSpec.describe Backup::Manager do
end
end
+ context 'when SKIP env is set' do
+ let(:expected_backup_contents) { %w{backup_information.yml task1.tar.gz} }
+
+ before do
+ stub_env('SKIP', 'task2')
+ end
+
+ it 'executes tar' do
+ subject.create # rubocop:disable Rails/SaveBang
+
+ expect(Kernel).to have_received(:system).with(*tar_cmdline)
+ end
+ end
+
context 'when the destination is optional' do
let(:expected_backup_contents) { %w{backup_information.yml task1.tar.gz} }
let(:definitions) do
@@ -248,6 +276,7 @@ RSpec.describe Backup::Manager do
end
before do
+ allow(Gitlab::BackupLogger).to receive(:info)
allow(Dir).to receive(:chdir).and_yield
allow(Dir).to receive(:glob).and_return(files)
allow(FileUtils).to receive(:rm)
@@ -266,7 +295,7 @@ RSpec.describe Backup::Manager do
end
it 'prints a skipped message' do
- expect(progress).to have_received(:puts).with('skipping')
+ expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Deleting old backups ... [SKIPPED]')
end
end
@@ -290,7 +319,7 @@ RSpec.describe Backup::Manager do
end
it 'prints a done message' do
- expect(progress).to have_received(:puts).with('done. (0 removed)')
+ expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Deleting old backups ... done. (0 removed)')
end
end
@@ -307,7 +336,7 @@ RSpec.describe Backup::Manager do
end
it 'prints a done message' do
- expect(progress).to have_received(:puts).with('done. (0 removed)')
+ expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Deleting old backups ... done. (0 removed)')
end
end
@@ -348,7 +377,7 @@ RSpec.describe Backup::Manager do
end
it 'prints a done message' do
- expect(progress).to have_received(:puts).with('done. (8 removed)')
+ expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Deleting old backups ... done. (8 removed)')
end
end
@@ -372,11 +401,11 @@ RSpec.describe Backup::Manager do
end
it 'sets the correct removed count' do
- expect(progress).to have_received(:puts).with('done. (7 removed)')
+ expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Deleting old backups ... done. (7 removed)')
end
it 'prints the error from file that could not be removed' do
- expect(progress).to have_received(:puts).with(a_string_matching(message))
+ expect(Gitlab::BackupLogger).to have_received(:info).with(message: a_string_matching(message))
end
end
end
@@ -386,6 +415,7 @@ RSpec.describe Backup::Manager do
let(:backup_filename) { File.basename(backup_file.path) }
before do
+ allow(Gitlab::BackupLogger).to receive(:info)
allow(subject).to receive(:tar_file).and_return(backup_filename)
stub_backup_setting(
@@ -410,6 +440,23 @@ RSpec.describe Backup::Manager do
connection.directories.create(key: Gitlab.config.backup.upload.remote_directory) # rubocop:disable Rails/SaveBang
end
+ context 'skipped upload' do
+ let(:backup_information) do
+ {
+ backup_created_at: Time.zone.parse('2019-01-01'),
+ gitlab_version: '12.3',
+ skipped: ['remote']
+ }
+ end
+
+ it 'informs the user' do
+ stub_env('SKIP', 'remote')
+ subject.create # rubocop:disable Rails/SaveBang
+
+ expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Uploading backup archive to remote storage directory ... [SKIPPED]')
+ end
+ end
+
context 'target path' do
it 'uses the tar filename by default' do
expect_any_instance_of(Fog::Collection).to receive(:create)
@@ -462,7 +509,7 @@ RSpec.describe Backup::Manager do
it 'sets encryption attributes' do
subject.create # rubocop:disable Rails/SaveBang
- expect(progress).to have_received(:puts).with("done (encrypted with AES256)")
+ expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Uploading backup archive to remote storage directory ... done (encrypted with AES256)')
end
end
@@ -473,7 +520,7 @@ RSpec.describe Backup::Manager do
it 'sets encryption attributes' do
subject.create # rubocop:disable Rails/SaveBang
- expect(progress).to have_received(:puts).with("done (encrypted with AES256)")
+ expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Uploading backup archive to remote storage directory ... done (encrypted with AES256)')
end
end
@@ -488,7 +535,7 @@ RSpec.describe Backup::Manager do
it 'sets encryption attributes' do
subject.create # rubocop:disable Rails/SaveBang
- expect(progress).to have_received(:puts).with("done (encrypted with aws:kms)")
+ expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Uploading backup archive to remote storage directory ... done (encrypted with aws:kms)')
end
end
end
@@ -546,15 +593,169 @@ RSpec.describe Backup::Manager do
end
end
end
+
+ context 'incremental' do
+ let(:incremental_env) { 'true' }
+ let(:gitlab_version) { Gitlab::VERSION }
+ let(:backup_id) { "1546300800_2019_01_01_#{gitlab_version}" }
+ let(:tar_file) { "#{backup_id}_gitlab_backup.tar" }
+ let(:backup_information) do
+ {
+ backup_created_at: Time.zone.parse('2019-01-01'),
+ gitlab_version: gitlab_version
+ }
+ end
+
+ context 'when there are no backup files in the directory' do
+ before do
+ allow(Dir).to receive(:glob).and_return([])
+ end
+
+ it 'fails the operation and prints an error' do
+ expect { subject.create }.to raise_error SystemExit # rubocop:disable Rails/SaveBang
+ expect(progress).to have_received(:puts)
+ .with(a_string_matching('No backups found'))
+ end
+ end
+
+ context 'when there are two backup files in the directory and BACKUP variable is not set' do
+ before do
+ allow(Dir).to receive(:glob).and_return(
+ [
+ '1451606400_2016_01_01_1.2.3_gitlab_backup.tar',
+ '1451520000_2015_12_31_gitlab_backup.tar'
+ ]
+ )
+ end
+
+ it 'prints the list of available backups' do
+ expect { subject.create }.to raise_error SystemExit # rubocop:disable Rails/SaveBang
+ expect(progress).to have_received(:puts)
+ .with(a_string_matching('1451606400_2016_01_01_1.2.3\n 1451520000_2015_12_31'))
+ end
+
+ it 'fails the operation and prints an error' do
+ expect { subject.create }.to raise_error SystemExit # rubocop:disable Rails/SaveBang
+ expect(progress).to have_received(:puts)
+ .with(a_string_matching('Found more than one backup'))
+ end
+ end
+
+ context 'when BACKUP variable is set to a non-existing file' do
+ before do
+ allow(Dir).to receive(:glob).and_return(
+ [
+ '1451606400_2016_01_01_gitlab_backup.tar'
+ ]
+ )
+ allow(File).to receive(:exist?).and_return(false)
+
+ stub_env('BACKUP', 'wrong')
+ end
+
+ it 'fails the operation and prints an error' do
+ expect { subject.create }.to raise_error SystemExit # rubocop:disable Rails/SaveBang
+ expect(File).to have_received(:exist?).with('wrong_gitlab_backup.tar')
+ expect(progress).to have_received(:puts)
+ .with(a_string_matching('The backup file wrong_gitlab_backup.tar does not exist'))
+ end
+ end
+
+ context 'when BACKUP variable is set to a correct file' do
+ let(:backup_id) { '1451606400_2016_01_01_1.2.3' }
+ let(:tar_cmdline) { %w{tar -xf 1451606400_2016_01_01_1.2.3_gitlab_backup.tar} }
+
+ before do
+ allow(Gitlab::BackupLogger).to receive(:info)
+ allow(Dir).to receive(:glob).and_return(
+ [
+ '1451606400_2016_01_01_1.2.3_gitlab_backup.tar'
+ ]
+ )
+ allow(File).to receive(:exist?).and_return(true)
+ allow(Kernel).to receive(:system).and_return(true)
+
+ stub_env('BACKUP', '/ignored/path/1451606400_2016_01_01_1.2.3')
+ end
+
+ it 'unpacks the file' do
+ subject.create # rubocop:disable Rails/SaveBang
+
+ expect(Kernel).to have_received(:system).with(*tar_cmdline)
+ end
+
+ context 'tar fails' do
+ before do
+ expect(Kernel).to receive(:system).with(*tar_cmdline).and_return(false)
+ end
+
+ it 'logs a failure' do
+ expect do
+ subject.create # rubocop:disable Rails/SaveBang
+ end.to raise_error(SystemExit)
+
+ expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Unpacking backup failed')
+ end
+ end
+
+ context 'on version mismatch' do
+ let(:backup_information) do
+ {
+ backup_created_at: Time.zone.parse('2019-01-01'),
+ gitlab_version: "not #{gitlab_version}"
+ }
+ end
+
+ it 'stops the process' do
+ expect { subject.create }.to raise_error SystemExit # rubocop:disable Rails/SaveBang
+ expect(progress).to have_received(:puts)
+ .with(a_string_matching('GitLab version mismatch'))
+ end
+ end
+ end
+
+ context 'when there is a non-tarred backup in the directory' do
+ before do
+ allow(Dir).to receive(:glob).and_return(
+ [
+ 'backup_information.yml'
+ ]
+ )
+ allow(File).to receive(:exist?).and_return(true)
+ end
+
+ it 'selects the non-tarred backup to restore from' do
+ subject.create # rubocop:disable Rails/SaveBang
+
+ expect(progress).to have_received(:puts)
+ .with(a_string_matching('Non tarred backup found '))
+ end
+
+ context 'on version mismatch' do
+ let(:backup_information) do
+ {
+ backup_created_at: Time.zone.parse('2019-01-01'),
+ gitlab_version: "not #{gitlab_version}"
+ }
+ end
+
+ it 'stops the process' do
+ expect { subject.create }.to raise_error SystemExit # rubocop:disable Rails/SaveBang
+ expect(progress).to have_received(:puts)
+ .with(a_string_matching('GitLab version mismatch'))
+ end
+ end
+ end
+ end
end
describe '#restore' do
- let(:task1) { instance_double(Backup::Task, human_name: 'task 1', enabled: true, pre_restore_warning: nil, post_restore_warning: nil) }
- let(:task2) { instance_double(Backup::Task, human_name: 'task 2', enabled: true, pre_restore_warning: nil, post_restore_warning: nil) }
+ let(:task1) { instance_double(Backup::Task, pre_restore_warning: nil, post_restore_warning: nil) }
+ let(:task2) { instance_double(Backup::Task, pre_restore_warning: nil, post_restore_warning: nil) }
let(:definitions) do
{
- 'task1' => Backup::Manager::TaskDefinition.new(task: task1, destination_path: 'task1.tar.gz'),
- 'task2' => Backup::Manager::TaskDefinition.new(task: task2, destination_path: 'task2.tar.gz')
+ 'task1' => Backup::Manager::TaskDefinition.new(task: task1, human_name: 'task 1', destination_path: 'task1.tar.gz'),
+ 'task2' => Backup::Manager::TaskDefinition.new(task: task2, human_name: 'task 2', destination_path: 'task2.tar.gz')
}
end
@@ -570,6 +771,7 @@ RSpec.describe Backup::Manager do
Rake.application.rake_require 'tasks/gitlab/shell'
Rake.application.rake_require 'tasks/cache'
+ allow(Gitlab::BackupLogger).to receive(:info)
allow(task1).to receive(:restore).with(File.join(Gitlab.config.backup.path, 'task1.tar.gz'))
allow(task2).to receive(:restore).with(File.join(Gitlab.config.backup.path, 'task2.tar.gz'))
allow(YAML).to receive(:load_file).with(File.join(Gitlab.config.backup.path, 'backup_information.yml'))
@@ -634,7 +836,10 @@ RSpec.describe Backup::Manager do
end
context 'when BACKUP variable is set to a correct file' do
+ let(:tar_cmdline) { %w{tar -xf 1451606400_2016_01_01_1.2.3_gitlab_backup.tar} }
+
before do
+ allow(Gitlab::BackupLogger).to receive(:info)
allow(Dir).to receive(:glob).and_return(
[
'1451606400_2016_01_01_1.2.3_gitlab_backup.tar'
@@ -649,8 +854,21 @@ RSpec.describe Backup::Manager do
it 'unpacks the file' do
subject.restore
- expect(Kernel).to have_received(:system)
- .with("tar", "-xf", "1451606400_2016_01_01_1.2.3_gitlab_backup.tar")
+ expect(Kernel).to have_received(:system).with(*tar_cmdline)
+ end
+
+ context 'tar fails' do
+ before do
+ expect(Kernel).to receive(:system).with(*tar_cmdline).and_return(false)
+ end
+
+ it 'logs a failure' do
+ expect do
+ subject.restore
+ end.to raise_error(SystemExit)
+
+ expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Unpacking backup failed')
+ end
end
context 'on version mismatch' do
@@ -680,7 +898,7 @@ RSpec.describe Backup::Manager do
subject.restore
- expect(progress).to have_received(:print).with('Deleting backups/tmp ... ')
+ expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Deleting backups/tmp ... ')
end
end
end
@@ -731,7 +949,7 @@ RSpec.describe Backup::Manager do
subject.restore
- expect(progress).to have_received(:print).with('Deleting backups/tmp ... ')
+ expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Deleting backups/tmp ... ')
end
end
end
diff --git a/spec/lib/backup/object_backup_spec.rb b/spec/lib/backup/object_backup_spec.rb
deleted file mode 100644
index 85658173b0e..00000000000
--- a/spec/lib/backup/object_backup_spec.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.shared_examples 'backup object' do |setting|
- let(:progress) { StringIO.new }
- let(:backup_path) { "/var/#{setting}" }
-
- subject(:backup) { described_class.new(progress) }
-
- describe '#dump' do
- before do
- allow(File).to receive(:realpath).and_call_original
- allow(File).to receive(:realpath).with(backup_path).and_return(backup_path)
- allow(File).to receive(:realpath).with("#{backup_path}/..").and_return('/var')
- allow(Settings.send(setting)).to receive(:storage_path).and_return(backup_path)
- end
-
- it 'uses the correct storage dir in tar command and excludes tmp', :aggregate_failures do
- expect(backup).to receive(:tar).and_return('blabla-tar')
- expect(backup).to receive(:run_pipeline!).with([%W(blabla-tar --exclude=lost+found --exclude=./tmp -C #{backup_path} -cf - .), 'gzip -c -1'], any_args).and_return([[true, true], ''])
- expect(backup).to receive(:pipeline_succeeded?).and_return(true)
-
- backup.dump('backup_object.tar.gz')
- end
- end
-end
-
-RSpec.describe Backup::Packages do
- it_behaves_like 'backup object', 'packages'
-end
-
-RSpec.describe Backup::TerraformState do
- it_behaves_like 'backup object', 'terraform_state'
-end
diff --git a/spec/lib/backup/pages_spec.rb b/spec/lib/backup/pages_spec.rb
deleted file mode 100644
index 095dda61cf4..00000000000
--- a/spec/lib/backup/pages_spec.rb
+++ /dev/null
@@ -1,25 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Backup::Pages do
- let(:progress) { StringIO.new }
-
- subject { described_class.new(progress) }
-
- before do
- allow(File).to receive(:realpath).with("/var/gitlab-pages").and_return("/var/gitlab-pages")
- allow(File).to receive(:realpath).with("/var/gitlab-pages/..").and_return("/var")
- end
-
- describe '#dump' do
- it 'excludes tmp from backup tar' do
- allow(Gitlab.config.pages).to receive(:path) { '/var/gitlab-pages' }
-
- expect(subject).to receive(:tar).and_return('blabla-tar')
- expect(subject).to receive(:run_pipeline!).with([%w(blabla-tar --exclude=lost+found --exclude=./@pages.tmp -C /var/gitlab-pages -cf - .), 'gzip -c -1'], any_args).and_return([[true, true], ''])
- expect(subject).to receive(:pipeline_succeeded?).and_return(true)
- subject.dump('pages.tar.gz')
- end
- end
-end
diff --git a/spec/lib/backup/repositories_spec.rb b/spec/lib/backup/repositories_spec.rb
index db3e507596f..c6f611e727c 100644
--- a/spec/lib/backup/repositories_spec.rb
+++ b/spec/lib/backup/repositories_spec.rb
@@ -4,18 +4,14 @@ require 'spec_helper'
RSpec.describe Backup::Repositories do
let(:progress) { spy(:stdout) }
- let(:parallel_enqueue) { true }
- let(:strategy) { spy(:strategy, parallel_enqueue?: parallel_enqueue) }
- let(:max_concurrency) { 1 }
- let(:max_storage_concurrency) { 1 }
+ let(:strategy) { spy(:strategy) }
let(:destination) { 'repositories' }
+ let(:backup_id) { 'backup_id' }
subject do
described_class.new(
progress,
- strategy: strategy,
- max_concurrency: max_concurrency,
- max_storage_concurrency: max_storage_concurrency
+ strategy: strategy
)
end
@@ -27,9 +23,9 @@ RSpec.describe Backup::Repositories do
project_snippet = create(:project_snippet, :repository, project: project)
personal_snippet = create(:personal_snippet, :repository, author: project.first_owner)
- subject.dump(destination)
+ subject.dump(destination, backup_id)
- expect(strategy).to have_received(:start).with(:create, destination)
+ expect(strategy).to have_received(:start).with(:create, destination, backup_id: backup_id)
expect(strategy).to have_received(:enqueue).with(project, Gitlab::GlRepository::PROJECT)
expect(strategy).to have_received(:enqueue).with(project, Gitlab::GlRepository::WIKI)
expect(strategy).to have_received(:enqueue).with(project, Gitlab::GlRepository::DESIGN)
@@ -51,139 +47,30 @@ RSpec.describe Backup::Repositories do
it_behaves_like 'creates repository bundles'
end
- context 'no concurrency' do
- it 'creates the expected number of threads' do
- expect(Thread).not_to receive(:new)
+ describe 'command failure' do
+ it 'enqueue_project raises an error' do
+ allow(strategy).to receive(:enqueue).with(anything, Gitlab::GlRepository::PROJECT).and_raise(IOError)
- expect(strategy).to receive(:start).with(:create, destination)
- projects.each do |project|
- expect(strategy).to receive(:enqueue).with(project, Gitlab::GlRepository::PROJECT)
- end
- expect(strategy).to receive(:finish!)
-
- subject.dump(destination)
- end
-
- describe 'command failure' do
- it 'enqueue_project raises an error' do
- allow(strategy).to receive(:enqueue).with(anything, Gitlab::GlRepository::PROJECT).and_raise(IOError)
-
- expect { subject.dump(destination) }.to raise_error(IOError)
- end
-
- it 'project query raises an error' do
- allow(Project).to receive_message_chain(:includes, :find_each).and_raise(ActiveRecord::StatementTimeout)
-
- expect { subject.dump(destination) }.to raise_error(ActiveRecord::StatementTimeout)
- end
+ expect { subject.dump(destination, backup_id) }.to raise_error(IOError)
end
- it 'avoids N+1 database queries' do
- control_count = ActiveRecord::QueryRecorder.new do
- subject.dump(destination)
- end.count
+ it 'project query raises an error' do
+ allow(Project).to receive_message_chain(:includes, :find_each).and_raise(ActiveRecord::StatementTimeout)
- create_list(:project, 2, :repository)
-
- expect do
- subject.dump(destination)
- end.not_to exceed_query_limit(control_count)
+ expect { subject.dump(destination, backup_id) }.to raise_error(ActiveRecord::StatementTimeout)
end
end
- context 'concurrency with a strategy without parallel enqueueing support' do
- let(:parallel_enqueue) { false }
- let(:max_concurrency) { 2 }
- let(:max_storage_concurrency) { 2 }
-
- it 'enqueues all projects sequentially' do
- expect(Thread).not_to receive(:new)
-
- expect(strategy).to receive(:start).with(:create, destination)
- projects.each do |project|
- expect(strategy).to receive(:enqueue).with(project, Gitlab::GlRepository::PROJECT)
- end
- expect(strategy).to receive(:finish!)
-
- subject.dump(destination)
- end
- end
-
- [4, 10].each do |max_storage_concurrency|
- context "max_storage_concurrency #{max_storage_concurrency}", quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/241701' do
- let(:storage_keys) { %w[default test_second_storage] }
- let(:max_storage_concurrency) { max_storage_concurrency }
-
- before do
- allow(Gitlab.config.repositories.storages).to receive(:keys).and_return(storage_keys)
- end
-
- it 'creates the expected number of threads' do
- expect(Thread).to receive(:new)
- .exactly(storage_keys.length * (max_storage_concurrency + 1)).times
- .and_call_original
+ it 'avoids N+1 database queries' do
+ control_count = ActiveRecord::QueryRecorder.new do
+ subject.dump(destination, backup_id)
+ end.count
- expect(strategy).to receive(:start).with(:create, destination)
- projects.each do |project|
- expect(strategy).to receive(:enqueue).with(project, Gitlab::GlRepository::PROJECT)
- end
- expect(strategy).to receive(:finish!)
+ create_list(:project, 2, :repository)
- subject.dump(destination)
- end
-
- context 'with extra max concurrency' do
- let(:max_concurrency) { 3 }
-
- it 'creates the expected number of threads' do
- expect(Thread).to receive(:new)
- .exactly(storage_keys.length * (max_storage_concurrency + 1)).times
- .and_call_original
-
- expect(strategy).to receive(:start).with(:create, destination)
- projects.each do |project|
- expect(strategy).to receive(:enqueue).with(project, Gitlab::GlRepository::PROJECT)
- end
- expect(strategy).to receive(:finish!)
-
- subject.dump(destination)
- end
- end
-
- describe 'command failure' do
- it 'enqueue_project raises an error' do
- allow(strategy).to receive(:enqueue).and_raise(IOError)
-
- expect { subject.dump(destination) }.to raise_error(IOError)
- end
-
- it 'project query raises an error' do
- allow(Project).to receive_message_chain(:for_repository_storage, :includes, :find_each).and_raise(ActiveRecord::StatementTimeout)
-
- expect { subject.dump(destination) }.to raise_error(ActiveRecord::StatementTimeout)
- end
-
- context 'misconfigured storages' do
- let(:storage_keys) { %w[test_second_storage] }
-
- it 'raises an error' do
- expect { subject.dump(destination) }.to raise_error(Backup::Error, 'repositories.storages in gitlab.yml is misconfigured')
- end
- end
- end
-
- it 'avoids N+1 database queries' do
- control_count = ActiveRecord::QueryRecorder.new do
- subject.dump(destination)
- end.count
-
- create_list(:project, 2, :repository)
-
- expect do
- subject.dump(destination)
- end.not_to exceed_query_limit(control_count)
- end
- end
+ expect do
+ subject.dump(destination, backup_id)
+ end.not_to exceed_query_limit(control_count)
end
end
diff --git a/spec/lib/backup/task_spec.rb b/spec/lib/backup/task_spec.rb
index b0eb885d3f4..80f1fe01b78 100644
--- a/spec/lib/backup/task_spec.rb
+++ b/spec/lib/backup/task_spec.rb
@@ -7,15 +7,9 @@ RSpec.describe Backup::Task do
subject { described_class.new(progress) }
- describe '#human_name' do
- it 'must be implemented by the subclass' do
- expect { subject.human_name }.to raise_error(NotImplementedError)
- end
- end
-
describe '#dump' do
it 'must be implemented by the subclass' do
- expect { subject.dump('some/path') }.to raise_error(NotImplementedError)
+ expect { subject.dump('some/path', 'backup_id') }.to raise_error(NotImplementedError)
end
end
diff --git a/spec/lib/backup/uploads_spec.rb b/spec/lib/backup/uploads_spec.rb
deleted file mode 100644
index 0cfc80a9cb9..00000000000
--- a/spec/lib/backup/uploads_spec.rb
+++ /dev/null
@@ -1,25 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Backup::Uploads do
- let(:progress) { StringIO.new }
-
- subject(:backup) { described_class.new(progress) }
-
- describe '#dump' do
- before do
- allow(File).to receive(:realpath).and_call_original
- allow(File).to receive(:realpath).with('/var/uploads').and_return('/var/uploads')
- allow(File).to receive(:realpath).with('/var/uploads/..').and_return('/var')
- allow(Gitlab.config.uploads).to receive(:storage_path) { '/var' }
- end
-
- it 'excludes tmp from backup tar' do
- expect(backup).to receive(:tar).and_return('blabla-tar')
- expect(backup).to receive(:run_pipeline!).with([%w(blabla-tar --exclude=lost+found --exclude=./tmp -C /var/uploads -cf - .), 'gzip -c -1'], any_args).and_return([[true, true], ''])
- expect(backup).to receive(:pipeline_succeeded?).and_return(true)
- backup.dump('uploads.tar.gz')
- end
- end
-end
diff --git a/spec/lib/banzai/filter/custom_emoji_filter_spec.rb b/spec/lib/banzai/filter/custom_emoji_filter_spec.rb
index 94e77663d0f..6e29b910a6c 100644
--- a/spec/lib/banzai/filter/custom_emoji_filter_spec.rb
+++ b/spec/lib/banzai/filter/custom_emoji_filter_spec.rb
@@ -18,31 +18,30 @@ RSpec.describe Banzai::Filter::CustomEmojiFilter do
doc = filter('<p>:tanuki:</p>', project: project)
expect(doc.css('gl-emoji').first.attributes['title'].value).to eq('tanuki')
- expect(doc.css('gl-emoji img').size).to eq 1
end
it 'correctly uses the custom emoji URL' do
doc = filter('<p>:tanuki:</p>')
- expect(doc.css('img').first.attributes['src'].value).to eq(custom_emoji.file)
+ expect(doc.css('gl-emoji').first.attributes['data-fallback-src'].value).to eq(custom_emoji.file)
end
it 'matches multiple same custom emoji' do
doc = filter(':tanuki: :tanuki:')
- expect(doc.css('img').size).to eq 2
+ expect(doc.css('gl-emoji').size).to eq 2
end
it 'matches multiple custom emoji' do
doc = filter(':tanuki: (:happy_tanuki:)')
- expect(doc.css('img').size).to eq 2
+ expect(doc.css('gl-emoji').size).to eq 2
end
it 'does not match enclosed colons' do
doc = filter('tanuki:tanuki:')
- expect(doc.css('img').size).to be 0
+ expect(doc.css('gl-emoji').size).to be 0
end
it 'does not do N+1 query' do
diff --git a/spec/lib/banzai/filter/image_link_filter_spec.rb b/spec/lib/banzai/filter/image_link_filter_spec.rb
index 238c3cdb9c1..6326d894b08 100644
--- a/spec/lib/banzai/filter/image_link_filter_spec.rb
+++ b/spec/lib/banzai/filter/image_link_filter_spec.rb
@@ -46,6 +46,16 @@ RSpec.describe Banzai::Filter::ImageLinkFilter do
expect(doc.at_css('img')['data-canonical-src']).to eq doc.at_css('a')['data-canonical-src']
end
+ it 'moves the data-diagram* attributes' do
+ doc = filter(%q(<img class="plantuml" src="http://localhost:8080/png/U9npoazIqBLJ24uiIbImKl18pSd91m0rkGMq" data-diagram="plantuml" data-diagram-src="data:text/plain;base64,Qm9iIC0+IFNhcmEgOiBIZWxsbw==">), context)
+
+ expect(doc.at_css('a')['data-diagram']).to eq "plantuml"
+ expect(doc.at_css('a')['data-diagram-src']).to eq "data:text/plain;base64,Qm9iIC0+IFNhcmEgOiBIZWxsbw=="
+
+ expect(doc.at_css('a img')['data-diagram']).to be_nil
+ expect(doc.at_css('a img')['data-diagram-src']).to be_nil
+ end
+
it 'adds no-attachment icon class to the link' do
doc = filter(image(path), context)
diff --git a/spec/lib/banzai/filter/kroki_filter_spec.rb b/spec/lib/banzai/filter/kroki_filter_spec.rb
index c9594ac702d..1fb61ad1991 100644
--- a/spec/lib/banzai/filter/kroki_filter_spec.rb
+++ b/spec/lib/banzai/filter/kroki_filter_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe Banzai::Filter::KrokiFilter do
stub_application_setting(kroki_enabled: true, kroki_url: "http://localhost:8000")
doc = filter("<pre lang='nomnoml'><code>[Pirate|eyeCount: Int|raid();pillage()|\n [beard]--[parrot]\n [beard]-:>[foul mouth]\n]</code></pre>")
- expect(doc.to_s).to eq '<img class="js-render-kroki" src="http://localhost:8000/nomnoml/svg/eNqLDsgsSixJrUmtTHXOL80rsVLwzCupKUrMTNHQtC7IzMlJTE_V0KzhUlCITkpNLEqJ1dWNLkgsKsoviUUSs7KLTssvzVHIzS8tyYjligUAMhEd0g==">'
+ expect(doc.to_s).to eq '<img src="http://localhost:8000/nomnoml/svg/eNqLDsgsSixJrUmtTHXOL80rsVLwzCupKUrMTNHQtC7IzMlJTE_V0KzhUlCITkpNLEqJ1dWNLkgsKsoviUUSs7KLTssvzVHIzS8tyYjligUAMhEd0g==" class="js-render-kroki" data-diagram="nomnoml" data-diagram-src="data:text/plain;base64,W1BpcmF0ZXxleWVDb3VudDogSW50fHJhaWQoKTtwaWxsYWdlKCl8CiAgW2JlYXJkXS0tW3BhcnJvdF0KICBbYmVhcmRdLTo+W2ZvdWwgbW91dGhdCl0=">'
end
it 'replaces nomnoml pre tag with img tag if both kroki and plantuml are enabled' do
@@ -19,7 +19,7 @@ RSpec.describe Banzai::Filter::KrokiFilter do
plantuml_url: "http://localhost:8080")
doc = filter("<pre lang='nomnoml'><code>[Pirate|eyeCount: Int|raid();pillage()|\n [beard]--[parrot]\n [beard]-:>[foul mouth]\n]</code></pre>")
- expect(doc.to_s).to eq '<img class="js-render-kroki" src="http://localhost:8000/nomnoml/svg/eNqLDsgsSixJrUmtTHXOL80rsVLwzCupKUrMTNHQtC7IzMlJTE_V0KzhUlCITkpNLEqJ1dWNLkgsKsoviUUSs7KLTssvzVHIzS8tyYjligUAMhEd0g==">'
+ expect(doc.to_s).to eq '<img src="http://localhost:8000/nomnoml/svg/eNqLDsgsSixJrUmtTHXOL80rsVLwzCupKUrMTNHQtC7IzMlJTE_V0KzhUlCITkpNLEqJ1dWNLkgsKsoviUUSs7KLTssvzVHIzS8tyYjligUAMhEd0g==" class="js-render-kroki" data-diagram="nomnoml" data-diagram-src="data:text/plain;base64,W1BpcmF0ZXxleWVDb3VudDogSW50fHJhaWQoKTtwaWxsYWdlKCl8CiAgW2JlYXJkXS0tW3BhcnJvdF0KICBbYmVhcmRdLTo+W2ZvdWwgbW91dGhdCl0=">'
end
it 'does not replace nomnoml pre tag with img tag if kroki is disabled' do
@@ -44,6 +44,6 @@ RSpec.describe Banzai::Filter::KrokiFilter do
text = '[Pirate|eyeCount: Int|raid();pillage()|\n [beard]--[parrot]\n [beard]-:>[foul mouth]\n]' * 25
doc = filter("<pre lang='nomnoml'><code>#{text}</code></pre>")
- expect(doc.to_s).to eq '<img class="js-render-kroki" src="http://localhost:8000/nomnoml/svg/eNqLDsgsSixJrUmtTHXOL80rsVLwzCupKUrMTNHQtC7IzMlJTE_V0KyJyVNQiE5KTSxKidXVjS5ILCrKL4lFFrSyi07LL81RyM0vLckAysRGjxo8avCowaMGjxo8avCowaMGU8lgAE7mIdc=" hidden>'
+ expect(doc.to_s).to start_with '<img src="http://localhost:8000/nomnoml/svg/eNqLDsgsSixJrUmtTHXOL80rsVLwzCupKUrMTNHQtC7IzMlJTE_V0KyJyVNQiE5KTSxKidXVjS5ILCrKL4lFFrSyi07LL81RyM0vLckAysRGjxo8avCowaMGjxo8avCowaMGU8lgAE7mIdc=" hidden="" class="js-render-kroki" data-diagram="nomnoml" data-diagram-src="data:text/plain;base64,W1BpcmF0ZXxleWVDb3VudDog'
end
end
diff --git a/spec/lib/banzai/filter/plantuml_filter_spec.rb b/spec/lib/banzai/filter/plantuml_filter_spec.rb
index 2d1a01116e0..dcfeb2ce3ba 100644
--- a/spec/lib/banzai/filter/plantuml_filter_spec.rb
+++ b/spec/lib/banzai/filter/plantuml_filter_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe Banzai::Filter::PlantumlFilter do
stub_application_setting(plantuml_enabled: true, plantuml_url: "http://localhost:8080")
input = '<pre lang="plantuml"><code>Bob -> Sara : Hello</code></pre>'
- output = '<div class="imageblock"><div class="content"><img class="plantuml" src="http://localhost:8080/png/U9npoazIqBLJ24uiIbImKl18pSd91m0rkGMq"></div></div>'
+ output = '<img class="plantuml" src="http://localhost:8080/png/U9npoazIqBLJ24uiIbImKl18pSd91m0rkGMq" data-diagram="plantuml" data-diagram-src="data:text/plain;base64,Qm9iIC0+IFNhcmEgOiBIZWxsbw==">'
doc = filter(input)
expect(doc.to_s).to eq output
@@ -29,7 +29,7 @@ RSpec.describe Banzai::Filter::PlantumlFilter do
stub_application_setting(plantuml_enabled: true, plantuml_url: "invalid")
input = '<pre lang="plantuml"><code>Bob -> Sara : Hello</code></pre>'
- output = '<div class="listingblock"><div class="content"><pre class="plantuml plantuml-error"> Error: cannot connect to PlantUML server at "invalid"</pre></div></div>'
+ output = '<pre lang="plantuml"><code>Bob -&gt; Sara : Hello</code></pre>'
doc = filter(input)
expect(doc.to_s).to eq output
diff --git a/spec/lib/bulk_imports/common/pipelines/entity_finisher_spec.rb b/spec/lib/bulk_imports/common/pipelines/entity_finisher_spec.rb
index c1a9ea7b7e2..f03a178b993 100644
--- a/spec/lib/bulk_imports/common/pipelines/entity_finisher_spec.rb
+++ b/spec/lib/bulk_imports/common/pipelines/entity_finisher_spec.rb
@@ -21,6 +21,8 @@ RSpec.describe BulkImports::Common::Pipelines::EntityFinisher do
)
end
+ expect(context.portable).to receive(:try).with(:after_import)
+
expect { subject.run }
.to change(entity, :status_name).to(:finished)
end
diff --git a/spec/lib/bulk_imports/groups/stage_spec.rb b/spec/lib/bulk_imports/groups/stage_spec.rb
index b6bb8a7d195..645dee4a6f1 100644
--- a/spec/lib/bulk_imports/groups/stage_spec.rb
+++ b/spec/lib/bulk_imports/groups/stage_spec.rb
@@ -3,7 +3,10 @@
require 'spec_helper'
RSpec.describe BulkImports::Groups::Stage do
+ let(:ancestor) { create(:group) }
+ let(:group) { create(:group, parent: ancestor) }
let(:bulk_import) { build(:bulk_import) }
+ let(:entity) { build(:bulk_import_entity, bulk_import: bulk_import, group: group, destination_namespace: ancestor.full_path) }
let(:pipelines) do
[
@@ -19,26 +22,46 @@ RSpec.describe BulkImports::Groups::Stage do
end
it 'raises error when initialized without a BulkImport' do
- expect { described_class.new({}) }.to raise_error(ArgumentError, 'Expected an argument of type ::BulkImport')
+ expect { described_class.new({}) }.to raise_error(ArgumentError, 'Expected an argument of type ::BulkImports::Entity')
end
describe '.pipelines' do
it 'list all the pipelines with their stage number, ordered by stage' do
- expect(described_class.new(bulk_import).pipelines & pipelines).to contain_exactly(*pipelines)
- expect(described_class.new(bulk_import).pipelines.last.last).to eq(BulkImports::Common::Pipelines::EntityFinisher)
+ expect(described_class.new(entity).pipelines & pipelines).to contain_exactly(*pipelines)
+ expect(described_class.new(entity).pipelines.last.last).to eq(BulkImports::Common::Pipelines::EntityFinisher)
end
- it 'includes project entities pipeline' do
- stub_feature_flags(bulk_import_projects: true)
+ context 'when bulk_import_projects feature flag is enabled' do
+ it 'includes project entities pipeline' do
+ stub_feature_flags(bulk_import_projects: true)
- expect(described_class.new(bulk_import).pipelines).to include([1, BulkImports::Groups::Pipelines::ProjectEntitiesPipeline])
+ expect(described_class.new(entity).pipelines).to include([1, BulkImports::Groups::Pipelines::ProjectEntitiesPipeline])
+ end
+
+ context 'when feature flag is enabled on root ancestor level' do
+ it 'includes project entities pipeline' do
+ stub_feature_flags(bulk_import_projects: ancestor)
+
+ expect(described_class.new(entity).pipelines).to include([1, BulkImports::Groups::Pipelines::ProjectEntitiesPipeline])
+ end
+ end
+
+ context 'when destination namespace is not present' do
+ it 'includes project entities pipeline' do
+ stub_feature_flags(bulk_import_projects: true)
+
+ entity = create(:bulk_import_entity, destination_namespace: '')
+
+ expect(described_class.new(entity).pipelines).to include([1, BulkImports::Groups::Pipelines::ProjectEntitiesPipeline])
+ end
+ end
end
context 'when bulk_import_projects feature flag is disabled' do
it 'does not include project entities pipeline' do
stub_feature_flags(bulk_import_projects: false)
- expect(described_class.new(bulk_import).pipelines.flatten).not_to include(BulkImports::Groups::Pipelines::ProjectEntitiesPipeline)
+ expect(described_class.new(entity).pipelines.flatten).not_to include(BulkImports::Groups::Pipelines::ProjectEntitiesPipeline)
end
end
end
diff --git a/spec/lib/bulk_imports/projects/stage_spec.rb b/spec/lib/bulk_imports/projects/stage_spec.rb
index ef98613dc25..9fce30f3a81 100644
--- a/spec/lib/bulk_imports/projects/stage_spec.rb
+++ b/spec/lib/bulk_imports/projects/stage_spec.rb
@@ -34,9 +34,9 @@ RSpec.describe BulkImports::Projects::Stage do
end
subject do
- bulk_import = build(:bulk_import)
+ entity = build(:bulk_import_entity, :project_entity)
- described_class.new(bulk_import)
+ described_class.new(entity)
end
describe '#pipelines' do
diff --git a/spec/lib/container_registry/gitlab_api_client_spec.rb b/spec/lib/container_registry/gitlab_api_client_spec.rb
index 4fe229024e5..16d2c42f332 100644
--- a/spec/lib/container_registry/gitlab_api_client_spec.rb
+++ b/spec/lib/container_registry/gitlab_api_client_spec.rb
@@ -62,6 +62,7 @@ RSpec.describe ContainerRegistry::GitlabApiClient do
where(:status_code, :expected_result) do
200 | :already_imported
202 | :ok
+ 400 | :bad_request
401 | :unauthorized
404 | :not_found
409 | :already_being_imported
@@ -86,6 +87,7 @@ RSpec.describe ContainerRegistry::GitlabApiClient do
where(:status_code, :expected_result) do
200 | :already_imported
202 | :ok
+ 400 | :bad_request
401 | :unauthorized
404 | :not_found
409 | :already_being_imported
@@ -104,54 +106,106 @@ RSpec.describe ContainerRegistry::GitlabApiClient do
end
end
- describe '#import_status' do
- subject { client.import_status(path) }
+ describe '#cancel_repository_import' do
+ let(:force) { false }
- before do
- stub_import_status(path, status)
+ subject { client.cancel_repository_import(path, force: force) }
+
+ where(:status_code, :expected_result) do
+ 200 | :already_imported
+ 202 | :ok
+ 400 | :bad_request
+ 401 | :unauthorized
+ 404 | :not_found
+ 409 | :already_being_imported
+ 418 | :error
+ 424 | :pre_import_failed
+ 425 | :already_being_imported
+ 429 | :too_many_imports
end
- context 'with a status' do
+ with_them do
+ before do
+ stub_import_cancel(path, status_code, force: force)
+ end
+
+ it { is_expected.to eq({ status: expected_result, migration_state: nil }) }
+ end
+
+ context 'bad request' do
let(:status) { 'this_is_a_test' }
- it { is_expected.to eq(status) }
+ before do
+ stub_import_cancel(path, 400, status: status, force: force)
+ end
+
+ it { is_expected.to eq({ status: :bad_request, migration_state: status }) }
end
- context 'with no status' do
- let(:status) { nil }
+ context 'force cancel' do
+ let(:force) { true }
- it { is_expected.to eq('error') }
+ before do
+ stub_import_cancel(path, 202, force: force)
+ end
+
+ it { is_expected.to eq({ status: :ok, migration_state: nil }) }
end
end
- describe '#repository_details' do
- let(:path) { 'namespace/path/to/repository' }
- let(:response) { { foo: :bar, this: :is_a_test } }
- let(:with_size) { true }
-
- subject { client.repository_details(path, with_size: with_size) }
+ describe '#import_status' do
+ subject { client.import_status(path) }
- context 'with size' do
+ context 'with successful response' do
before do
- stub_repository_details(path, with_size: with_size, respond_with: response)
+ stub_import_status(path, status)
end
- it { is_expected.to eq(response.stringify_keys.deep_transform_values(&:to_s)) }
- end
+ context 'with a status' do
+ let(:status) { 'this_is_a_test' }
+
+ it { is_expected.to eq(status) }
+ end
+
+ context 'with no status' do
+ let(:status) { nil }
- context 'without_size' do
- let(:with_size) { false }
+ it { is_expected.to eq('error') }
+ end
+ end
+ context 'with non successful response' do
before do
- stub_repository_details(path, with_size: with_size, respond_with: response)
+ stub_import_status(path, nil, status_code: 404)
end
- it { is_expected.to eq(response.stringify_keys.deep_transform_values(&:to_s)) }
+ it { is_expected.to eq('pre_import_failed') }
+ end
+ end
+
+ describe '#repository_details' do
+ let(:path) { 'namespace/path/to/repository' }
+ let(:response) { { foo: :bar, this: :is_a_test } }
+
+ subject { client.repository_details(path, sizing: sizing) }
+
+ [:self, :self_with_descendants, nil].each do |size_type|
+ context "with sizing #{size_type}" do
+ let(:sizing) { size_type }
+
+ before do
+ stub_repository_details(path, sizing: sizing, respond_with: response)
+ end
+
+ it { is_expected.to eq(response.stringify_keys.deep_transform_values(&:to_s)) }
+ end
end
context 'with non successful response' do
+ let(:sizing) { nil }
+
before do
- stub_repository_details(path, with_size: with_size, status_code: 404)
+ stub_repository_details(path, sizing: sizing, status_code: 404)
end
it { is_expected.to eq({}) }
@@ -216,6 +270,54 @@ RSpec.describe ContainerRegistry::GitlabApiClient do
end
end
+ describe '.deduplicated_size' do
+ let(:path) { 'foo/bar' }
+ let(:response) { { 'size_bytes': 555 } }
+ let(:registry_enabled) { true }
+
+ subject { described_class.deduplicated_size(path) }
+
+ before do
+ stub_container_registry_config(enabled: registry_enabled, api_url: registry_api_url, key: 'spec/fixtures/x509_certificate_pk.key')
+ end
+
+ context 'with successful response' do
+ before do
+ expect(Auth::ContainerRegistryAuthenticationService).to receive(:pull_nested_repositories_access_token).with(path).and_return(token)
+ stub_repository_details(path, sizing: :self_with_descendants, status_code: 200, respond_with: response)
+ end
+
+ it { is_expected.to eq(555) }
+ end
+
+ context 'with unsuccessful response' do
+ before do
+ expect(Auth::ContainerRegistryAuthenticationService).to receive(:pull_nested_repositories_access_token).with(path).and_return(token)
+ stub_repository_details(path, sizing: :self_with_descendants, status_code: 404, respond_with: response)
+ end
+
+ it { is_expected.to eq(nil) }
+ end
+
+ context 'with the registry disabled' do
+ let(:registry_enabled) { false }
+
+ it { is_expected.to eq(nil) }
+ end
+
+ context 'with a nil path' do
+ let(:path) { nil }
+ let(:token) { nil }
+
+ before do
+ expect(Auth::ContainerRegistryAuthenticationService).not_to receive(:pull_nested_repositories_access_token)
+ stub_repository_details(path, sizing: :self_with_descendants, status_code: 401, respond_with: response)
+ end
+
+ it { is_expected.to eq(nil) }
+ end
+ end
+
def stub_pre_import(path, status_code, pre:)
import_type = pre ? 'pre' : 'final'
stub_request(:put, "#{registry_api_url}/gitlab/v1/import/#{path}/?import_type=#{import_type}")
@@ -230,21 +332,50 @@ RSpec.describe ContainerRegistry::GitlabApiClient do
.to_return(status: status_code, body: '')
end
- def stub_import_status(path, status)
+ def stub_import_status(path, status, status_code: 200)
stub_request(:get, "#{registry_api_url}/gitlab/v1/import/#{path}/")
.with(headers: { 'Accept' => described_class::JSON_TYPE, 'Authorization' => "bearer #{import_token}" })
.to_return(
- status: 200,
+ status: status_code,
body: { status: status }.to_json,
headers: { content_type: 'application/json' }
)
end
- def stub_repository_details(path, with_size: true, status_code: 200, respond_with: {})
+ def stub_import_cancel(path, http_status, status: nil, force: false)
+ body = {}
+
+ if http_status == 400
+ body = { status: status }
+ end
+
+ headers = {
+ 'Accept' => described_class::JSON_TYPE,
+ 'Authorization' => "bearer #{import_token}",
+ 'User-Agent' => "GitLab/#{Gitlab::VERSION}",
+ 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3'
+ }
+
+ params = force ? '?force=true' : ''
+
+ stub_request(:delete, "#{registry_api_url}/gitlab/v1/import/#{path}/#{params}")
+ .with(headers: headers)
+ .to_return(
+ status: http_status,
+ body: body.to_json,
+ headers: { content_type: 'application/json' }
+ )
+ end
+
+ def stub_repository_details(path, sizing: nil, status_code: 200, respond_with: {})
url = "#{registry_api_url}/gitlab/v1/repositories/#{path}/"
- url += "?size=self" if with_size
+ url += "?size=#{sizing}" if sizing
+
+ headers = { 'Accept' => described_class::JSON_TYPE }
+ headers['Authorization'] = "bearer #{token}" if token
+
stub_request(:get, url)
- .with(headers: { 'Accept' => described_class::JSON_TYPE, 'Authorization' => "bearer #{token}" })
+ .with(headers: headers)
.to_return(status: status_code, body: respond_with.to_json, headers: { 'Content-Type' => described_class::JSON_TYPE })
end
end
diff --git a/spec/lib/container_registry/migration_spec.rb b/spec/lib/container_registry/migration_spec.rb
index ffbbfb249e3..6c0fc94e27f 100644
--- a/spec/lib/container_registry/migration_spec.rb
+++ b/spec/lib/container_registry/migration_spec.rb
@@ -37,8 +37,8 @@ RSpec.describe ContainerRegistry::Migration do
subject { described_class.enqueue_waiting_time }
where(:slow_enabled, :fast_enabled, :expected_result) do
- false | false | 1.hour
- true | false | 6.hours
+ false | false | 45.minutes
+ true | false | 165.minutes
false | true | 0
true | true | 0
end
@@ -154,15 +154,35 @@ RSpec.describe ContainerRegistry::Migration do
end
end
- describe '.target_plan' do
- let_it_be(:plan) { create(:plan) }
+ describe '.target_plans' do
+ subject { described_class.target_plans }
- before do
- stub_application_setting(container_registry_import_target_plan: plan.name)
+ where(:target_plan, :result) do
+ 'free' | described_class::FREE_TIERS
+ 'premium' | described_class::PREMIUM_TIERS
+ 'ultimate' | described_class::ULTIMATE_TIERS
end
- it 'returns the matching application_setting' do
- expect(described_class.target_plan).to eq(plan)
+ with_them do
+ before do
+ stub_application_setting(container_registry_import_target_plan: target_plan)
+ end
+
+ it { is_expected.to eq(result) }
+ end
+ end
+
+ describe '.all_plans?' do
+ subject { described_class.all_plans? }
+
+ it { is_expected.to eq(true) }
+
+ context 'feature flag disabled' do
+ before do
+ stub_feature_flags(container_registry_migration_phase2_all_plans: false)
+ end
+
+ it { is_expected.to eq(false) }
end
end
end
diff --git a/spec/lib/error_tracking/sentry_client/issue_spec.rb b/spec/lib/error_tracking/sentry_client/issue_spec.rb
index 82db0f70f2e..d7bb0ca5c9a 100644
--- a/spec/lib/error_tracking/sentry_client/issue_spec.rb
+++ b/spec/lib/error_tracking/sentry_client/issue_spec.rb
@@ -13,7 +13,7 @@ RSpec.describe ErrorTracking::SentryClient::Issue do
describe '#list_issues' do
shared_examples 'issues have correct return type' do |klass|
it "returns objects of type #{klass}" do
- expect(subject[:issues]).to all( be_a(klass) )
+ expect(subject[:issues]).to all(be_a(klass))
end
end
@@ -41,10 +41,18 @@ RSpec.describe ErrorTracking::SentryClient::Issue do
let(:cursor) { nil }
let(:sort) { 'last_seen' }
let(:sentry_api_response) { issues_sample_response }
- let(:sentry_request_url) { sentry_url + '/issues/?limit=20&query=is:unresolved' }
+ let(:sentry_request_url) { "#{sentry_url}/issues/?limit=20&query=is:unresolved" }
let!(:sentry_api_request) { stub_sentry_request(sentry_request_url, body: sentry_api_response) }
- subject { client.list_issues(issue_status: issue_status, limit: limit, search_term: search_term, sort: sort, cursor: cursor) }
+ subject do
+ client.list_issues(
+ issue_status: issue_status,
+ limit: limit,
+ search_term: search_term,
+ sort: sort,
+ cursor: cursor
+ )
+ end
it_behaves_like 'calls sentry api'
@@ -52,7 +60,7 @@ RSpec.describe ErrorTracking::SentryClient::Issue do
it_behaves_like 'issues have correct length', 3
shared_examples 'has correct external_url' do
- context 'external_url' do
+ describe '#external_url' do
it 'is constructed correctly' do
expect(subject[:issues][0].external_url).to eq('https://sentrytest.gitlab.com/sentry-org/sentry-project/issues/11')
end
@@ -62,7 +70,8 @@ RSpec.describe ErrorTracking::SentryClient::Issue do
context 'when response has a pagination info' do
let(:headers) do
{
- link: '<https://sentrytest.gitlab.com>; rel="previous"; results="true"; cursor="1573556671000:0:1", <https://sentrytest.gitlab.com>; rel="next"; results="true"; cursor="1572959139000:0:0"'
+ link: '<https://sentrytest.gitlab.com>; rel="previous"; results="true"; cursor="1573556671000:0:1",' \
+ '<https://sentrytest.gitlab.com>; rel="next"; results="true"; cursor="1572959139000:0:0"'
}
end
@@ -76,7 +85,7 @@ RSpec.describe ErrorTracking::SentryClient::Issue do
end
end
- context 'error object created from sentry response' do
+ context 'when error object created from sentry response' do
using RSpec::Parameterized::TableSyntax
where(:error_object, :sentry_response) do
@@ -104,13 +113,13 @@ RSpec.describe ErrorTracking::SentryClient::Issue do
it_behaves_like 'has correct external_url'
end
- context 'redirects' do
- let(:sentry_api_url) { sentry_url + '/issues/?limit=20&query=is:unresolved' }
+ context 'with redirects' do
+ let(:sentry_api_url) { "#{sentry_url}/issues/?limit=20&query=is:unresolved" }
it_behaves_like 'no Sentry redirects'
end
- context 'requests with sort parameter in sentry api' do
+ context 'with sort parameter in sentry api' do
let(:sentry_request_url) do
'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project/' \
'issues/?limit=20&query=is:unresolved&sort=freq'
@@ -140,7 +149,7 @@ RSpec.describe ErrorTracking::SentryClient::Issue do
end
end
- context 'Older sentry versions where keys are not present' do
+ context 'with older sentry versions where keys are not present' do
let(:sentry_api_response) do
issues_sample_response[0...1].map do |issue|
issue[:project].delete(:id)
@@ -156,7 +165,7 @@ RSpec.describe ErrorTracking::SentryClient::Issue do
it_behaves_like 'has correct external_url'
end
- context 'essential keys missing in API response' do
+ context 'when essential keys are missing in API response' do
let(:sentry_api_response) do
issues_sample_response[0...1].map do |issue|
issue.except(:id)
@@ -164,16 +173,18 @@ RSpec.describe ErrorTracking::SentryClient::Issue do
end
it 'raises exception' do
- expect { subject }.to raise_error(ErrorTracking::SentryClient::MissingKeysError, 'Sentry API response is missing keys. key not found: "id"')
+ expect { subject }.to raise_error(ErrorTracking::SentryClient::MissingKeysError,
+ 'Sentry API response is missing keys. key not found: "id"')
end
end
- context 'sentry api response too large' do
+ context 'when sentry api response is too large' do
it 'raises exception' do
- deep_size = double('Gitlab::Utils::DeepSize', valid?: false)
+ deep_size = instance_double(Gitlab::Utils::DeepSize, valid?: false)
allow(Gitlab::Utils::DeepSize).to receive(:new).with(sentry_api_response).and_return(deep_size)
- expect { subject }.to raise_error(ErrorTracking::SentryClient::ResponseInvalidSizeError, 'Sentry API response is too big. Limit is 1 MB.')
+ expect { subject }.to raise_error(ErrorTracking::SentryClient::ResponseInvalidSizeError,
+ 'Sentry API response is too big. Limit is 1 MB.')
end
end
@@ -212,7 +223,7 @@ RSpec.describe ErrorTracking::SentryClient::Issue do
subject { client.issue_details(issue_id: issue_id) }
- context 'error object created from sentry response' do
+ context 'with error object created from sentry response' do
using RSpec::Parameterized::TableSyntax
where(:error_object, :sentry_response) do
@@ -298,17 +309,16 @@ RSpec.describe ErrorTracking::SentryClient::Issue do
describe '#update_issue' do
let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0' }
let(:sentry_request_url) { "#{sentry_url}/issues/#{issue_id}/" }
-
- before do
- stub_sentry_request(sentry_request_url, :put)
- end
-
let(:params) do
{
status: 'resolved'
}
end
+ before do
+ stub_sentry_request(sentry_request_url, :put)
+ end
+
subject { client.update_issue(issue_id: issue_id, params: params) }
it_behaves_like 'calls sentry api' do
@@ -319,7 +329,7 @@ RSpec.describe ErrorTracking::SentryClient::Issue do
expect(subject).to be_truthy
end
- context 'error encountered' do
+ context 'when error is encountered' do
let(:error) { StandardError.new('error') }
before do
diff --git a/spec/lib/gitlab/application_context_spec.rb b/spec/lib/gitlab/application_context_spec.rb
index 55f5ae7d7dc..f9e18a65af4 100644
--- a/spec/lib/gitlab/application_context_spec.rb
+++ b/spec/lib/gitlab/application_context_spec.rb
@@ -146,7 +146,8 @@ RSpec.describe Gitlab::ApplicationContext do
where(:provided_options, :client) do
[:remote_ip] | :remote_ip
[:remote_ip, :runner] | :runner
- [:remote_ip, :runner, :user] | :user
+ [:remote_ip, :runner, :user] | :runner
+ [:remote_ip, :user] | :user
end
with_them do
@@ -195,6 +196,16 @@ RSpec.describe Gitlab::ApplicationContext do
expect(result(context)).to include(project: nil)
end
end
+
+ context 'when using job context' do
+ let_it_be(:job) { create(:ci_build, :pending, :queued, user: user, project: project) }
+
+ it 'sets expected values' do
+ context = described_class.new(job: job)
+
+ expect(result(context)).to include(job_id: job.id, project: project.full_path, pipeline_id: job.pipeline_id)
+ end
+ end
end
describe '#use' do
diff --git a/spec/lib/gitlab/auth/o_auth/user_spec.rb b/spec/lib/gitlab/auth/o_auth/user_spec.rb
index 1a9e2f02de6..6cb9085c3ad 100644
--- a/spec/lib/gitlab/auth/o_auth/user_spec.rb
+++ b/spec/lib/gitlab/auth/o_auth/user_spec.rb
@@ -6,11 +6,15 @@ RSpec.describe Gitlab::Auth::OAuth::User do
include LdapHelpers
let(:oauth_user) { described_class.new(auth_hash) }
+ let(:oauth_user_2) { described_class.new(auth_hash_2) }
let(:gl_user) { oauth_user.gl_user }
+ let(:gl_user_2) { oauth_user_2.gl_user }
let(:uid) { 'my-uid' }
+ let(:uid_2) { 'my-uid-2' }
let(:dn) { 'uid=user1,ou=people,dc=example' }
let(:provider) { 'my-provider' }
let(:auth_hash) { OmniAuth::AuthHash.new(uid: uid, provider: provider, info: info_hash) }
+ let(:auth_hash_2) { OmniAuth::AuthHash.new(uid: uid_2, provider: provider, info: info_hash) }
let(:info_hash) do
{
nickname: '-john+gitlab-ETC%.git@gmail.com',
@@ -24,6 +28,7 @@ RSpec.describe Gitlab::Auth::OAuth::User do
end
let(:ldap_user) { Gitlab::Auth::Ldap::Person.new(Net::LDAP::Entry.new, 'ldapmain') }
+ let(:ldap_user_2) { Gitlab::Auth::Ldap::Person.new(Net::LDAP::Entry.new, 'ldapmain') }
describe '.find_by_uid_and_provider' do
let(:dn) { 'CN=John Åström, CN=Users, DC=Example, DC=com' }
@@ -46,12 +51,12 @@ RSpec.describe Gitlab::Auth::OAuth::User do
let!(:existing_user) { create(:omniauth_user, extern_uid: 'my-uid', provider: 'my-provider') }
it "finds an existing user based on uid and provider (facebook)" do
- expect( oauth_user.persisted? ).to be_truthy
+ expect(oauth_user.persisted?).to be_truthy
end
it 'returns false if user is not found in database' do
allow(auth_hash).to receive(:uid).and_return('non-existing')
- expect( oauth_user.persisted? ).to be_falsey
+ expect(oauth_user.persisted?).to be_falsey
end
end
@@ -78,15 +83,27 @@ RSpec.describe Gitlab::Auth::OAuth::User do
context 'when signup is disabled' do
before do
stub_application_setting signup_enabled: false
+ stub_omniauth_config(allow_single_sign_on: [provider])
end
it 'creates the user' do
- stub_omniauth_config(allow_single_sign_on: [provider])
-
oauth_user.save # rubocop:disable Rails/SaveBang
expect(gl_user).to be_persisted
end
+
+ it 'does not repeat the default user password' do
+ oauth_user.save # rubocop:disable Rails/SaveBang
+ oauth_user_2.save # rubocop:disable Rails/SaveBang
+
+ expect(gl_user.password).not_to eq(gl_user_2.password)
+ end
+
+ it 'has the password length within specified range' do
+ oauth_user.save # rubocop:disable Rails/SaveBang
+
+ expect(gl_user.password.length).to be_between(Devise.password_length.min, Devise.password_length.max)
+ end
end
context 'when user confirmation email is enabled' do
@@ -330,6 +347,12 @@ RSpec.describe Gitlab::Auth::OAuth::User do
allow(ldap_user).to receive(:name) { 'John Doe' }
allow(ldap_user).to receive(:email) { ['johndoe@example.com', 'john2@example.com'] }
allow(ldap_user).to receive(:dn) { dn }
+
+ allow(ldap_user_2).to receive(:uid) { uid_2 }
+ allow(ldap_user_2).to receive(:username) { uid_2 }
+ allow(ldap_user_2).to receive(:name) { 'Beck Potter' }
+ allow(ldap_user_2).to receive(:email) { ['beckpotter@example.com', 'beck2@example.com'] }
+ allow(ldap_user_2).to receive(:dn) { dn }
end
context "and no account for the LDAP user" do
@@ -340,6 +363,14 @@ RSpec.describe Gitlab::Auth::OAuth::User do
oauth_user.save # rubocop:disable Rails/SaveBang
end
+ it 'does not repeat the default user password' do
+ allow(Gitlab::Auth::Ldap::Person).to receive(:find_by_uid).and_return(ldap_user_2)
+
+ oauth_user_2.save # rubocop:disable Rails/SaveBang
+
+ expect(gl_user.password).not_to eq(gl_user_2.password)
+ end
+
it "creates a user with dual LDAP and omniauth identities" do
expect(gl_user).to be_valid
expect(gl_user.username).to eql uid
@@ -609,6 +640,7 @@ RSpec.describe Gitlab::Auth::OAuth::User do
context 'signup with SAML' do
let(:provider) { 'saml' }
+ let(:block_auto_created_users) { false }
before do
stub_omniauth_config({
@@ -625,6 +657,13 @@ RSpec.describe Gitlab::Auth::OAuth::User do
it_behaves_like 'not being blocked on creation' do
let(:block_auto_created_users) { false }
end
+
+ it 'does not repeat the default user password' do
+ oauth_user.save # rubocop:disable Rails/SaveBang
+ oauth_user_2.save # rubocop:disable Rails/SaveBang
+
+ expect(gl_user.password).not_to eq(gl_user_2.password)
+ end
end
context 'signup with omniauth only' do
diff --git a/spec/lib/gitlab/background_migration/backfill_draft_status_on_merge_requests_spec.rb b/spec/lib/gitlab/background_migration/backfill_draft_status_on_merge_requests_spec.rb
index a7895623d6f..1158eedfe7c 100644
--- a/spec/lib/gitlab/background_migration/backfill_draft_status_on_merge_requests_spec.rb
+++ b/spec/lib/gitlab/background_migration/backfill_draft_status_on_merge_requests_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::BackgroundMigration::BackfillDraftStatusOnMergeRequests do
+RSpec.describe Gitlab::BackgroundMigration::BackfillDraftStatusOnMergeRequests, :migration, schema: 20220326161803 do
let(:namespaces) { table(:namespaces) }
let(:projects) { table(:projects) }
let(:merge_requests) { table(:merge_requests) }
@@ -50,5 +50,19 @@ RSpec.describe Gitlab::BackgroundMigration::BackfillDraftStatusOnMergeRequests d
subject.perform(mr_ids.first, mr_ids.last)
end
+
+ it_behaves_like 'marks background migration job records' do
+ let!(:non_eligible_mrs) do
+ Array.new(2) do
+ create_merge_request(
+ title: "Not a d-r-a-f-t 1",
+ draft: false,
+ state_id: 1
+ )
+ end
+ end
+
+ let(:arguments) { [non_eligible_mrs.first.id, non_eligible_mrs.last.id] }
+ end
end
end
diff --git a/spec/lib/gitlab/background_migration/backfill_group_features_spec.rb b/spec/lib/gitlab/background_migration/backfill_group_features_spec.rb
new file mode 100644
index 00000000000..4705f0d0ab9
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/backfill_group_features_spec.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::BackfillGroupFeatures, :migration, schema: 20220302114046 do
+ let(:group_features) { table(:group_features) }
+ let(:namespaces) { table(:namespaces) }
+
+ subject { described_class.new(connection: ActiveRecord::Base.connection) }
+
+ describe '#perform' do
+ it 'creates settings for all group namespaces in range' do
+ namespaces.create!(id: 1, name: 'group1', path: 'group1', type: 'Group')
+ namespaces.create!(id: 2, name: 'user', path: 'user')
+ namespaces.create!(id: 3, name: 'group2', path: 'group2', type: 'Group')
+
+ # Checking that no error is raised if the group_feature for a group already exists
+ namespaces.create!(id: 4, name: 'group3', path: 'group3', type: 'Group')
+ group_features.create!(id: 1, group_id: 4)
+ expect(group_features.count).to eq 1
+
+ expect { subject.perform(1, 4, :namespaces, :id, 10, 0, 4) }.to change { group_features.count }.by(2)
+
+ expect(group_features.count).to eq 3
+ expect(group_features.all.pluck(:group_id)).to contain_exactly(1, 3, 4)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/backfill_incident_issue_escalation_statuses_spec.rb b/spec/lib/gitlab/background_migration/backfill_incident_issue_escalation_statuses_spec.rb
deleted file mode 100644
index 242da383453..00000000000
--- a/spec/lib/gitlab/background_migration/backfill_incident_issue_escalation_statuses_spec.rb
+++ /dev/null
@@ -1,27 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::BackgroundMigration::BackfillIncidentIssueEscalationStatuses, schema: 20211214012507 do
- let(:namespaces) { table(:namespaces) }
- let(:projects) { table(:projects) }
- let(:issues) { table(:issues) }
- let(:issuable_escalation_statuses) { table(:incident_management_issuable_escalation_statuses) }
-
- subject(:migration) { described_class.new }
-
- it 'correctly backfills issuable escalation status records' do
- namespace = namespaces.create!(name: 'foo', path: 'foo')
- project = projects.create!(namespace_id: namespace.id)
-
- issues.create!(project_id: project.id, title: 'issue 1', issue_type: 0) # non-incident issue
- issues.create!(project_id: project.id, title: 'incident 1', issue_type: 1)
- issues.create!(project_id: project.id, title: 'incident 2', issue_type: 1)
- incident_issue_existing_status = issues.create!(project_id: project.id, title: 'incident 3', issue_type: 1)
- issuable_escalation_statuses.create!(issue_id: incident_issue_existing_status.id)
-
- migration.perform(1, incident_issue_existing_status.id)
-
- expect(issuable_escalation_statuses.count).to eq(3)
- end
-end
diff --git a/spec/lib/gitlab/background_migration/backfill_issue_search_data_spec.rb b/spec/lib/gitlab/background_migration/backfill_issue_search_data_spec.rb
index b29d4c3583b..f98aea2dda7 100644
--- a/spec/lib/gitlab/background_migration/backfill_issue_search_data_spec.rb
+++ b/spec/lib/gitlab/background_migration/backfill_issue_search_data_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::BackgroundMigration::BackfillIssueSearchData do
+RSpec.describe Gitlab::BackgroundMigration::BackfillIssueSearchData, :migration, schema: 20220326161803 do
let(:namespaces_table) { table(:namespaces) }
let(:projects_table) { table(:projects) }
let(:issue_search_data_table) { table(:issue_search_data) }
diff --git a/spec/lib/gitlab/background_migration/backfill_namespace_id_for_project_route_spec.rb b/spec/lib/gitlab/background_migration/backfill_namespace_id_for_project_route_spec.rb
new file mode 100644
index 00000000000..2dcd4645c84
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/backfill_namespace_id_for_project_route_spec.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::BackfillNamespaceIdForProjectRoute do
+ let(:migration) { described_class.new }
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+ let(:routes) { table(:routes) }
+
+ let(:namespace1) { namespaces.create!(name: 'batchtest1', type: 'Group', path: 'space1') }
+ let(:namespace2) { namespaces.create!(name: 'batchtest2', type: 'Group', parent_id: namespace1.id, path: 'space2') }
+ let(:namespace3) { namespaces.create!(name: 'batchtest3', type: 'Group', parent_id: namespace2.id, path: 'space3') }
+
+ let(:proj_namespace1) { namespaces.create!(name: 'proj1', path: 'proj1', type: 'Project', parent_id: namespace1.id) }
+ let(:proj_namespace2) { namespaces.create!(name: 'proj2', path: 'proj2', type: 'Project', parent_id: namespace2.id) }
+ let(:proj_namespace3) { namespaces.create!(name: 'proj3', path: 'proj3', type: 'Project', parent_id: namespace3.id) }
+ let(:proj_namespace4) { namespaces.create!(name: 'proj4', path: 'proj4', type: 'Project', parent_id: namespace3.id) }
+
+ # rubocop:disable Layout/LineLength
+ let(:proj1) { projects.create!(name: 'proj1', path: 'proj1', namespace_id: namespace1.id, project_namespace_id: proj_namespace1.id) }
+ let(:proj2) { projects.create!(name: 'proj2', path: 'proj2', namespace_id: namespace2.id, project_namespace_id: proj_namespace2.id) }
+ let(:proj3) { projects.create!(name: 'proj3', path: 'proj3', namespace_id: namespace3.id, project_namespace_id: proj_namespace3.id) }
+ let(:proj4) { projects.create!(name: 'proj4', path: 'proj4', namespace_id: namespace3.id, project_namespace_id: proj_namespace4.id) }
+ # rubocop:enable Layout/LineLength
+
+ let!(:namespace_route1) { routes.create!(path: 'space1', source_id: namespace1.id, source_type: 'Namespace') }
+ let!(:namespace_route2) { routes.create!(path: 'space1/space2', source_id: namespace2.id, source_type: 'Namespace') }
+ let!(:namespace_route3) { routes.create!(path: 'space1/space3', source_id: namespace3.id, source_type: 'Namespace') }
+
+ let!(:proj_route1) { routes.create!(path: 'space1/proj1', source_id: proj1.id, source_type: 'Project') }
+ let!(:proj_route2) { routes.create!(path: 'space1/space2/proj2', source_id: proj2.id, source_type: 'Project') }
+ let!(:proj_route3) { routes.create!(path: 'space1/space3/proj3', source_id: proj3.id, source_type: 'Project') }
+ let!(:proj_route4) { routes.create!(path: 'space1/space3/proj4', source_id: proj4.id, source_type: 'Project') }
+
+ subject(:perform_migration) { migration.perform(proj_route1.id, proj_route4.id, :routes, :id, 2, 0) }
+
+ it 'backfills namespace_id for the selected records', :aggregate_failures do
+ perform_migration
+
+ expected_namespaces = [proj_namespace1.id, proj_namespace2.id, proj_namespace3.id, proj_namespace4.id]
+
+ expected_projects = [proj_route1.id, proj_route2.id, proj_route3.id, proj_route4.id]
+ expect(routes.where.not(namespace_id: nil).pluck(:id)).to match_array(expected_projects)
+ expect(routes.where.not(namespace_id: nil).pluck(:namespace_id)).to match_array(expected_namespaces)
+ end
+
+ it 'tracks timings of queries' do
+ expect(migration.batch_metrics.timings).to be_empty
+
+ expect { perform_migration }.to change { migration.batch_metrics.timings }
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/backfill_work_item_type_id_for_issues_spec.rb b/spec/lib/gitlab/background_migration/backfill_work_item_type_id_for_issues_spec.rb
new file mode 100644
index 00000000000..8d82c533d20
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/backfill_work_item_type_id_for_issues_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::BackfillWorkItemTypeIdForIssues, :migration, schema: 20220326161803 do
+ subject(:migrate) { migration.perform(start_id, end_id, batch_table, batch_column, sub_batch_size, pause_ms, issue_type_enum[:issue], issue_type.id) }
+
+ let(:migration) { described_class.new }
+
+ let(:batch_table) { 'issues' }
+ let(:batch_column) { 'id' }
+ let(:sub_batch_size) { 2 }
+ let(:pause_ms) { 0 }
+
+ # let_it_be can't be used in migration specs because all tables but `work_item_types` are deleted after each spec
+ let(:issue_type_enum) { { issue: 0, incident: 1, test_case: 2, requirement: 3, task: 4 } }
+ let(:namespace) { table(:namespaces).create!(name: 'namespace', path: 'namespace') }
+ let(:project) { table(:projects).create!(namespace_id: namespace.id) }
+ let(:issues_table) { table(:issues) }
+ let(:issue_type) { table(:work_item_types).find_by!(namespace_id: nil, base_type: issue_type_enum[:issue]) }
+
+ let(:issue1) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:issue]) }
+ let(:issue2) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:issue]) }
+ let(:issue3) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:issue]) }
+ let(:incident1) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:incident]) }
+ # test_case and requirement are EE only, but enum values exist on the FOSS model
+ let(:test_case1) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:test_case]) }
+ let(:requirement1) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:requirement]) }
+
+ let(:start_id) { issue1.id }
+ let(:end_id) { requirement1.id }
+
+ let(:all_issues) { [issue1, issue2, issue3, incident1, test_case1, requirement1] }
+
+ it 'sets work_item_type_id only for the given type' do
+ expect(all_issues).to all(have_attributes(work_item_type_id: nil))
+
+ expect { migrate }.to make_queries_matching(/UPDATE \"issues\" SET "work_item_type_id"/, 2)
+ all_issues.each(&:reload)
+
+ expect([issue1, issue2, issue3]).to all(have_attributes(work_item_type_id: issue_type.id))
+ expect(all_issues - [issue1, issue2, issue3]).to all(have_attributes(work_item_type_id: nil))
+ end
+
+ it 'tracks timings of queries' do
+ expect(migration.batch_metrics.timings).to be_empty
+
+ expect { migrate }.to change { migration.batch_metrics.timings }
+ end
+
+ context 'when database timeouts' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(error_class: [ActiveRecord::StatementTimeout, ActiveRecord::QueryCanceled])
+
+ with_them do
+ it 'retries on timeout error' do
+ expect(migration).to receive(:update_batch).exactly(3).times.and_raise(error_class)
+ expect(migration).to receive(:sleep).with(30).twice
+
+ expect do
+ migrate
+ end.to raise_error(error_class)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/batching_strategies/backfill_issue_work_item_type_batching_strategy_spec.rb b/spec/lib/gitlab/background_migration/batching_strategies/backfill_issue_work_item_type_batching_strategy_spec.rb
new file mode 100644
index 00000000000..3cba99bfe51
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/batching_strategies/backfill_issue_work_item_type_batching_strategy_spec.rb
@@ -0,0 +1,135 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::BatchingStrategies::BackfillIssueWorkItemTypeBatchingStrategy, '#next_batch', schema: 20220326161803 do # rubocop:disable Layout/LineLength
+ # let! can't be used in migration specs because all tables but `work_item_types` are deleted after each spec
+ let!(:issue_type_enum) { { issue: 0, incident: 1, test_case: 2, requirement: 3, task: 4 } }
+ let!(:namespace) { table(:namespaces).create!(name: 'namespace', path: 'namespace') }
+ let!(:project) { table(:projects).create!(namespace_id: namespace.id) }
+ let!(:issues_table) { table(:issues) }
+ let!(:task_type) { table(:work_item_types).find_by!(namespace_id: nil, base_type: issue_type_enum[:task]) }
+
+ let!(:issue1) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:issue]) }
+ let!(:task1) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:task]) }
+ let!(:issue2) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:issue]) }
+ let!(:issue3) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:issue]) }
+ let!(:task2) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:task]) }
+ let!(:incident1) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:incident]) }
+ # test_case is EE only, but enum values exist on the FOSS model
+ let!(:test_case1) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:test_case]) }
+
+ let!(:task3) do
+ issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:task], work_item_type_id: task_type.id)
+ end
+
+ let!(:task4) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:task]) }
+
+ let!(:batching_strategy) { described_class.new(connection: ActiveRecord::Base.connection) }
+
+ context 'when issue_type is issue' do
+ let(:job_arguments) { [issue_type_enum[:issue], 'irrelevant_work_item_id'] }
+
+ context 'when starting on the first batch' do
+ it 'returns the bounds of the next batch' do
+ batch_bounds = next_batch(issue1.id, 2)
+
+ expect(batch_bounds).to match_array([issue1.id, issue2.id])
+ end
+ end
+
+ context 'when additional batches remain' do
+ it 'returns the bounds of the next batch' do
+ batch_bounds = next_batch(issue2.id, 2)
+
+ expect(batch_bounds).to match_array([issue2.id, issue3.id])
+ end
+ end
+
+ context 'when on the final batch' do
+ it 'returns the bounds of the next batch' do
+ batch_bounds = next_batch(issue3.id, 2)
+
+ expect(batch_bounds).to match_array([issue3.id, issue3.id])
+ end
+ end
+
+ context 'when no additional batches remain' do
+ it 'returns nil' do
+ batch_bounds = next_batch(issue3.id + 1, 1)
+
+ expect(batch_bounds).to be_nil
+ end
+ end
+ end
+
+ context 'when issue_type is incident' do
+ let(:job_arguments) { [issue_type_enum[:incident], 'irrelevant_work_item_id'] }
+
+ context 'when starting on the first batch' do
+ it 'returns the bounds of the next batch with only one element' do
+ batch_bounds = next_batch(incident1.id, 2)
+
+ expect(batch_bounds).to match_array([incident1.id, incident1.id])
+ end
+ end
+ end
+
+ context 'when issue_type is requirement and there are no matching records' do
+ let(:job_arguments) { [issue_type_enum[:requirement], 'irrelevant_work_item_id'] }
+
+ context 'when starting on the first batch' do
+ it 'returns nil' do
+ batch_bounds = next_batch(1, 2)
+
+ expect(batch_bounds).to be_nil
+ end
+ end
+ end
+
+ context 'when issue_type is task' do
+ let(:job_arguments) { [issue_type_enum[:task], 'irrelevant_work_item_id'] }
+
+ context 'when starting on the first batch' do
+ it 'returns the bounds of the next batch' do
+ batch_bounds = next_batch(task1.id, 2)
+
+ expect(batch_bounds).to match_array([task1.id, task2.id])
+ end
+ end
+
+ context 'when additional batches remain' do
+ it 'returns the bounds of the next batch, does not skip records where FK is already set' do
+ batch_bounds = next_batch(task2.id, 2)
+
+ expect(batch_bounds).to match_array([task2.id, task3.id])
+ end
+ end
+
+ context 'when on the final batch' do
+ it 'returns the bounds of the next batch' do
+ batch_bounds = next_batch(task4.id, 2)
+
+ expect(batch_bounds).to match_array([task4.id, task4.id])
+ end
+ end
+
+ context 'when no additional batches remain' do
+ it 'returns nil' do
+ batch_bounds = next_batch(task4.id + 1, 1)
+
+ expect(batch_bounds).to be_nil
+ end
+ end
+ end
+
+ def next_batch(min_value, batch_size)
+ batching_strategy.next_batch(
+ :issues,
+ :id,
+ batch_min_value: min_value,
+ batch_size: batch_size,
+ job_arguments: job_arguments
+ )
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/batching_strategies/backfill_project_namespace_per_group_batching_strategy_spec.rb b/spec/lib/gitlab/background_migration/batching_strategies/backfill_project_namespace_per_group_batching_strategy_spec.rb
index b01dd5b410e..dc0935efa94 100644
--- a/spec/lib/gitlab/background_migration/batching_strategies/backfill_project_namespace_per_group_batching_strategy_spec.rb
+++ b/spec/lib/gitlab/background_migration/batching_strategies/backfill_project_namespace_per_group_batching_strategy_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::BackgroundMigration::BatchingStrategies::BackfillProjectNamespacePerGroupBatchingStrategy, '#next_batch' do
+RSpec.describe Gitlab::BackgroundMigration::BatchingStrategies::BackfillProjectNamespacePerGroupBatchingStrategy, '#next_batch', :migration, schema: 20220326161803 do
let!(:namespaces) { table(:namespaces) }
let!(:projects) { table(:projects) }
let!(:background_migrations) { table(:batched_background_migrations) }
diff --git a/spec/lib/gitlab/background_migration/batching_strategies/primary_key_batching_strategy_spec.rb b/spec/lib/gitlab/background_migration/batching_strategies/primary_key_batching_strategy_spec.rb
index 4e0ebd4b692..521e2067744 100644
--- a/spec/lib/gitlab/background_migration/batching_strategies/primary_key_batching_strategy_spec.rb
+++ b/spec/lib/gitlab/background_migration/batching_strategies/primary_key_batching_strategy_spec.rb
@@ -15,7 +15,7 @@ RSpec.describe Gitlab::BackgroundMigration::BatchingStrategies::PrimaryKeyBatchi
context 'when starting on the first batch' do
it 'returns the bounds of the next batch' do
- batch_bounds = batching_strategy.next_batch(:namespaces, :id, batch_min_value: namespace1.id, batch_size: 3, job_arguments: nil)
+ batch_bounds = batching_strategy.next_batch(:namespaces, :id, batch_min_value: namespace1.id, batch_size: 3, job_arguments: [])
expect(batch_bounds).to eq([namespace1.id, namespace3.id])
end
@@ -23,7 +23,7 @@ RSpec.describe Gitlab::BackgroundMigration::BatchingStrategies::PrimaryKeyBatchi
context 'when additional batches remain' do
it 'returns the bounds of the next batch' do
- batch_bounds = batching_strategy.next_batch(:namespaces, :id, batch_min_value: namespace2.id, batch_size: 3, job_arguments: nil)
+ batch_bounds = batching_strategy.next_batch(:namespaces, :id, batch_min_value: namespace2.id, batch_size: 3, job_arguments: [])
expect(batch_bounds).to eq([namespace2.id, namespace4.id])
end
@@ -31,7 +31,7 @@ RSpec.describe Gitlab::BackgroundMigration::BatchingStrategies::PrimaryKeyBatchi
context 'when on the final batch' do
it 'returns the bounds of the next batch' do
- batch_bounds = batching_strategy.next_batch(:namespaces, :id, batch_min_value: namespace4.id, batch_size: 3, job_arguments: nil)
+ batch_bounds = batching_strategy.next_batch(:namespaces, :id, batch_min_value: namespace4.id, batch_size: 3, job_arguments: [])
expect(batch_bounds).to eq([namespace4.id, namespace4.id])
end
@@ -39,9 +39,30 @@ RSpec.describe Gitlab::BackgroundMigration::BatchingStrategies::PrimaryKeyBatchi
context 'when no additional batches remain' do
it 'returns nil' do
- batch_bounds = batching_strategy.next_batch(:namespaces, :id, batch_min_value: namespace4.id + 1, batch_size: 1, job_arguments: nil)
+ batch_bounds = batching_strategy.next_batch(:namespaces, :id, batch_min_value: namespace4.id + 1, batch_size: 1, job_arguments: [])
expect(batch_bounds).to be_nil
end
end
+
+ context 'additional filters' do
+ let(:strategy_with_filters) do
+ Class.new(described_class) do
+ def apply_additional_filters(relation, job_arguments:)
+ min_id = job_arguments.first
+
+ relation.where.not(type: 'Project').where('id >= ?', min_id)
+ end
+ end
+ end
+
+ let(:batching_strategy) { strategy_with_filters.new(connection: ActiveRecord::Base.connection) }
+ let!(:namespace5) { namespaces.create!(name: 'batchtest5', path: 'batch-test5', type: 'Project') }
+
+ it 'applies additional filters' do
+ batch_bounds = batching_strategy.next_batch(:namespaces, :id, batch_min_value: namespace4.id, batch_size: 3, job_arguments: [1])
+
+ expect(batch_bounds).to eq([namespace4.id, namespace4.id])
+ end
+ end
end
diff --git a/spec/lib/gitlab/background_migration/cleanup_draft_data_from_faulty_regex_spec.rb b/spec/lib/gitlab/background_migration/cleanup_draft_data_from_faulty_regex_spec.rb
new file mode 100644
index 00000000000..d1ef7ca2188
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/cleanup_draft_data_from_faulty_regex_spec.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::CleanupDraftDataFromFaultyRegex, :migration, schema: 20220326161803 do
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+ let(:merge_requests) { table(:merge_requests) }
+
+ let(:group) { namespaces.create!(name: 'gitlab', path: 'gitlab') }
+ let(:project) { projects.create!(namespace_id: group.id) }
+
+ let(:draft_prefixes) { ["[Draft]", "(Draft)", "Draft:", "Draft", "[WIP]", "WIP:", "WIP"] }
+
+ def create_merge_request(params)
+ common_params = {
+ target_project_id: project.id,
+ target_branch: 'feature1',
+ source_branch: 'master'
+ }
+
+ merge_requests.create!(common_params.merge(params))
+ end
+
+ context "mr.draft == true, and title matches the leaky regex and not the corrected regex" do
+ let(:mr_ids) { merge_requests.all.collect(&:id) }
+
+ before do
+ draft_prefixes.each do |prefix|
+ (1..4).each do |n|
+ create_merge_request(
+ title: "#{prefix} This is a title",
+ draft: true,
+ state_id: 1
+ )
+ end
+ end
+
+ create_merge_request(title: "This has draft in the title", draft: true, state_id: 1)
+ end
+
+ it "updates all open draft merge request's draft field to true" do
+ expect { subject.perform(mr_ids.first, mr_ids.last) }
+ .to change { MergeRequest.where(draft: true).count }
+ .by(-1)
+ end
+
+ it "marks successful slices as completed" do
+ expect(subject).to receive(:mark_job_as_succeeded).with(mr_ids.first, mr_ids.last)
+
+ subject.perform(mr_ids.first, mr_ids.last)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/disable_expiration_policies_linked_to_no_container_images_spec.rb b/spec/lib/gitlab/background_migration/disable_expiration_policies_linked_to_no_container_images_spec.rb
index 04eb9ad475f..8a63673bf38 100644
--- a/spec/lib/gitlab/background_migration/disable_expiration_policies_linked_to_no_container_images_spec.rb
+++ b/spec/lib/gitlab/background_migration/disable_expiration_policies_linked_to_no_container_images_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::BackgroundMigration::DisableExpirationPoliciesLinkedToNoContainerImages do
+RSpec.describe Gitlab::BackgroundMigration::DisableExpirationPoliciesLinkedToNoContainerImages, :migration, schema: 20220326161803 do # rubocop:disable Layout/LineLength
let_it_be(:projects) { table(:projects) }
let_it_be(:container_expiration_policies) { table(:container_expiration_policies) }
let_it_be(:container_repositories) { table(:container_repositories) }
diff --git a/spec/lib/gitlab/background_migration/encrypt_static_object_token_spec.rb b/spec/lib/gitlab/background_migration/encrypt_static_object_token_spec.rb
index 94d9f4509a7..4e7b97d33f6 100644
--- a/spec/lib/gitlab/background_migration/encrypt_static_object_token_spec.rb
+++ b/spec/lib/gitlab/background_migration/encrypt_static_object_token_spec.rb
@@ -39,6 +39,14 @@ RSpec.describe Gitlab::BackgroundMigration::EncryptStaticObjectToken do
expect(new_state[user_with_encrypted_token.id]).to match_array([nil, 'encrypted'])
end
+ context 'when id range does not include existing user ids' do
+ let(:arguments) { [non_existing_record_id, non_existing_record_id.succ] }
+
+ it_behaves_like 'marks background migration job records' do
+ subject { described_class.new }
+ end
+ end
+
private
def create_user!(name:, token: nil, encrypted_token: nil)
diff --git a/spec/lib/gitlab/background_migration/fix_duplicate_project_name_and_path_spec.rb b/spec/lib/gitlab/background_migration/fix_duplicate_project_name_and_path_spec.rb
new file mode 100644
index 00000000000..65663d26f37
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/fix_duplicate_project_name_and_path_spec.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::FixDuplicateProjectNameAndPath, :migration, schema: 20220325155953 do
+ let(:migration) { described_class.new }
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+ let(:routes) { table(:routes) }
+
+ let(:namespace1) { namespaces.create!(name: 'batchtest1', type: 'Group', path: 'batch-test1') }
+ let(:namespace2) { namespaces.create!(name: 'batchtest2', type: 'Group', parent_id: namespace1.id, path: 'batch-test2') }
+ let(:namespace3) { namespaces.create!(name: 'batchtest3', type: 'Group', parent_id: namespace2.id, path: 'batch-test3') }
+
+ let(:project_namespace2) { namespaces.create!(name: 'project2', path: 'project2', type: 'Project', parent_id: namespace2.id, visibility_level: 20) }
+ let(:project_namespace3) { namespaces.create!(name: 'project3', path: 'project3', type: 'Project', parent_id: namespace3.id, visibility_level: 20) }
+
+ let(:project1) { projects.create!(name: 'project1', path: 'project1', namespace_id: namespace1.id, visibility_level: 20) }
+ let(:project2) { projects.create!(name: 'project2', path: 'project2', namespace_id: namespace2.id, project_namespace_id: project_namespace2.id, visibility_level: 20) }
+ let(:project2_dup) { projects.create!(name: 'project2', path: 'project2', namespace_id: namespace2.id, visibility_level: 20) }
+ let(:project3) { projects.create!(name: 'project3', path: 'project3', namespace_id: namespace3.id, project_namespace_id: project_namespace3.id, visibility_level: 20) }
+ let(:project3_dup) { projects.create!(name: 'project3', path: 'project3', namespace_id: namespace3.id, visibility_level: 20) }
+
+ let!(:namespace_route1) { routes.create!(path: 'batch-test1', source_id: namespace1.id, source_type: 'Namespace') }
+ let!(:namespace_route2) { routes.create!(path: 'batch-test1/batch-test2', source_id: namespace2.id, source_type: 'Namespace') }
+ let!(:namespace_route3) { routes.create!(path: 'batch-test1/batch-test3', source_id: namespace3.id, source_type: 'Namespace') }
+
+ let!(:proj_route1) { routes.create!(path: 'batch-test1/project1', source_id: project1.id, source_type: 'Project') }
+ let!(:proj_route2) { routes.create!(path: 'batch-test1/batch-test2/project2', source_id: project2.id, source_type: 'Project') }
+ let!(:proj_route2_dup) { routes.create!(path: "batch-test1/batch-test2/project2-route-#{project2_dup.id}", source_id: project2_dup.id, source_type: 'Project') }
+ let!(:proj_route3) { routes.create!(path: 'batch-test1/batch-test3/project3', source_id: project3.id, source_type: 'Project') }
+ let!(:proj_route3_dup) { routes.create!(path: "batch-test1/batch-test3/project3-route-#{project3_dup.id}", source_id: project3_dup.id, source_type: 'Project') }
+
+ subject(:perform_migration) { migration.perform(projects.minimum(:id), projects.maximum(:id)) }
+
+ describe '#up' do
+ it 'backfills namespace_id for the selected records', :aggregate_failures do
+ expect(namespaces.where(type: 'Project').count).to eq(2)
+
+ perform_migration
+
+ expect(namespaces.where(type: 'Project').count).to eq(5)
+
+ expect(project1.reload.name).to eq("project1-#{project1.id}")
+ expect(project1.path).to eq('project1')
+
+ expect(project2.reload.name).to eq('project2')
+ expect(project2.path).to eq('project2')
+
+ expect(project2_dup.reload.name).to eq("project2-#{project2_dup.id}")
+ expect(project2_dup.path).to eq("project2-route-#{project2_dup.id}")
+
+ expect(project3.reload.name).to eq("project3")
+ expect(project3.path).to eq("project3")
+
+ expect(project3_dup.reload.name).to eq("project3-#{project3_dup.id}")
+ expect(project3_dup.path).to eq("project3-route-#{project3_dup.id}")
+
+ projects.all.each do |pr|
+ project_namespace = namespaces.find(pr.project_namespace_id)
+ expect(project_namespace).to be_in_sync_with_project(pr)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/merge_topics_with_same_name_spec.rb b/spec/lib/gitlab/background_migration/merge_topics_with_same_name_spec.rb
new file mode 100644
index 00000000000..254b4fea698
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/merge_topics_with_same_name_spec.rb
@@ -0,0 +1,135 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::MergeTopicsWithSameName, schema: 20220223124428 do
+ def set_avatar(topic_id, avatar)
+ topic = ::Projects::Topic.find(topic_id)
+ topic.avatar = avatar
+ topic.save!
+ topic.avatar.absolute_path
+ end
+
+ it 'merges project topics with same case insensitive name' do
+ namespaces = table(:namespaces)
+ projects = table(:projects)
+ topics = table(:topics)
+ project_topics = table(:project_topics)
+
+ group = namespaces.create!(name: 'group', path: 'group')
+ project_1 = projects.create!(namespace_id: group.id, visibility_level: 20)
+ project_2 = projects.create!(namespace_id: group.id, visibility_level: 10)
+ project_3 = projects.create!(namespace_id: group.id, visibility_level: 0)
+ topic_1_keep = topics.create!(
+ name: 'topic1',
+ description: 'description 1 to keep',
+ total_projects_count: 2,
+ non_private_projects_count: 2
+ )
+ topic_1_remove = topics.create!(
+ name: 'TOPIC1',
+ description: 'description 1 to remove',
+ total_projects_count: 2,
+ non_private_projects_count: 1
+ )
+ topic_2_remove = topics.create!(
+ name: 'topic2',
+ total_projects_count: 0
+ )
+ topic_2_keep = topics.create!(
+ name: 'TOPIC2',
+ description: 'description 2 to keep',
+ total_projects_count: 1
+ )
+ topic_3_remove_1 = topics.create!(
+ name: 'topic3',
+ total_projects_count: 2,
+ non_private_projects_count: 1
+ )
+ topic_3_keep = topics.create!(
+ name: 'Topic3',
+ total_projects_count: 2,
+ non_private_projects_count: 2
+ )
+ topic_3_remove_2 = topics.create!(
+ name: 'TOPIC3',
+ description: 'description 3 to keep',
+ total_projects_count: 2,
+ non_private_projects_count: 1
+ )
+ topic_4_keep = topics.create!(
+ name: 'topic4'
+ )
+
+ project_topics_1 = []
+ project_topics_3 = []
+ project_topics_removed = []
+
+ project_topics_1 << project_topics.create!(topic_id: topic_1_keep.id, project_id: project_1.id)
+ project_topics_1 << project_topics.create!(topic_id: topic_1_keep.id, project_id: project_2.id)
+ project_topics_removed << project_topics.create!(topic_id: topic_1_remove.id, project_id: project_2.id)
+ project_topics_1 << project_topics.create!(topic_id: topic_1_remove.id, project_id: project_3.id)
+
+ project_topics_3 << project_topics.create!(topic_id: topic_3_keep.id, project_id: project_1.id)
+ project_topics_3 << project_topics.create!(topic_id: topic_3_keep.id, project_id: project_2.id)
+ project_topics_removed << project_topics.create!(topic_id: topic_3_remove_1.id, project_id: project_1.id)
+ project_topics_3 << project_topics.create!(topic_id: topic_3_remove_1.id, project_id: project_3.id)
+ project_topics_removed << project_topics.create!(topic_id: topic_3_remove_2.id, project_id: project_1.id)
+ project_topics_removed << project_topics.create!(topic_id: topic_3_remove_2.id, project_id: project_3.id)
+
+ avatar_paths = {
+ topic_1_keep: set_avatar(topic_1_keep.id, fixture_file_upload('spec/fixtures/avatars/avatar1.png')),
+ topic_1_remove: set_avatar(topic_1_remove.id, fixture_file_upload('spec/fixtures/avatars/avatar2.png')),
+ topic_2_remove: set_avatar(topic_2_remove.id, fixture_file_upload('spec/fixtures/avatars/avatar3.png')),
+ topic_3_remove_1: set_avatar(topic_3_remove_1.id, fixture_file_upload('spec/fixtures/avatars/avatar4.png')),
+ topic_3_remove_2: set_avatar(topic_3_remove_2.id, fixture_file_upload('spec/fixtures/avatars/avatar5.png'))
+ }
+
+ subject.perform(%w[topic1 topic2 topic3 topic4])
+
+ # Topics
+ [topic_1_keep, topic_2_keep, topic_3_keep, topic_4_keep].each(&:reload)
+ expect(topic_1_keep.name).to eq('topic1')
+ expect(topic_1_keep.description).to eq('description 1 to keep')
+ expect(topic_1_keep.total_projects_count).to eq(3)
+ expect(topic_1_keep.non_private_projects_count).to eq(2)
+ expect(topic_2_keep.name).to eq('TOPIC2')
+ expect(topic_2_keep.description).to eq('description 2 to keep')
+ expect(topic_2_keep.total_projects_count).to eq(0)
+ expect(topic_2_keep.non_private_projects_count).to eq(0)
+ expect(topic_3_keep.name).to eq('Topic3')
+ expect(topic_3_keep.description).to eq('description 3 to keep')
+ expect(topic_3_keep.total_projects_count).to eq(3)
+ expect(topic_3_keep.non_private_projects_count).to eq(2)
+ expect(topic_4_keep.reload.name).to eq('topic4')
+
+ [topic_1_remove, topic_2_remove, topic_3_remove_1, topic_3_remove_2].each do |topic|
+ expect { topic.reload }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+
+ # Topic avatars
+ expect(topic_1_keep.avatar).to eq('avatar1.png')
+ expect(File.exist?(::Projects::Topic.find(topic_1_keep.id).avatar.absolute_path)).to be_truthy
+ expect(topic_2_keep.avatar).to eq('avatar3.png')
+ expect(File.exist?(::Projects::Topic.find(topic_2_keep.id).avatar.absolute_path)).to be_truthy
+ expect(topic_3_keep.avatar).to eq('avatar4.png')
+ expect(File.exist?(::Projects::Topic.find(topic_3_keep.id).avatar.absolute_path)).to be_truthy
+
+ [:topic_1_remove, :topic_2_remove, :topic_3_remove_1, :topic_3_remove_2].each do |topic|
+ expect(File.exist?(avatar_paths[topic])).to be_falsey
+ end
+
+ # Project Topic assignments
+ project_topics_1.each do |project_topic|
+ expect(project_topic.reload.topic_id).to eq(topic_1_keep.id)
+ end
+
+ project_topics_3.each do |project_topic|
+ expect(project_topic.reload.topic_id).to eq(topic_3_keep.id)
+ end
+
+ project_topics_removed.each do |project_topic|
+ expect { project_topic.reload }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/migrate_shimo_confluence_integration_category_spec.rb b/spec/lib/gitlab/background_migration/migrate_shimo_confluence_integration_category_spec.rb
new file mode 100644
index 00000000000..8bc6bb8ae0a
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/migrate_shimo_confluence_integration_category_spec.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::MigrateShimoConfluenceIntegrationCategory, schema: 20220326161803 do
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+ let(:integrations) { table(:integrations) }
+ let(:perform) { described_class.new.perform(1, 5) }
+
+ before do
+ namespace = namespaces.create!(name: 'test', path: 'test')
+ projects.create!(id: 1, namespace_id: namespace.id, name: 'gitlab', path: 'gitlab')
+ integrations.create!(id: 1, active: true, type_new: "Integrations::SlackSlashCommands",
+ category: 'chat', project_id: 1)
+ integrations.create!(id: 3, active: true, type_new: "Integrations::Confluence", category: 'common', project_id: 1)
+ integrations.create!(id: 5, active: true, type_new: "Integrations::Shimo", category: 'common', project_id: 1)
+ end
+
+ describe '#up' do
+ it 'updates category to third_party_wiki for Shimo and Confluence' do
+ perform
+
+ expect(integrations.where(category: 'third_party_wiki').count).to eq(2)
+ expect(integrations.where(category: 'chat').count).to eq(1)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/populate_container_repository_migration_plan_spec.rb b/spec/lib/gitlab/background_migration/populate_container_repository_migration_plan_spec.rb
new file mode 100644
index 00000000000..0463f5a0c0d
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/populate_container_repository_migration_plan_spec.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::PopulateContainerRepositoryMigrationPlan, schema: 20220316202640 do
+ let_it_be(:container_repositories) { table(:container_repositories) }
+ let_it_be(:projects) { table(:projects) }
+ let_it_be(:namespaces) { table(:namespaces) }
+ let_it_be(:gitlab_subscriptions) { table(:gitlab_subscriptions) }
+ let_it_be(:plans) { table(:plans) }
+ let_it_be(:namespace_statistics) { table(:namespace_statistics) }
+
+ let!(:namepace1) { namespaces.create!(id: 1, type: 'Group', name: 'group1', path: 'group1', traversal_ids: [1]) }
+ let!(:namepace2) { namespaces.create!(id: 2, type: 'Group', name: 'group2', path: 'group2', traversal_ids: [2]) }
+ let!(:namepace3) { namespaces.create!(id: 3, type: 'Group', name: 'group3', path: 'group3', traversal_ids: [3]) }
+ let!(:sub_namespace) { namespaces.create!(id: 4, type: 'Group', name: 'group3', path: 'group3', parent_id: 1, traversal_ids: [1, 4]) }
+ let!(:plan1) { plans.create!(id: 1, name: 'plan1') }
+ let!(:plan2) { plans.create!(id: 2, name: 'plan2') }
+ let!(:gitlab_subscription1) { gitlab_subscriptions.create!(id: 1, namespace_id: 1, hosted_plan_id: 1) }
+ let!(:gitlab_subscription2) { gitlab_subscriptions.create!(id: 2, namespace_id: 2, hosted_plan_id: 2) }
+ let!(:project1) { projects.create!(id: 1, name: 'project1', path: 'project1', namespace_id: 4) }
+ let!(:project2) { projects.create!(id: 2, name: 'project2', path: 'project2', namespace_id: 2) }
+ let!(:project3) { projects.create!(id: 3, name: 'project3', path: 'project3', namespace_id: 3) }
+ let!(:container_repository1) { container_repositories.create!(id: 1, name: 'cr1', project_id: 1) }
+ let!(:container_repository2) { container_repositories.create!(id: 2, name: 'cr2', project_id: 2) }
+ let!(:container_repository3) { container_repositories.create!(id: 3, name: 'cr3', project_id: 3) }
+
+ let(:migration) { described_class.new }
+
+ subject do
+ migration.perform(1, 4)
+ end
+
+ it 'updates the migration_plan to match the actual plan', :aggregate_failures do
+ expect(Gitlab::Database::BackgroundMigrationJob).to receive(:mark_all_as_succeeded)
+ .with('PopulateContainerRepositoryMigrationPlan', [1, 4]).and_return(true)
+
+ subject
+
+ expect(container_repository1.reload.migration_plan).to eq('plan1')
+ expect(container_repository2.reload.migration_plan).to eq('plan2')
+ expect(container_repository3.reload.migration_plan).to eq(nil)
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/populate_namespace_statistics_spec.rb b/spec/lib/gitlab/background_migration/populate_namespace_statistics_spec.rb
new file mode 100644
index 00000000000..98b2bc437f3
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/populate_namespace_statistics_spec.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::PopulateNamespaceStatistics do
+ let_it_be(:namespaces) { table(:namespaces) }
+ let_it_be(:namespace_statistics) { table(:namespace_statistics) }
+ let_it_be(:dependency_proxy_manifests) { table(:dependency_proxy_manifests) }
+ let_it_be(:dependency_proxy_blobs) { table(:dependency_proxy_blobs) }
+
+ let!(:group1) { namespaces.create!(id: 10, type: 'Group', name: 'group1', path: 'group1') }
+ let!(:group2) { namespaces.create!(id: 20, type: 'Group', name: 'group2', path: 'group2') }
+
+ let!(:group1_manifest) do
+ dependency_proxy_manifests.create!(group_id: 10, size: 20, file_name: 'test-file', file: 'test', digest: 'abc123')
+ end
+
+ let!(:group2_manifest) do
+ dependency_proxy_manifests.create!(group_id: 20, size: 20, file_name: 'test-file', file: 'test', digest: 'abc123')
+ end
+
+ let!(:group1_stats) { namespace_statistics.create!(id: 10, namespace_id: 10) }
+
+ let(:ids) { namespaces.pluck(:id) }
+ let(:statistics) { [] }
+
+ subject(:perform) { described_class.new.perform(ids, statistics) }
+
+ it 'creates/updates all namespace_statistics and updates root storage statistics', :aggregate_failures do
+ expect(Namespaces::ScheduleAggregationWorker).to receive(:perform_async).with(group1.id)
+ expect(Namespaces::ScheduleAggregationWorker).to receive(:perform_async).with(group2.id)
+
+ expect { perform }.to change(namespace_statistics, :count).from(1).to(2)
+
+ namespace_statistics.all.each do |stat|
+ expect(stat.dependency_proxy_size).to eq 20
+ expect(stat.storage_size).to eq 20
+ end
+ end
+
+ context 'when just a stat is passed' do
+ let(:statistics) { [:dependency_proxy_size] }
+
+ it 'calls the statistics update service with just that stat' do
+ expect(Groups::UpdateStatisticsService)
+ .to receive(:new)
+ .with(anything, statistics: [:dependency_proxy_size])
+ .twice.and_call_original
+
+ perform
+ end
+ end
+
+ context 'when a statistics update fails' do
+ before do
+ error_response = instance_double(ServiceResponse, message: 'an error', error?: true)
+
+ allow_next_instance_of(Groups::UpdateStatisticsService) do |instance|
+ allow(instance).to receive(:execute).and_return(error_response)
+ end
+ end
+
+ it 'logs an error' do
+ expect_next_instance_of(Gitlab::BackgroundMigration::Logger) do |instance|
+ expect(instance).to receive(:error).twice
+ end
+
+ perform
+ end
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/populate_vulnerability_reads_spec.rb b/spec/lib/gitlab/background_migration/populate_vulnerability_reads_spec.rb
index a265fa95b23..3de84a4e880 100644
--- a/spec/lib/gitlab/background_migration/populate_vulnerability_reads_spec.rb
+++ b/spec/lib/gitlab/background_migration/populate_vulnerability_reads_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::BackgroundMigration::PopulateVulnerabilityReads do
+RSpec.describe Gitlab::BackgroundMigration::PopulateVulnerabilityReads, :migration, schema: 20220326161803 do
let(:vulnerabilities) { table(:vulnerabilities) }
let(:vulnerability_reads) { table(:vulnerability_reads) }
let(:vulnerabilities_findings) { table(:vulnerability_occurrences) }
diff --git a/spec/lib/gitlab/background_migration/project_namespaces/backfill_project_namespaces_spec.rb b/spec/lib/gitlab/background_migration/project_namespaces/backfill_project_namespaces_spec.rb
index 2c5de448fbc..2ad561ead87 100644
--- a/spec/lib/gitlab/background_migration/project_namespaces/backfill_project_namespaces_spec.rb
+++ b/spec/lib/gitlab/background_migration/project_namespaces/backfill_project_namespaces_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::BackgroundMigration::ProjectNamespaces::BackfillProjectNamespaces, :migration do
+RSpec.describe Gitlab::BackgroundMigration::ProjectNamespaces::BackfillProjectNamespaces, :migration, schema: 20220326161803 do
include MigrationsHelpers
context 'when migrating data', :aggregate_failures do
diff --git a/spec/lib/gitlab/background_migration/remove_duplicate_vulnerabilities_findings_spec.rb b/spec/lib/gitlab/background_migration/remove_duplicate_vulnerabilities_findings_spec.rb
index f6f4a3f6115..8003159f59e 100644
--- a/spec/lib/gitlab/background_migration/remove_duplicate_vulnerabilities_findings_spec.rb
+++ b/spec/lib/gitlab/background_migration/remove_duplicate_vulnerabilities_findings_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Gitlab::BackgroundMigration::RemoveDuplicateVulnerabilitiesFindings do
+RSpec.describe Gitlab::BackgroundMigration::RemoveDuplicateVulnerabilitiesFindings, :migration, schema: 20220326161803 do
let(:namespace) { table(:namespaces).create!(name: 'user', path: 'user') }
let(:users) { table(:users) }
let(:user) { create_user! }
diff --git a/spec/lib/gitlab/background_migration/remove_occurrence_pipelines_and_duplicate_vulnerabilities_findings_spec.rb b/spec/lib/gitlab/background_migration/remove_occurrence_pipelines_and_duplicate_vulnerabilities_findings_spec.rb
index 28aa9efde4f..07cff32304e 100644
--- a/spec/lib/gitlab/background_migration/remove_occurrence_pipelines_and_duplicate_vulnerabilities_findings_spec.rb
+++ b/spec/lib/gitlab/background_migration/remove_occurrence_pipelines_and_duplicate_vulnerabilities_findings_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Gitlab::BackgroundMigration::RemoveOccurrencePipelinesAndDuplicateVulnerabilitiesFindings do
+RSpec.describe Gitlab::BackgroundMigration::RemoveOccurrencePipelinesAndDuplicateVulnerabilitiesFindings, :migration, schema: 20220326161803 do
let(:namespace) { table(:namespaces).create!(name: 'user', path: 'user') }
let(:users) { table(:users) }
let(:user) { create_user! }
diff --git a/spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_encrypted_values_on_projects_spec.rb b/spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_encrypted_values_on_projects_spec.rb
index 6aea549b136..d02f7245c15 100644
--- a/spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_encrypted_values_on_projects_spec.rb
+++ b/spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_encrypted_values_on_projects_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::BackgroundMigration::ResetDuplicateCiRunnersTokenEncryptedValuesOnProjects do
+RSpec.describe Gitlab::BackgroundMigration::ResetDuplicateCiRunnersTokenEncryptedValuesOnProjects, :migration, schema: 20220326161803 do # rubocop:disable Layout/LineLength
let(:namespaces) { table(:namespaces) }
let(:projects) { table(:projects) }
diff --git a/spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_values_on_projects_spec.rb b/spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_values_on_projects_spec.rb
index cbe762c2680..fd61047d851 100644
--- a/spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_values_on_projects_spec.rb
+++ b/spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_values_on_projects_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::BackgroundMigration::ResetDuplicateCiRunnersTokenValuesOnProjects do
+RSpec.describe Gitlab::BackgroundMigration::ResetDuplicateCiRunnersTokenValuesOnProjects, :migration, schema: 20220326161803 do # rubocop:disable Layout/LineLength
let(:namespaces) { table(:namespaces) }
let(:projects) { table(:projects) }
diff --git a/spec/lib/gitlab/blame_spec.rb b/spec/lib/gitlab/blame_spec.rb
index e22399723ac..f636ce283ae 100644
--- a/spec/lib/gitlab/blame_spec.rb
+++ b/spec/lib/gitlab/blame_spec.rb
@@ -3,13 +3,31 @@
require 'spec_helper'
RSpec.describe Gitlab::Blame do
- let(:project) { create(:project, :repository) }
+ let_it_be(:project) { create(:project, :repository) }
+
let(:path) { 'files/ruby/popen.rb' }
let(:commit) { project.commit('master') }
let(:blob) { project.repository.blob_at(commit.id, path) }
+ let(:range) { nil }
+
+ subject(:blame) { described_class.new(blob, commit, range: range) }
+
+ describe '#first_line' do
+ subject { blame.first_line }
+
+ it { is_expected.to eq(1) }
+
+ context 'with a range' do
+ let(:range) { 2..3 }
+
+ it { is_expected.to eq(range.first) }
+ end
+ end
describe "#groups" do
- let(:subject) { described_class.new(blob, commit).groups(highlight: false) }
+ let(:highlighted) { false }
+
+ subject(:groups) { blame.groups(highlight: highlighted) }
it 'groups lines properly' do
expect(subject.count).to eq(18)
@@ -22,5 +40,62 @@ RSpec.describe Gitlab::Blame do
expect(subject[-1][:commit].sha).to eq('913c66a37b4a45b9769037c55c2d238bd0942d2e')
expect(subject[-1][:lines]).to eq([" end", "end"])
end
+
+ context 'with a range 1..5' do
+ let(:range) { 1..5 }
+
+ it 'returns the correct lines' do
+ expect(groups.count).to eq(2)
+ expect(groups[0][:lines]).to eq(["require 'fileutils'", "require 'open3'", ""])
+ expect(groups[1][:lines]).to eq(['module Popen', ' extend self'])
+ end
+
+ context 'with highlighted lines' do
+ let(:highlighted) { true }
+
+ it 'returns the correct lines' do
+ expect(groups.count).to eq(2)
+ expect(groups[0][:lines][0]).to match(/LC1.*fileutils/)
+ expect(groups[0][:lines][1]).to match(/LC2.*open3/)
+ expect(groups[0][:lines][2]).to eq("<span id=\"LC3\" class=\"line\" lang=\"ruby\"></span>\n")
+ expect(groups[1][:lines][0]).to match(/LC4.*Popen/)
+ expect(groups[1][:lines][1]).to match(/LC5.*extend/)
+ end
+ end
+ end
+
+ context 'with a range 2..4' do
+ let(:range) { 2..4 }
+
+ it 'returns the correct lines' do
+ expect(groups.count).to eq(2)
+ expect(groups[0][:lines]).to eq(["require 'open3'", ""])
+ expect(groups[1][:lines]).to eq(['module Popen'])
+ end
+
+ context 'with highlighted lines' do
+ let(:highlighted) { true }
+
+ it 'returns the correct lines' do
+ expect(groups.count).to eq(2)
+ expect(groups[0][:lines][0]).to match(/LC2.*open3/)
+ expect(groups[0][:lines][1]).to eq("<span id=\"LC3\" class=\"line\" lang=\"ruby\"></span>\n")
+ expect(groups[1][:lines][0]).to match(/LC4.*Popen/)
+ end
+ end
+ end
+
+ context 'renamed file' do
+ let(:path) { 'files/plain_text/renamed' }
+ let(:commit) { project.commit('blame-on-renamed') }
+
+ it 'adds previous path' do
+ expect(subject[0][:previous_path]).to be nil
+ expect(subject[0][:lines]).to match_array(['Initial commit', 'Initial commit'])
+
+ expect(subject[1][:previous_path]).to eq('files/plain_text/initial-commit')
+ expect(subject[1][:lines]).to match_array(['Renamed as "filename"'])
+ end
+ end
end
end
diff --git a/spec/lib/gitlab/ci/build/image_spec.rb b/spec/lib/gitlab/ci/build/image_spec.rb
index 71cd57d317c..630dfcd06bb 100644
--- a/spec/lib/gitlab/ci/build/image_spec.rb
+++ b/spec/lib/gitlab/ci/build/image_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe Gitlab::Ci::Build::Image do
subject { described_class.from_image(job) }
context 'when image is defined in job' do
- let(:image_name) { 'ruby:2.7' }
+ let(:image_name) { 'image:1.0' }
let(:job) { create(:ci_build, options: { image: image_name } ) }
context 'when image is defined as string' do
diff --git a/spec/lib/gitlab/ci/config/entry/image_spec.rb b/spec/lib/gitlab/ci/config/entry/image_spec.rb
index e810d65d560..e16a9a7a74a 100644
--- a/spec/lib/gitlab/ci/config/entry/image_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/image_spec.rb
@@ -6,11 +6,11 @@ RSpec.describe Gitlab::Ci::Config::Entry::Image do
let(:entry) { described_class.new(config) }
context 'when configuration is a string' do
- let(:config) { 'ruby:2.7' }
+ let(:config) { 'image:1.0' }
describe '#value' do
it 'returns image hash' do
- expect(entry.value).to eq({ name: 'ruby:2.7' })
+ expect(entry.value).to eq({ name: 'image:1.0' })
end
end
@@ -28,7 +28,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Image do
describe '#image' do
it "returns image's name" do
- expect(entry.name).to eq 'ruby:2.7'
+ expect(entry.name).to eq 'image:1.0'
end
end
@@ -46,7 +46,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Image do
end
context 'when configuration is a hash' do
- let(:config) { { name: 'ruby:2.7', entrypoint: %w(/bin/sh run) } }
+ let(:config) { { name: 'image:1.0', entrypoint: %w(/bin/sh run) } }
describe '#value' do
it 'returns image hash' do
@@ -68,7 +68,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Image do
describe '#image' do
it "returns image's name" do
- expect(entry.name).to eq 'ruby:2.7'
+ expect(entry.name).to eq 'image:1.0'
end
end
@@ -80,7 +80,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Image do
context 'when configuration has ports' do
let(:ports) { [{ number: 80, protocol: 'http', name: 'foobar' }] }
- let(:config) { { name: 'ruby:2.7', entrypoint: %w(/bin/sh run), ports: ports } }
+ let(:config) { { name: 'image:1.0', entrypoint: %w(/bin/sh run), ports: ports } }
let(:entry) { described_class.new(config, with_image_ports: image_ports) }
let(:image_ports) { false }
@@ -112,7 +112,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Image do
end
context 'when entry value is not correct' do
- let(:config) { ['ruby:2.7'] }
+ let(:config) { ['image:1.0'] }
describe '#errors' do
it 'saves errors' do
@@ -129,7 +129,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Image do
end
context 'when unexpected key is specified' do
- let(:config) { { name: 'ruby:2.7', non_existing: 'test' } }
+ let(:config) { { name: 'image:1.0', non_existing: 'test' } }
describe '#errors' do
it 'saves errors' do
diff --git a/spec/lib/gitlab/ci/config/entry/reports/coverage_report_spec.rb b/spec/lib/gitlab/ci/config/entry/reports/coverage_report_spec.rb
index 588f53150ff..0fd9a83a4fa 100644
--- a/spec/lib/gitlab/ci/config/entry/reports/coverage_report_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/reports/coverage_report_spec.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-require 'fast_spec_helper'
+require 'spec_helper'
RSpec.describe Gitlab::Ci::Config::Entry::Reports::CoverageReport do
let(:entry) { described_class.new(config) }
diff --git a/spec/lib/gitlab/ci/config/entry/root_spec.rb b/spec/lib/gitlab/ci/config/entry/root_spec.rb
index daf58aff116..b9c32bc51be 100644
--- a/spec/lib/gitlab/ci/config/entry/root_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/root_spec.rb
@@ -31,7 +31,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do
let(:hash) do
{
before_script: %w(ls pwd),
- image: 'ruby:2.7',
+ image: 'image:1.0',
default: {},
services: ['postgres:9.1', 'mysql:5.5'],
variables: { VAR: 'root', VAR2: { value: 'val 2', description: 'this is var 2' } },
@@ -154,7 +154,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do
{ name: :rspec,
script: %w[rspec ls],
before_script: %w(ls pwd),
- image: { name: 'ruby:2.7' },
+ image: { name: 'image:1.0' },
services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }],
stage: 'test',
cache: [{ key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' }],
@@ -169,7 +169,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do
{ name: :spinach,
before_script: [],
script: %w[spinach],
- image: { name: 'ruby:2.7' },
+ image: { name: 'image:1.0' },
services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }],
stage: 'test',
cache: [{ key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' }],
@@ -186,7 +186,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do
before_script: [],
script: ["make changelog | tee release_changelog.txt"],
release: { name: "Release $CI_TAG_NAME", tag_name: 'v0.06', description: "./release_changelog.txt" },
- image: { name: "ruby:2.7" },
+ image: { name: "image:1.0" },
services: [{ name: "postgres:9.1" }, { name: "mysql:5.5" }],
cache: [{ key: "k", untracked: true, paths: ["public/"], policy: "pull-push", when: 'on_success' }],
only: { refs: %w(branches tags) },
@@ -206,7 +206,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do
{ before_script: %w(ls pwd),
after_script: ['make clean'],
default: {
- image: 'ruby:2.7',
+ image: 'image:1.0',
services: ['postgres:9.1', 'mysql:5.5']
},
variables: { VAR: 'root' },
@@ -233,7 +233,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do
rspec: { name: :rspec,
script: %w[rspec ls],
before_script: %w(ls pwd),
- image: { name: 'ruby:2.7' },
+ image: { name: 'image:1.0' },
services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }],
stage: 'test',
cache: [{ key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' }],
@@ -246,7 +246,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do
spinach: { name: :spinach,
before_script: [],
script: %w[spinach],
- image: { name: 'ruby:2.7' },
+ image: { name: 'image:1.0' },
services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }],
stage: 'test',
cache: [{ key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' }],
diff --git a/spec/lib/gitlab/ci/config/external/file/artifact_spec.rb b/spec/lib/gitlab/ci/config/external/file/artifact_spec.rb
index b59fc95a8cc..9da8d106862 100644
--- a/spec/lib/gitlab/ci/config/external/file/artifact_spec.rb
+++ b/spec/lib/gitlab/ci/config/external/file/artifact_spec.rb
@@ -4,8 +4,9 @@ require 'spec_helper'
RSpec.describe Gitlab::Ci::Config::External::File::Artifact do
let(:parent_pipeline) { create(:ci_pipeline) }
+ let(:variables) {}
let(:context) do
- Gitlab::Ci::Config::External::Context.new(parent_pipeline: parent_pipeline)
+ Gitlab::Ci::Config::External::Context.new(variables: variables, parent_pipeline: parent_pipeline)
end
let(:external_file) { described_class.new(params, context) }
@@ -29,14 +30,15 @@ RSpec.describe Gitlab::Ci::Config::External::File::Artifact do
end
describe '#valid?' do
- shared_examples 'is invalid' do
- it 'is not valid' do
- expect(external_file).not_to be_valid
- end
+ subject(:valid?) do
+ external_file.validate!
+ external_file.valid?
+ end
+ shared_examples 'is invalid' do
it 'sets the expected error' do
- expect(external_file.errors)
- .to contain_exactly(expected_error)
+ expect(valid?).to be_falsy
+ expect(external_file.errors).to contain_exactly(expected_error)
end
end
@@ -148,7 +150,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Artifact do
context 'when file is not empty' do
it 'is valid' do
- expect(external_file).to be_valid
+ expect(valid?).to be_truthy
expect(external_file.content).to be_present
end
@@ -160,6 +162,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Artifact do
user: anything
}
expect(context).to receive(:mutate).with(expected_attrs).and_call_original
+ external_file.validate!
external_file.content
end
end
@@ -168,6 +171,58 @@ RSpec.describe Gitlab::Ci::Config::External::File::Artifact do
end
end
end
+
+ context 'when job is provided as a variable' do
+ let(:variables) do
+ Gitlab::Ci::Variables::Collection.new([
+ { key: 'VAR1', value: 'a_secret_variable_value', masked: true }
+ ])
+ end
+
+ let(:params) { { artifact: 'generated.yml', job: 'a_secret_variable_value' } }
+
+ context 'when job does not exist in the parent pipeline' do
+ let(:expected_error) do
+ 'Job `xxxxxxxxxxxxxxxxxxxxxxx` not found in parent pipeline or does not have artifacts!'
+ end
+
+ it_behaves_like 'is invalid'
+ end
+ end
+ end
+ end
+
+ describe '#metadata' do
+ let(:params) { { artifact: 'generated.yml' } }
+
+ subject(:metadata) { external_file.metadata }
+
+ it {
+ is_expected.to eq(
+ context_project: nil,
+ context_sha: nil,
+ type: :artifact,
+ location: 'generated.yml',
+ extra: { job_name: nil }
+ )
+ }
+
+ context 'when job name includes a masked variable' do
+ let(:variables) do
+ Gitlab::Ci::Variables::Collection.new([{ key: 'VAR1', value: 'a_secret_variable_value', masked: true }])
+ end
+
+ let(:params) { { artifact: 'generated.yml', job: 'a_secret_variable_value' } }
+
+ it {
+ is_expected.to eq(
+ context_project: nil,
+ context_sha: nil,
+ type: :artifact,
+ location: 'generated.yml',
+ extra: { job_name: 'xxxxxxxxxxxxxxxxxxxxxxx' }
+ )
+ }
end
end
end
diff --git a/spec/lib/gitlab/ci/config/external/file/base_spec.rb b/spec/lib/gitlab/ci/config/external/file/base_spec.rb
index 536f48ecba6..280bebe1a7c 100644
--- a/spec/lib/gitlab/ci/config/external/file/base_spec.rb
+++ b/spec/lib/gitlab/ci/config/external/file/base_spec.rb
@@ -17,7 +17,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Base do
end
end
- subject { test_class.new(location, context) }
+ subject(:file) { test_class.new(location, context) }
before do
allow_any_instance_of(test_class)
@@ -32,7 +32,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Base do
let(:location) { 'some-location' }
it 'returns true' do
- expect(subject).to be_matching
+ expect(file).to be_matching
end
end
@@ -40,40 +40,45 @@ RSpec.describe Gitlab::Ci::Config::External::File::Base do
let(:location) { nil }
it 'returns false' do
- expect(subject).not_to be_matching
+ expect(file).not_to be_matching
end
end
end
describe '#valid?' do
+ subject(:valid?) do
+ file.validate!
+ file.valid?
+ end
+
context 'when location is not a string' do
let(:location) { %w(some/file.txt other/file.txt) }
- it { is_expected.not_to be_valid }
+ it { is_expected.to be_falsy }
end
context 'when location is not a YAML file' do
let(:location) { 'some/file.txt' }
- it { is_expected.not_to be_valid }
+ it { is_expected.to be_falsy }
end
context 'when location has not a valid naming scheme' do
let(:location) { 'some/file/.yml' }
- it { is_expected.not_to be_valid }
+ it { is_expected.to be_falsy }
end
context 'when location is a valid .yml extension' do
let(:location) { 'some/file/config.yml' }
- it { is_expected.to be_valid }
+ it { is_expected.to be_truthy }
end
context 'when location is a valid .yaml extension' do
let(:location) { 'some/file/config.yaml' }
- it { is_expected.to be_valid }
+ it { is_expected.to be_truthy }
end
context 'when there are YAML syntax errors' do
@@ -86,8 +91,8 @@ RSpec.describe Gitlab::Ci::Config::External::File::Base do
end
it 'is not a valid file' do
- expect(subject).not_to be_valid
- expect(subject.error_message).to eq('Included file `some/file/xxxxxxxxxxxxxxxx.yml` does not have valid YAML syntax!')
+ expect(valid?).to be_falsy
+ expect(file.error_message).to eq('Included file `some/file/xxxxxxxxxxxxxxxx.yml` does not have valid YAML syntax!')
end
end
end
@@ -103,8 +108,56 @@ RSpec.describe Gitlab::Ci::Config::External::File::Base do
end
it 'does expand hash to include the template' do
- expect(subject.to_hash).to include(:before_script)
+ expect(file.to_hash).to include(:before_script)
end
end
end
+
+ describe '#metadata' do
+ let(:location) { 'some/file/config.yml' }
+
+ subject(:metadata) { file.metadata }
+
+ it {
+ is_expected.to eq(
+ context_project: nil,
+ context_sha: 'HEAD'
+ )
+ }
+ end
+
+ describe '#eql?' do
+ let(:location) { 'some/file/config.yml' }
+
+ subject(:eql) { file.eql?(other_file) }
+
+ context 'when the other file has the same params' do
+ let(:other_file) { test_class.new(location, context) }
+
+ it { is_expected.to eq(true) }
+ end
+
+ context 'when the other file has not the same params' do
+ let(:other_file) { test_class.new('some/other/file', context) }
+
+ it { is_expected.to eq(false) }
+ end
+ end
+
+ describe '#hash' do
+ let(:location) { 'some/file/config.yml' }
+
+ subject(:filehash) { file.hash }
+
+ context 'with a project' do
+ let(:project) { create(:project) }
+ let(:context_params) { { project: project, sha: 'HEAD', variables: variables } }
+
+ it { is_expected.to eq([location, project.full_path, 'HEAD'].hash) }
+ end
+
+ context 'without a project' do
+ it { is_expected.to eq([location, nil, 'HEAD'].hash) }
+ end
+ end
end
diff --git a/spec/lib/gitlab/ci/config/external/file/local_spec.rb b/spec/lib/gitlab/ci/config/external/file/local_spec.rb
index b9314dfc44e..c0a0b0009ce 100644
--- a/spec/lib/gitlab/ci/config/external/file/local_spec.rb
+++ b/spec/lib/gitlab/ci/config/external/file/local_spec.rb
@@ -55,6 +55,11 @@ RSpec.describe Gitlab::Ci::Config::External::File::Local do
end
describe '#valid?' do
+ subject(:valid?) do
+ local_file.validate!
+ local_file.valid?
+ end
+
context 'when is a valid local path' do
let(:location) { '/lib/gitlab/ci/templates/existent-file.yml' }
@@ -62,25 +67,19 @@ RSpec.describe Gitlab::Ci::Config::External::File::Local do
allow_any_instance_of(described_class).to receive(:fetch_local_content).and_return("image: 'ruby2:2'")
end
- it 'returns true' do
- expect(local_file.valid?).to be_truthy
- end
+ it { is_expected.to be_truthy }
end
context 'when it is not a valid local path' do
let(:location) { '/lib/gitlab/ci/templates/non-existent-file.yml' }
- it 'returns false' do
- expect(local_file.valid?).to be_falsy
- end
+ it { is_expected.to be_falsy }
end
context 'when it is not a yaml file' do
let(:location) { '/config/application.rb' }
- it 'returns false' do
- expect(local_file.valid?).to be_falsy
- end
+ it { is_expected.to be_falsy }
end
context 'when it is an empty file' do
@@ -89,6 +88,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Local do
it 'returns false and adds an error message about an empty file' do
allow_any_instance_of(described_class).to receive(:fetch_local_content).and_return("")
+ local_file.validate!
expect(local_file.errors).to include("Local file `/lib/gitlab/ci/templates/xxxxxx/existent-file.yml` is empty!")
end
end
@@ -98,7 +98,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Local do
let(:sha) { ':' }
it 'returns false and adds an error message stating that included file does not exist' do
- expect(local_file).not_to be_valid
+ expect(valid?).to be_falsy
expect(local_file.errors).to include("Sha #{sha} is not valid!")
end
end
@@ -140,6 +140,10 @@ RSpec.describe Gitlab::Ci::Config::External::File::Local do
let(:location) { '/lib/gitlab/ci/templates/secret_file.yml' }
let(:variables) { Gitlab::Ci::Variables::Collection.new([{ 'key' => 'GITLAB_TOKEN', 'value' => 'secret_file', 'masked' => true }]) }
+ before do
+ local_file.validate!
+ end
+
it 'returns an error message' do
expect(local_file.error_message).to eq("Local file `/lib/gitlab/ci/templates/xxxxxxxxxxx.yml` does not exist!")
end
@@ -174,6 +178,8 @@ RSpec.describe Gitlab::Ci::Config::External::File::Local do
allow(project.repository).to receive(:blob_data_at).with(sha, another_location)
.and_return(another_content)
+
+ local_file.validate!
end
it 'does expand hash to include the template' do
@@ -181,4 +187,20 @@ RSpec.describe Gitlab::Ci::Config::External::File::Local do
end
end
end
+
+ describe '#metadata' do
+ let(:location) { '/lib/gitlab/ci/templates/existent-file.yml' }
+
+ subject(:metadata) { local_file.metadata }
+
+ it {
+ is_expected.to eq(
+ context_project: project.full_path,
+ context_sha: '12345',
+ type: :local,
+ location: location,
+ extra: {}
+ )
+ }
+ end
end
diff --git a/spec/lib/gitlab/ci/config/external/file/project_spec.rb b/spec/lib/gitlab/ci/config/external/file/project_spec.rb
index 74720c0a3ca..5d3412a148b 100644
--- a/spec/lib/gitlab/ci/config/external/file/project_spec.rb
+++ b/spec/lib/gitlab/ci/config/external/file/project_spec.rb
@@ -66,6 +66,11 @@ RSpec.describe Gitlab::Ci::Config::External::File::Project do
end
describe '#valid?' do
+ subject(:valid?) do
+ project_file.validate!
+ project_file.valid?
+ end
+
context 'when a valid path is used' do
let(:params) do
{ project: project.full_path, file: '/file.yml' }
@@ -74,18 +79,16 @@ RSpec.describe Gitlab::Ci::Config::External::File::Project do
let(:root_ref_sha) { project.repository.root_ref_sha }
before do
- stub_project_blob(root_ref_sha, '/file.yml') { 'image: ruby:2.7' }
+ stub_project_blob(root_ref_sha, '/file.yml') { 'image: image:1.0' }
end
- it 'returns true' do
- expect(project_file).to be_valid
- end
+ it { is_expected.to be_truthy }
context 'when user does not have permission to access file' do
let(:context_user) { create(:user) }
it 'returns false' do
- expect(project_file).not_to be_valid
+ expect(valid?).to be_falsy
expect(project_file.error_message).to include("Project `#{project.full_path}` not found or access denied!")
end
end
@@ -99,12 +102,10 @@ RSpec.describe Gitlab::Ci::Config::External::File::Project do
let(:ref_sha) { project.commit('master').sha }
before do
- stub_project_blob(ref_sha, '/file.yml') { 'image: ruby:2.7' }
+ stub_project_blob(ref_sha, '/file.yml') { 'image: image:1.0' }
end
- it 'returns true' do
- expect(project_file).to be_valid
- end
+ it { is_expected.to be_truthy }
end
context 'when an empty file is used' do
@@ -120,7 +121,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Project do
end
it 'returns false' do
- expect(project_file).not_to be_valid
+ expect(valid?).to be_falsy
expect(project_file.error_message).to include("Project `#{project.full_path}` file `/xxxxxxxxxxx.yml` is empty!")
end
end
@@ -131,7 +132,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Project do
end
it 'returns false' do
- expect(project_file).not_to be_valid
+ expect(valid?).to be_falsy
expect(project_file.error_message).to include("Project `#{project.full_path}` reference `I-Do-Not-Exist` does not exist!")
end
end
@@ -144,7 +145,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Project do
end
it 'returns false' do
- expect(project_file).not_to be_valid
+ expect(valid?).to be_falsy
expect(project_file.error_message).to include("Project `#{project.full_path}` file `/xxxxxxxxxxxxxxxxxxx.yml` does not exist!")
end
end
@@ -155,10 +156,27 @@ RSpec.describe Gitlab::Ci::Config::External::File::Project do
end
it 'returns false' do
- expect(project_file).not_to be_valid
+ expect(valid?).to be_falsy
expect(project_file.error_message).to include('Included file `/invalid-file` does not have YAML extension!')
end
end
+
+ context 'when non-existing project is used with a masked variable' do
+ let(:variables) do
+ Gitlab::Ci::Variables::Collection.new([
+ { key: 'VAR1', value: 'a_secret_variable_value', masked: true }
+ ])
+ end
+
+ let(:params) do
+ { project: 'a_secret_variable_value', file: '/file.yml' }
+ end
+
+ it 'returns false with masked project name' do
+ expect(valid?).to be_falsy
+ expect(project_file.error_message).to include("Project `xxxxxxxxxxxxxxxxxxxxxxx` not found or access denied!")
+ end
+ end
end
describe '#expand_context' do
@@ -176,6 +194,45 @@ RSpec.describe Gitlab::Ci::Config::External::File::Project do
end
end
+ describe '#metadata' do
+ let(:params) do
+ { project: project.full_path, file: '/file.yml' }
+ end
+
+ subject(:metadata) { project_file.metadata }
+
+ it {
+ is_expected.to eq(
+ context_project: context_project.full_path,
+ context_sha: '12345',
+ type: :file,
+ location: '/file.yml',
+ extra: { project: project.full_path, ref: 'HEAD' }
+ )
+ }
+
+ context 'when project name and ref include masked variables' do
+ let(:variables) do
+ Gitlab::Ci::Variables::Collection.new([
+ { key: 'VAR1', value: 'a_secret_variable_value1', masked: true },
+ { key: 'VAR2', value: 'a_secret_variable_value2', masked: true }
+ ])
+ end
+
+ let(:params) { { project: 'a_secret_variable_value1', ref: 'a_secret_variable_value2', file: '/file.yml' } }
+
+ it {
+ is_expected.to eq(
+ context_project: context_project.full_path,
+ context_sha: '12345',
+ type: :file,
+ location: '/file.yml',
+ extra: { project: 'xxxxxxxxxxxxxxxxxxxxxxxx', ref: 'xxxxxxxxxxxxxxxxxxxxxxxx' }
+ )
+ }
+ end
+ end
+
private
def stub_project_blob(ref, path)
diff --git a/spec/lib/gitlab/ci/config/external/file/remote_spec.rb b/spec/lib/gitlab/ci/config/external/file/remote_spec.rb
index 2613bfbfdcf..5c07c87fd5a 100644
--- a/spec/lib/gitlab/ci/config/external/file/remote_spec.rb
+++ b/spec/lib/gitlab/ci/config/external/file/remote_spec.rb
@@ -54,22 +54,23 @@ RSpec.describe Gitlab::Ci::Config::External::File::Remote do
end
describe "#valid?" do
+ subject(:valid?) do
+ remote_file.validate!
+ remote_file.valid?
+ end
+
context 'when is a valid remote url' do
before do
stub_full_request(location).to_return(body: remote_file_content)
end
- it 'returns true' do
- expect(remote_file.valid?).to be_truthy
- end
+ it { is_expected.to be_truthy }
end
context 'with an irregular url' do
let(:location) { 'not-valid://gitlab.com/gitlab-org/gitlab-foss/blob/1234/.gitlab-ci-1.yml' }
- it 'returns false' do
- expect(remote_file.valid?).to be_falsy
- end
+ it { is_expected.to be_falsy }
end
context 'with a timeout' do
@@ -77,25 +78,19 @@ RSpec.describe Gitlab::Ci::Config::External::File::Remote do
allow(Gitlab::HTTP).to receive(:get).and_raise(Timeout::Error)
end
- it 'is falsy' do
- expect(remote_file.valid?).to be_falsy
- end
+ it { is_expected.to be_falsy }
end
context 'when is not a yaml file' do
let(:location) { 'https://asdasdasdaj48ggerexample.com' }
- it 'is falsy' do
- expect(remote_file.valid?).to be_falsy
- end
+ it { is_expected.to be_falsy }
end
context 'with an internal url' do
let(:location) { 'http://localhost:8080' }
- it 'is falsy' do
- expect(remote_file.valid?).to be_falsy
- end
+ it { is_expected.to be_falsy }
end
end
@@ -142,7 +137,10 @@ RSpec.describe Gitlab::Ci::Config::External::File::Remote do
end
describe "#error_message" do
- subject { remote_file.error_message }
+ subject(:error_message) do
+ remote_file.validate!
+ remote_file.error_message
+ end
context 'when remote file location is not valid' do
let(:location) { 'not-valid://gitlab.com/gitlab-org/gitlab-foss/blob/1234/?secret_file.yml' }
@@ -201,4 +199,22 @@ RSpec.describe Gitlab::Ci::Config::External::File::Remote do
is_expected.to be_empty
end
end
+
+ describe '#metadata' do
+ before do
+ stub_full_request(location).to_return(body: remote_file_content)
+ end
+
+ subject(:metadata) { remote_file.metadata }
+
+ it {
+ is_expected.to eq(
+ context_project: nil,
+ context_sha: '12345',
+ type: :remote,
+ location: 'https://gitlab.com/gitlab-org/gitlab-foss/blob/1234/.xxxxxxxxxxx.yml',
+ extra: {}
+ )
+ }
+ end
end
diff --git a/spec/lib/gitlab/ci/config/external/file/template_spec.rb b/spec/lib/gitlab/ci/config/external/file/template_spec.rb
index 66a06de3d28..4da9a933a9f 100644
--- a/spec/lib/gitlab/ci/config/external/file/template_spec.rb
+++ b/spec/lib/gitlab/ci/config/external/file/template_spec.rb
@@ -45,12 +45,15 @@ RSpec.describe Gitlab::Ci::Config::External::File::Template do
end
describe "#valid?" do
+ subject(:valid?) do
+ template_file.validate!
+ template_file.valid?
+ end
+
context 'when is a valid template name' do
let(:template) { 'Auto-DevOps.gitlab-ci.yml' }
- it 'returns true' do
- expect(template_file).to be_valid
- end
+ it { is_expected.to be_truthy }
end
context 'with invalid template name' do
@@ -59,7 +62,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Template do
let(:context_params) { { project: project, sha: '12345', user: user, variables: variables } }
it 'returns false' do
- expect(template_file).not_to be_valid
+ expect(valid?).to be_falsy
expect(template_file.error_message).to include('`xxxxxxxxxxxxxx.yml` is not a valid location!')
end
end
@@ -68,7 +71,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Template do
let(:template) { 'I-Do-Not-Have-This-Template.gitlab-ci.yml' }
it 'returns false' do
- expect(template_file).not_to be_valid
+ expect(valid?).to be_falsy
expect(template_file.error_message).to include('Included file `I-Do-Not-Have-This-Template.gitlab-ci.yml` is empty or does not exist!')
end
end
@@ -111,4 +114,18 @@ RSpec.describe Gitlab::Ci::Config::External::File::Template do
is_expected.to be_empty
end
end
+
+ describe '#metadata' do
+ subject(:metadata) { template_file.metadata }
+
+ it {
+ is_expected.to eq(
+ context_project: project.full_path,
+ context_sha: '12345',
+ type: :template,
+ location: template,
+ extra: {}
+ )
+ }
+ end
end
diff --git a/spec/lib/gitlab/ci/config/external/mapper_spec.rb b/spec/lib/gitlab/ci/config/external/mapper_spec.rb
index f69feba5e59..2d2adf09a42 100644
--- a/spec/lib/gitlab/ci/config/external/mapper_spec.rb
+++ b/spec/lib/gitlab/ci/config/external/mapper_spec.rb
@@ -17,10 +17,12 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do
let(:file_content) do
<<~HEREDOC
- image: 'ruby:2.7'
+ image: 'image:1.0'
HEREDOC
end
+ subject(:mapper) { described_class.new(values, context) }
+
before do
stub_full_request(remote_url).to_return(body: file_content)
@@ -30,13 +32,13 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do
end
describe '#process' do
- subject { described_class.new(values, context).process }
+ subject(:process) { mapper.process }
context "when single 'include' keyword is defined" do
context 'when the string is a local file' do
let(:values) do
{ include: local_file,
- image: 'ruby:2.7' }
+ image: 'image:1.0' }
end
it 'returns File instances' do
@@ -48,7 +50,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do
context 'when the key is a local file hash' do
let(:values) do
{ include: { 'local' => local_file },
- image: 'ruby:2.7' }
+ image: 'image:1.0' }
end
it 'returns File instances' do
@@ -59,7 +61,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do
context 'when the string is a remote file' do
let(:values) do
- { include: remote_url, image: 'ruby:2.7' }
+ { include: remote_url, image: 'image:1.0' }
end
it 'returns File instances' do
@@ -71,7 +73,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do
context 'when the key is a remote file hash' do
let(:values) do
{ include: { 'remote' => remote_url },
- image: 'ruby:2.7' }
+ image: 'image:1.0' }
end
it 'returns File instances' do
@@ -83,7 +85,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do
context 'when the key is a template file hash' do
let(:values) do
{ include: { 'template' => template_file },
- image: 'ruby:2.7' }
+ image: 'image:1.0' }
end
it 'returns File instances' do
@@ -98,7 +100,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do
let(:remote_url) { 'https://gitlab.com/secret-file.yml' }
let(:values) do
{ include: { 'local' => local_file, 'remote' => remote_url },
- image: 'ruby:2.7' }
+ image: 'image:1.0' }
end
it 'returns ambigious specification error' do
@@ -109,7 +111,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do
context "when the key is a project's file" do
let(:values) do
{ include: { project: project.full_path, file: local_file },
- image: 'ruby:2.7' }
+ image: 'image:1.0' }
end
it 'returns File instances' do
@@ -121,7 +123,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do
context "when the key is project's files" do
let(:values) do
{ include: { project: project.full_path, file: [local_file, 'another_file_path.yml'] },
- image: 'ruby:2.7' }
+ image: 'image:1.0' }
end
it 'returns two File instances' do
@@ -135,7 +137,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do
context "when 'include' is defined as an array" do
let(:values) do
{ include: [remote_url, local_file],
- image: 'ruby:2.7' }
+ image: 'image:1.0' }
end
it 'returns Files instances' do
@@ -147,7 +149,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do
context "when 'include' is defined as an array of hashes" do
let(:values) do
{ include: [{ remote: remote_url }, { local: local_file }],
- image: 'ruby:2.7' }
+ image: 'image:1.0' }
end
it 'returns Files instances' do
@@ -158,7 +160,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do
context 'when it has ambigious match' do
let(:values) do
{ include: [{ remote: remote_url, local: local_file }],
- image: 'ruby:2.7' }
+ image: 'image:1.0' }
end
it 'returns ambigious specification error' do
@@ -170,7 +172,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do
context "when 'include' is not defined" do
let(:values) do
{
- image: 'ruby:2.7'
+ image: 'image:1.0'
}
end
@@ -185,11 +187,16 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do
{ 'local' => local_file },
{ 'local' => local_file }
],
- image: 'ruby:2.7' }
+ image: 'image:1.0' }
end
it 'does not raise an exception' do
- expect { subject }.not_to raise_error
+ expect { process }.not_to raise_error
+ end
+
+ it 'has expanset with one' do
+ process
+ expect(mapper.expandset.size).to eq(1)
end
end
@@ -199,7 +206,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do
{ 'local' => local_file },
{ 'remote' => remote_url }
],
- image: 'ruby:2.7' }
+ image: 'image:1.0' }
end
before do
@@ -217,7 +224,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do
{ 'local' => local_file },
{ 'remote' => remote_url }
],
- image: 'ruby:2.7' }
+ image: 'image:1.0' }
end
before do
@@ -269,7 +276,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do
context 'defined as an array' do
let(:values) do
{ include: [full_local_file_path, remote_url],
- image: 'ruby:2.7' }
+ image: 'image:1.0' }
end
it 'expands the variable' do
@@ -281,7 +288,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do
context 'defined as an array of hashes' do
let(:values) do
{ include: [{ local: full_local_file_path }, { remote: remote_url }],
- image: 'ruby:2.7' }
+ image: 'image:1.0' }
end
it 'expands the variable' do
@@ -303,7 +310,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do
context 'project name' do
let(:values) do
{ include: { project: '$CI_PROJECT_PATH', file: local_file },
- image: 'ruby:2.7' }
+ image: 'image:1.0' }
end
it 'expands the variable', :aggregate_failures do
@@ -315,7 +322,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do
context 'with multiple files' do
let(:values) do
{ include: { project: project.full_path, file: [full_local_file_path, 'another_file_path.yml'] },
- image: 'ruby:2.7' }
+ image: 'image:1.0' }
end
it 'expands the variable' do
@@ -327,7 +334,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do
context 'when include variable has an unsupported type for variable expansion' do
let(:values) do
{ include: { project: project.id, file: local_file },
- image: 'ruby:2.7' }
+ image: 'image:1.0' }
end
it 'does not invoke expansion for the variable', :aggregate_failures do
@@ -365,7 +372,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do
let(:values) do
{ include: [{ remote: remote_url },
{ local: local_file, rules: [{ if: "$CI_PROJECT_ID == '#{project_id}'" }] }],
- image: 'ruby:2.7' }
+ image: 'image:1.0' }
end
context 'when the rules matches' do
@@ -385,5 +392,27 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do
end
end
end
+
+ context "when locations are same after masking variables" do
+ let(:variables) do
+ Gitlab::Ci::Variables::Collection.new([
+ { 'key' => 'GITLAB_TOKEN', 'value' => 'secret-file1', 'masked' => true },
+ { 'key' => 'GITLAB_TOKEN', 'value' => 'secret-file2', 'masked' => true }
+ ])
+ end
+
+ let(:values) do
+ { include: [
+ { 'local' => 'hello/secret-file1.yml' },
+ { 'local' => 'hello/secret-file2.yml' }
+ ],
+ image: 'ruby:2.7' }
+ end
+
+ it 'has expanset with two' do
+ process
+ expect(mapper.expandset.size).to eq(2)
+ end
+ end
end
end
diff --git a/spec/lib/gitlab/ci/config/external/processor_spec.rb b/spec/lib/gitlab/ci/config/external/processor_spec.rb
index 97bd74721f2..56cd006717e 100644
--- a/spec/lib/gitlab/ci/config/external/processor_spec.rb
+++ b/spec/lib/gitlab/ci/config/external/processor_spec.rb
@@ -22,10 +22,10 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do
end
describe "#perform" do
- subject { processor.perform }
+ subject(:perform) { processor.perform }
context 'when no external files defined' do
- let(:values) { { image: 'ruby:2.7' } }
+ let(:values) { { image: 'image:1.0' } }
it 'returns the same values' do
expect(processor.perform).to eq(values)
@@ -33,7 +33,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do
end
context 'when an invalid local file is defined' do
- let(:values) { { include: '/lib/gitlab/ci/templates/non-existent-file.yml', image: 'ruby:2.7' } }
+ let(:values) { { include: '/lib/gitlab/ci/templates/non-existent-file.yml', image: 'image:1.0' } }
it 'raises an error' do
expect { processor.perform }.to raise_error(
@@ -45,7 +45,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do
context 'when an invalid remote file is defined' do
let(:remote_file) { 'http://doesntexist.com/.gitlab-ci-1.yml' }
- let(:values) { { include: remote_file, image: 'ruby:2.7' } }
+ let(:values) { { include: remote_file, image: 'image:1.0' } }
before do
stub_full_request(remote_file).and_raise(SocketError.new('Some HTTP error'))
@@ -61,7 +61,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do
context 'with a valid remote external file is defined' do
let(:remote_file) { 'https://gitlab.com/gitlab-org/gitlab-foss/blob/1234/.gitlab-ci-1.yml' }
- let(:values) { { include: remote_file, image: 'ruby:2.7' } }
+ let(:values) { { include: remote_file, image: 'image:1.0' } }
let(:external_file_content) do
<<-HEREDOC
before_script:
@@ -95,7 +95,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do
end
context 'with a valid local external file is defined' do
- let(:values) { { include: '/lib/gitlab/ci/templates/template.yml', image: 'ruby:2.7' } }
+ let(:values) { { include: '/lib/gitlab/ci/templates/template.yml', image: 'image:1.0' } }
let(:local_file_content) do
<<-HEREDOC
before_script:
@@ -133,7 +133,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do
let(:values) do
{
include: external_files,
- image: 'ruby:2.7'
+ image: 'image:1.0'
}
end
@@ -165,7 +165,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do
end
context 'when external files are defined but not valid' do
- let(:values) { { include: '/lib/gitlab/ci/templates/template.yml', image: 'ruby:2.7' } }
+ let(:values) { { include: '/lib/gitlab/ci/templates/template.yml', image: 'image:1.0' } }
let(:local_file_content) { 'invalid content file ////' }
@@ -187,7 +187,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do
let(:values) do
{
include: remote_file,
- image: 'ruby:2.7'
+ image: 'image:1.0'
}
end
@@ -200,7 +200,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do
it 'takes precedence' do
stub_full_request(remote_file).to_return(body: remote_file_content)
- expect(processor.perform[:image]).to eq('ruby:2.7')
+ expect(processor.perform[:image]).to eq('image:1.0')
end
end
@@ -210,7 +210,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do
include: [
{ local: '/local/file.yml' }
],
- image: 'ruby:2.7'
+ image: 'image:1.0'
}
end
@@ -262,6 +262,18 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do
expect(process_obs_count).to eq(3)
end
+
+ it 'stores includes' do
+ perform
+
+ expect(context.includes).to contain_exactly(
+ { type: :local, location: '/local/file.yml', extra: {}, context_project: project.full_path, context_sha: '12345' },
+ { type: :template, location: 'Ruby.gitlab-ci.yml', extra: {}, context_project: project.full_path, context_sha: '12345' },
+ { type: :remote, location: 'http://my.domain.com/config.yml', extra: {}, context_project: project.full_path, context_sha: '12345' },
+ { type: :file, location: '/templates/my-workflow.yml', extra: { project: another_project.full_path, ref: 'HEAD' }, context_project: project.full_path, context_sha: '12345' },
+ { type: :local, location: '/templates/my-build.yml', extra: {}, context_project: another_project.full_path, context_sha: another_project.commit.sha }
+ )
+ end
end
context 'when user is reporter of another project' do
@@ -294,7 +306,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do
context 'when config includes an external configuration file via SSL web request' do
before do
stub_full_request('https://sha256.badssl.com/fake.yml', ip_address: '8.8.8.8')
- .to_return(body: 'image: ruby:2.6', status: 200)
+ .to_return(body: 'image: image:1.0', status: 200)
stub_full_request('https://self-signed.badssl.com/fake.yml', ip_address: '8.8.8.9')
.to_raise(OpenSSL::SSL::SSLError.new('SSL_connect returned=1 errno=0 state=error: certificate verify failed (self signed certificate)'))
@@ -303,7 +315,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do
context 'with an acceptable certificate' do
let(:values) { { include: 'https://sha256.badssl.com/fake.yml' } }
- it { is_expected.to include(image: 'ruby:2.6') }
+ it { is_expected.to include(image: 'image:1.0') }
end
context 'with a self-signed certificate' do
@@ -319,7 +331,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do
let(:values) do
{
include: { project: another_project.full_path, file: '/templates/my-build.yml' },
- image: 'ruby:2.7'
+ image: 'image:1.0'
}
end
@@ -349,7 +361,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do
project: another_project.full_path,
file: ['/templates/my-build.yml', '/templates/my-test.yml']
},
- image: 'ruby:2.7'
+ image: 'image:1.0'
}
end
@@ -377,13 +389,22 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do
output = processor.perform
expect(output.keys).to match_array([:image, :my_build, :my_test])
end
+
+ it 'stores includes' do
+ perform
+
+ expect(context.includes).to contain_exactly(
+ { type: :file, location: '/templates/my-build.yml', extra: { project: another_project.full_path, ref: 'HEAD' }, context_project: project.full_path, context_sha: '12345' },
+ { type: :file, location: '/templates/my-test.yml', extra: { project: another_project.full_path, ref: 'HEAD' }, context_project: project.full_path, context_sha: '12345' }
+ )
+ end
end
context 'when local file path has wildcard' do
- let_it_be(:project) { create(:project, :repository) }
+ let(:project) { create(:project, :repository) }
let(:values) do
- { include: 'myfolder/*.yml', image: 'ruby:2.7' }
+ { include: 'myfolder/*.yml', image: 'image:1.0' }
end
before do
@@ -412,6 +433,15 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do
output = processor.perform
expect(output.keys).to match_array([:image, :my_build, :my_test])
end
+
+ it 'stores includes' do
+ perform
+
+ expect(context.includes).to contain_exactly(
+ { type: :local, location: 'myfolder/file1.yml', extra: {}, context_project: project.full_path, context_sha: '12345' },
+ { type: :local, location: 'myfolder/file2.yml', extra: {}, context_project: project.full_path, context_sha: '12345' }
+ )
+ end
end
context 'when rules defined' do
diff --git a/spec/lib/gitlab/ci/config_spec.rb b/spec/lib/gitlab/ci/config_spec.rb
index 05ff1f3618b..3ba6a9059c6 100644
--- a/spec/lib/gitlab/ci/config_spec.rb
+++ b/spec/lib/gitlab/ci/config_spec.rb
@@ -20,7 +20,7 @@ RSpec.describe Gitlab::Ci::Config do
context 'when config is valid' do
let(:yml) do
<<-EOS
- image: ruby:2.7
+ image: image:1.0
rspec:
script:
@@ -32,7 +32,7 @@ RSpec.describe Gitlab::Ci::Config do
describe '#to_hash' do
it 'returns hash created from string' do
hash = {
- image: 'ruby:2.7',
+ image: 'image:1.0',
rspec: {
script: ['gem install rspec',
'rspec']
@@ -104,12 +104,32 @@ RSpec.describe Gitlab::Ci::Config do
end
it { is_expected.to contain_exactly('Jobs/Deploy.gitlab-ci.yml', 'Jobs/Build.gitlab-ci.yml') }
+
+ it 'stores includes' do
+ expect(config.metadata[:includes]).to contain_exactly(
+ { type: :template,
+ location: 'Jobs/Deploy.gitlab-ci.yml',
+ extra: {},
+ context_project: nil,
+ context_sha: nil },
+ { type: :template,
+ location: 'Jobs/Build.gitlab-ci.yml',
+ extra: {},
+ context_project: nil,
+ context_sha: nil },
+ { type: :remote,
+ location: 'https://example.com/gitlab-ci.yml',
+ extra: {},
+ context_project: nil,
+ context_sha: nil }
+ )
+ end
end
context 'when using extendable hash' do
let(:yml) do
<<-EOS
- image: ruby:2.7
+ image: image:1.0
rspec:
script: rspec
@@ -122,7 +142,7 @@ RSpec.describe Gitlab::Ci::Config do
it 'correctly extends the hash' do
hash = {
- image: 'ruby:2.7',
+ image: 'image:1.0',
rspec: { script: 'rspec' },
test: {
extends: 'rspec',
@@ -212,7 +232,7 @@ RSpec.describe Gitlab::Ci::Config do
let(:yml) do
<<-EOS
image:
- name: ruby:2.7
+ name: image:1.0
ports:
- 80
EOS
@@ -226,12 +246,12 @@ RSpec.describe Gitlab::Ci::Config do
context 'in the job image' do
let(:yml) do
<<-EOS
- image: ruby:2.7
+ image: image:1.0
test:
script: rspec
image:
- name: ruby:2.7
+ name: image:1.0
ports:
- 80
EOS
@@ -245,11 +265,11 @@ RSpec.describe Gitlab::Ci::Config do
context 'in the services' do
let(:yml) do
<<-EOS
- image: ruby:2.7
+ image: image:1.0
test:
script: rspec
- image: ruby:2.7
+ image: image:1.0
services:
- name: test
alias: test
@@ -325,7 +345,7 @@ RSpec.describe Gitlab::Ci::Config do
- project: '$MAIN_PROJECT'
ref: '$REF'
file: '$FILENAME'
- image: ruby:2.7
+ image: image:1.0
HEREDOC
end
@@ -364,7 +384,7 @@ RSpec.describe Gitlab::Ci::Config do
it 'returns a composed hash' do
composed_hash = {
before_script: local_location_hash[:before_script],
- image: "ruby:2.7",
+ image: "image:1.0",
rspec: { script: ["bundle exec rspec"] },
variables: remote_file_hash[:variables]
}
@@ -403,6 +423,26 @@ RSpec.describe Gitlab::Ci::Config do
end
end
end
+
+ it 'stores includes' do
+ expect(config.metadata[:includes]).to contain_exactly(
+ { type: :local,
+ location: local_location,
+ extra: {},
+ context_project: project.full_path,
+ context_sha: '12345' },
+ { type: :remote,
+ location: remote_location,
+ extra: {},
+ context_project: project.full_path,
+ context_sha: '12345' },
+ { type: :file,
+ location: '.gitlab-ci.yml',
+ extra: { project: main_project.full_path, ref: 'HEAD' },
+ context_project: project.full_path,
+ context_sha: '12345' }
+ )
+ end
end
context "when gitlab_ci.yml has invalid 'include' defined" do
@@ -481,7 +521,7 @@ RSpec.describe Gitlab::Ci::Config do
include:
- #{remote_location}
- image: ruby:2.7
+ image: image:1.0
HEREDOC
end
@@ -492,7 +532,7 @@ RSpec.describe Gitlab::Ci::Config do
end
it 'takes precedence' do
- expect(config.to_hash).to eq({ image: 'ruby:2.7' })
+ expect(config.to_hash).to eq({ image: 'image:1.0' })
end
end
@@ -699,7 +739,7 @@ RSpec.describe Gitlab::Ci::Config do
- #{local_location}
- #{other_file_location}
- image: ruby:2.7
+ image: image:1.0
HEREDOC
end
@@ -718,7 +758,7 @@ RSpec.describe Gitlab::Ci::Config do
it 'returns a composed hash' do
composed_hash = {
before_script: local_location_hash[:before_script],
- image: "ruby:2.7",
+ image: "image:1.0",
build: { stage: "build", script: "echo hello" },
rspec: { stage: "test", script: "bundle exec rspec" }
}
@@ -735,7 +775,7 @@ RSpec.describe Gitlab::Ci::Config do
- local: #{local_location}
rules:
- if: $CI_PROJECT_ID == "#{project_id}"
- image: ruby:2.7
+ image: image:1.0
HEREDOC
end
@@ -763,7 +803,7 @@ RSpec.describe Gitlab::Ci::Config do
- local: #{local_location}
rules:
- exists: "#{filename}"
- image: ruby:2.7
+ image: image:1.0
HEREDOC
end
diff --git a/spec/lib/gitlab/ci/parsers/security/common_spec.rb b/spec/lib/gitlab/ci/parsers/security/common_spec.rb
index 1e96c717a4f..dfc5dec1481 100644
--- a/spec/lib/gitlab/ci/parsers/security/common_spec.rb
+++ b/spec/lib/gitlab/ci/parsers/security/common_spec.rb
@@ -4,6 +4,18 @@ require 'spec_helper'
RSpec.describe Gitlab::Ci::Parsers::Security::Common do
describe '#parse!' do
+ let_it_be(:scanner_data) do
+ {
+ scan: {
+ scanner: {
+ id: "gemnasium",
+ name: "Gemnasium",
+ version: "2.1.0"
+ }
+ }
+ }
+ end
+
where(vulnerability_finding_signatures_enabled: [true, false])
with_them do
let_it_be(:pipeline) { create(:ci_pipeline) }
@@ -30,7 +42,9 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Common do
describe 'schema validation' do
let(:validator_class) { Gitlab::Ci::Parsers::Security::Validators::SchemaValidator }
- let(:parser) { described_class.new('{}', report, vulnerability_finding_signatures_enabled, validate: validate) }
+ let(:data) { {}.merge(scanner_data) }
+ let(:json_data) { data.to_json }
+ let(:parser) { described_class.new(json_data, report, vulnerability_finding_signatures_enabled, validate: validate) }
subject(:parse_report) { parser.parse! }
@@ -38,172 +52,138 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Common do
allow(validator_class).to receive(:new).and_call_original
end
- context 'when show_report_validation_warnings is enabled' do
- before do
- stub_feature_flags(show_report_validation_warnings: true)
- end
-
- context 'when the validate flag is set to `false`' do
- let(:validate) { false }
- let(:valid?) { false }
- let(:errors) { ['foo'] }
-
- before do
- allow_next_instance_of(validator_class) do |instance|
- allow(instance).to receive(:valid?).and_return(valid?)
- allow(instance).to receive(:errors).and_return(errors)
- end
-
- allow(parser).to receive_messages(create_scanner: true, create_scan: true)
- end
-
- it 'instantiates the validator with correct params' do
- parse_report
-
- expect(validator_class).to have_received(:new).with(report.type, {}, report.version)
- end
-
- context 'when the report data is not valid according to the schema' do
- it 'adds warnings to the report' do
- expect { parse_report }.to change { report.warnings }.from([]).to([{ message: 'foo', type: 'Schema' }])
- end
-
- it 'keeps the execution flow as normal' do
- parse_report
+ context 'when the validate flag is set to `false`' do
+ let(:validate) { false }
+ let(:valid?) { false }
+ let(:errors) { ['foo'] }
+ let(:warnings) { ['bar'] }
- expect(parser).to have_received(:create_scanner)
- expect(parser).to have_received(:create_scan)
- end
+ before do
+ allow_next_instance_of(validator_class) do |instance|
+ allow(instance).to receive(:valid?).and_return(valid?)
+ allow(instance).to receive(:errors).and_return(errors)
+ allow(instance).to receive(:warnings).and_return(warnings)
end
- context 'when the report data is valid according to the schema' do
- let(:valid?) { true }
- let(:errors) { [] }
-
- it 'does not add warnings to the report' do
- expect { parse_report }.not_to change { report.errors }
- end
-
- it 'keeps the execution flow as normal' do
- parse_report
-
- expect(parser).to have_received(:create_scanner)
- expect(parser).to have_received(:create_scan)
- end
- end
+ allow(parser).to receive_messages(create_scanner: true, create_scan: true)
end
- context 'when the validate flag is set to `true`' do
- let(:validate) { true }
- let(:valid?) { false }
- let(:errors) { ['foo'] }
+ it 'instantiates the validator with correct params' do
+ parse_report
- before do
- allow_next_instance_of(validator_class) do |instance|
- allow(instance).to receive(:valid?).and_return(valid?)
- allow(instance).to receive(:errors).and_return(errors)
- end
+ expect(validator_class).to have_received(:new).with(
+ report.type,
+ data.deep_stringify_keys,
+ report.version,
+ project: pipeline.project,
+ scanner: data.dig(:scan, :scanner).deep_stringify_keys
+ )
+ end
- allow(parser).to receive_messages(create_scanner: true, create_scan: true)
+ context 'when the report data is not valid according to the schema' do
+ it 'adds warnings to the report' do
+ expect { parse_report }.to change { report.warnings }.from([]).to(
+ [
+ { message: 'foo', type: 'Schema' },
+ { message: 'bar', type: 'Schema' }
+ ]
+ )
end
- it 'instantiates the validator with correct params' do
+ it 'keeps the execution flow as normal' do
parse_report
- expect(validator_class).to have_received(:new).with(report.type, {}, report.version)
+ expect(parser).to have_received(:create_scanner)
+ expect(parser).to have_received(:create_scan)
end
+ end
- context 'when the report data is not valid according to the schema' do
- it 'adds errors to the report' do
- expect { parse_report }.to change { report.errors }.from([]).to([{ message: 'foo', type: 'Schema' }])
- end
-
- it 'does not try to create report entities' do
- parse_report
+ context 'when the report data is valid according to the schema' do
+ let(:valid?) { true }
+ let(:errors) { [] }
+ let(:warnings) { [] }
- expect(parser).not_to have_received(:create_scanner)
- expect(parser).not_to have_received(:create_scan)
- end
+ it 'does not add errors to the report' do
+ expect { parse_report }.not_to change { report.errors }
end
- context 'when the report data is valid according to the schema' do
- let(:valid?) { true }
- let(:errors) { [] }
-
- it 'does not add errors to the report' do
- expect { parse_report }.not_to change { report.errors }.from([])
- end
+ it 'does not add warnings to the report' do
+ expect { parse_report }.not_to change { report.warnings }
+ end
- it 'keeps the execution flow as normal' do
- parse_report
+ it 'keeps the execution flow as normal' do
+ parse_report
- expect(parser).to have_received(:create_scanner)
- expect(parser).to have_received(:create_scan)
- end
+ expect(parser).to have_received(:create_scanner)
+ expect(parser).to have_received(:create_scan)
end
end
end
- context 'when show_report_validation_warnings is disabled' do
- before do
- stub_feature_flags(show_report_validation_warnings: false)
- end
-
- context 'when the validate flag is set as `false`' do
- let(:validate) { false }
+ context 'when the validate flag is set to `true`' do
+ let(:validate) { true }
+ let(:valid?) { false }
+ let(:errors) { ['foo'] }
+ let(:warnings) { ['bar'] }
- it 'does not run the validation logic' do
- parse_report
-
- expect(validator_class).not_to have_received(:new)
+ before do
+ allow_next_instance_of(validator_class) do |instance|
+ allow(instance).to receive(:valid?).and_return(valid?)
+ allow(instance).to receive(:errors).and_return(errors)
+ allow(instance).to receive(:warnings).and_return(warnings)
end
+
+ allow(parser).to receive_messages(create_scanner: true, create_scan: true)
end
- context 'when the validate flag is set as `true`' do
- let(:validate) { true }
- let(:valid?) { false }
+ it 'instantiates the validator with correct params' do
+ parse_report
- before do
- allow_next_instance_of(validator_class) do |instance|
- allow(instance).to receive(:valid?).and_return(valid?)
- allow(instance).to receive(:errors).and_return(['foo'])
- end
+ expect(validator_class).to have_received(:new).with(
+ report.type,
+ data.deep_stringify_keys,
+ report.version,
+ project: pipeline.project,
+ scanner: data.dig(:scan, :scanner).deep_stringify_keys
+ )
+ end
- allow(parser).to receive_messages(create_scanner: true, create_scan: true)
+ context 'when the report data is not valid according to the schema' do
+ it 'adds errors to the report' do
+ expect { parse_report }.to change { report.errors }.from([]).to(
+ [
+ { message: 'foo', type: 'Schema' },
+ { message: 'bar', type: 'Schema' }
+ ]
+ )
end
- it 'instantiates the validator with correct params' do
+ it 'does not try to create report entities' do
parse_report
- expect(validator_class).to have_received(:new).with(report.type, {}, report.version)
+ expect(parser).not_to have_received(:create_scanner)
+ expect(parser).not_to have_received(:create_scan)
end
+ end
- context 'when the report data is not valid according to the schema' do
- it 'adds errors to the report' do
- expect { parse_report }.to change { report.errors }.from([]).to([{ message: 'foo', type: 'Schema' }])
- end
-
- it 'does not try to create report entities' do
- parse_report
+ context 'when the report data is valid according to the schema' do
+ let(:valid?) { true }
+ let(:errors) { [] }
+ let(:warnings) { [] }
- expect(parser).not_to have_received(:create_scanner)
- expect(parser).not_to have_received(:create_scan)
- end
+ it 'does not add errors to the report' do
+ expect { parse_report }.not_to change { report.errors }.from([])
end
- context 'when the report data is valid according to the schema' do
- let(:valid?) { true }
-
- it 'does not add errors to the report' do
- expect { parse_report }.not_to change { report.errors }.from([])
- end
+ it 'does not add warnings to the report' do
+ expect { parse_report }.not_to change { report.warnings }.from([])
+ end
- it 'keeps the execution flow as normal' do
- parse_report
+ it 'keeps the execution flow as normal' do
+ parse_report
- expect(parser).to have_received(:create_scanner)
- expect(parser).to have_received(:create_scan)
- end
+ expect(parser).to have_received(:create_scanner)
+ expect(parser).to have_received(:create_scan)
end
end
end
diff --git a/spec/lib/gitlab/ci/parsers/security/validators/schema_validator_spec.rb b/spec/lib/gitlab/ci/parsers/security/validators/schema_validator_spec.rb
index c83427b68ef..f6409c8b01f 100644
--- a/spec/lib/gitlab/ci/parsers/security/validators/schema_validator_spec.rb
+++ b/spec/lib/gitlab/ci/parsers/security/validators/schema_validator_spec.rb
@@ -3,6 +3,18 @@
require 'spec_helper'
RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do
+ let_it_be(:project) { create(:project) }
+
+ let(:scanner) do
+ {
+ 'id' => 'gemnasium',
+ 'name' => 'Gemnasium',
+ 'version' => '2.1.0'
+ }
+ end
+
+ let(:validator) { described_class.new(report_type, report_data, report_version, project: project, scanner: scanner) }
+
describe 'SUPPORTED_VERSIONS' do
schema_path = Rails.root.join("lib", "gitlab", "ci", "parsers", "security", "validators", "schemas")
@@ -47,48 +59,652 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do
end
end
- using RSpec::Parameterized::TableSyntax
+ describe '#valid?' do
+ subject { validator.valid? }
- where(:report_type, :report_version, :expected_errors, :valid_data) do
- 'sast' | '10.0.0' | ['root is missing required keys: vulnerabilities'] | { 'version' => '10.0.0', 'vulnerabilities' => [] }
- :sast | '10.0.0' | ['root is missing required keys: vulnerabilities'] | { 'version' => '10.0.0', 'vulnerabilities' => [] }
- :secret_detection | '10.0.0' | ['root is missing required keys: vulnerabilities'] | { 'version' => '10.0.0', 'vulnerabilities' => [] }
- end
+ context 'when given a supported schema version' do
+ let(:report_type) { :dast }
+ let(:report_version) { described_class::SUPPORTED_VERSIONS[report_type].last }
- with_them do
- let(:validator) { described_class.new(report_type, report_data, report_version) }
+ context 'and the report is valid' do
+ let(:report_data) do
+ {
+ 'version' => report_version,
+ 'vulnerabilities' => []
+ }
+ end
- describe '#valid?' do
- subject { validator.valid? }
+ it { is_expected.to be_truthy }
+ end
- context 'when given data is invalid according to the schema' do
- let(:report_data) { {} }
+ context 'and the report is invalid' do
+ let(:report_data) do
+ {
+ 'version' => report_version
+ }
+ end
it { is_expected.to be_falsey }
+
+ it 'logs related information' do
+ expect(Gitlab::AppLogger).to receive(:info).with(
+ message: "security report schema validation problem",
+ security_report_type: report_type,
+ security_report_version: report_version,
+ project_id: project.id,
+ security_report_failure: 'schema_validation_fails',
+ security_report_scanner_id: 'gemnasium',
+ security_report_scanner_version: '2.1.0'
+ )
+
+ subject
+ end
end
+ end
+
+ context 'when given a deprecated schema version' do
+ let(:report_type) { :dast }
+ let(:report_version) { described_class::DEPRECATED_VERSIONS[report_type].last }
- context 'when given data is valid according to the schema' do
- let(:report_data) { valid_data }
+ context 'and the report passes schema validation' do
+ let(:report_data) do
+ {
+ 'version' => '10.0.0',
+ 'vulnerabilities' => []
+ }
+ end
it { is_expected.to be_truthy }
+
+ it 'logs related information' do
+ expect(Gitlab::AppLogger).to receive(:info).with(
+ message: "security report schema validation problem",
+ security_report_type: report_type,
+ security_report_version: report_version,
+ project_id: project.id,
+ security_report_failure: 'using_deprecated_schema_version',
+ security_report_scanner_id: 'gemnasium',
+ security_report_scanner_version: '2.1.0'
+ )
+
+ subject
+ end
end
- context 'when no report_version is provided' do
- let(:report_version) { nil }
- let(:report_data) { valid_data }
+ context 'and the report does not pass schema validation' do
+ context 'and enforce_security_report_validation is enabled' do
+ before do
+ stub_feature_flags(enforce_security_report_validation: true)
+ end
+
+ let(:report_data) do
+ {
+ 'version' => 'V2.7.0'
+ }
+ end
- it 'does not fail' do
- expect { subject }.not_to raise_error
+ it { is_expected.to be_falsey }
+ end
+
+ context 'and enforce_security_report_validation is disabled' do
+ before do
+ stub_feature_flags(enforce_security_report_validation: false)
+ end
+
+ let(:report_data) do
+ {
+ 'version' => 'V2.7.0'
+ }
+ end
+
+ it { is_expected.to be_truthy }
end
end
end
- describe '#errors' do
- let(:report_data) { { 'version' => '10.0.0' } }
+ context 'when given an unsupported schema version' do
+ let(:report_type) { :dast }
+ let(:report_version) { "12.37.0" }
+
+ context 'if enforce_security_report_validation is enabled' do
+ before do
+ stub_feature_flags(enforce_security_report_validation: true)
+ end
+
+ context 'and the report is valid' do
+ let(:report_data) do
+ {
+ 'version' => report_version,
+ 'vulnerabilities' => []
+ }
+ end
+
+ it { is_expected.to be_falsey }
+
+ it 'logs related information' do
+ expect(Gitlab::AppLogger).to receive(:info).with(
+ message: "security report schema validation problem",
+ security_report_type: report_type,
+ security_report_version: report_version,
+ project_id: project.id,
+ security_report_failure: 'using_unsupported_schema_version',
+ security_report_scanner_id: 'gemnasium',
+ security_report_scanner_version: '2.1.0'
+ )
+
+ subject
+ end
+ end
+
+ context 'and the report is invalid' do
+ let(:report_data) do
+ {
+ 'version' => report_version
+ }
+ end
+
+ context 'and scanner information is empty' do
+ let(:scanner) { {} }
+
+ it 'logs related information' do
+ expect(Gitlab::AppLogger).to receive(:info).with(
+ message: "security report schema validation problem",
+ security_report_type: report_type,
+ security_report_version: report_version,
+ project_id: project.id,
+ security_report_failure: 'schema_validation_fails',
+ security_report_scanner_id: nil,
+ security_report_scanner_version: nil
+ )
+
+ expect(Gitlab::AppLogger).to receive(:info).with(
+ message: "security report schema validation problem",
+ security_report_type: report_type,
+ security_report_version: report_version,
+ project_id: project.id,
+ security_report_failure: 'using_unsupported_schema_version',
+ security_report_scanner_id: nil,
+ security_report_scanner_version: nil
+ )
+
+ subject
+ end
+ end
+
+ it { is_expected.to be_falsey }
+ end
+ end
+
+ context 'if enforce_security_report_validation is disabled' do
+ before do
+ stub_feature_flags(enforce_security_report_validation: false)
+ end
+
+ context 'and the report is valid' do
+ let(:report_data) do
+ {
+ 'version' => report_version,
+ 'vulnerabilities' => []
+ }
+ end
+
+ it { is_expected.to be_truthy }
+ end
+
+ context 'and the report is invalid' do
+ let(:report_data) do
+ {
+ 'version' => report_version
+ }
+ end
+
+ it { is_expected.to be_truthy }
+ end
+ end
+ end
+ end
- subject { validator.errors }
+ describe '#errors' do
+ subject { validator.errors }
- it { is_expected.to eq(expected_errors) }
+ context 'when given a supported schema version' do
+ let(:report_type) { :dast }
+ let(:report_version) { described_class::SUPPORTED_VERSIONS[report_type].last }
+
+ context 'and the report is valid' do
+ let(:report_data) do
+ {
+ 'version' => report_version,
+ 'vulnerabilities' => []
+ }
+ end
+
+ let(:expected_errors) { [] }
+
+ it { is_expected.to match_array(expected_errors) }
+ end
+
+ context 'and the report is invalid' do
+ let(:report_data) do
+ {
+ 'version' => report_version
+ }
+ end
+
+ context 'if enforce_security_report_validation is enabled' do
+ before do
+ stub_feature_flags(enforce_security_report_validation: project)
+ end
+
+ let(:expected_errors) do
+ [
+ 'root is missing required keys: vulnerabilities'
+ ]
+ end
+
+ it { is_expected.to match_array(expected_errors) }
+ end
+
+ context 'if enforce_security_report_validation is disabled' do
+ before do
+ stub_feature_flags(enforce_security_report_validation: false)
+ end
+
+ let(:expected_errors) { [] }
+
+ it { is_expected.to match_array(expected_errors) }
+ end
+ end
+ end
+
+ context 'when given a deprecated schema version' do
+ let(:report_type) { :dast }
+ let(:report_version) { described_class::DEPRECATED_VERSIONS[report_type].last }
+
+ context 'and the report passes schema validation' do
+ let(:report_data) do
+ {
+ 'version' => '10.0.0',
+ 'vulnerabilities' => []
+ }
+ end
+
+ let(:expected_errors) { [] }
+
+ it { is_expected.to match_array(expected_errors) }
+ end
+
+ context 'and the report does not pass schema validation' do
+ context 'and enforce_security_report_validation is enabled' do
+ before do
+ stub_feature_flags(enforce_security_report_validation: true)
+ end
+
+ let(:report_data) do
+ {
+ 'version' => 'V2.7.0'
+ }
+ end
+
+ let(:expected_errors) do
+ [
+ "property '/version' does not match pattern: ^[0-9]+\\.[0-9]+\\.[0-9]+$",
+ "root is missing required keys: vulnerabilities"
+ ]
+ end
+
+ it { is_expected.to match_array(expected_errors) }
+ end
+
+ context 'and enforce_security_report_validation is disabled' do
+ before do
+ stub_feature_flags(enforce_security_report_validation: false)
+ end
+
+ let(:report_data) do
+ {
+ 'version' => 'V2.7.0'
+ }
+ end
+
+ let(:expected_errors) { [] }
+
+ it { is_expected.to match_array(expected_errors) }
+ end
+ end
+ end
+
+ context 'when given an unsupported schema version' do
+ let(:report_type) { :dast }
+ let(:report_version) { "12.37.0" }
+
+ context 'if enforce_security_report_validation is enabled' do
+ before do
+ stub_feature_flags(enforce_security_report_validation: true)
+ end
+
+ context 'and the report is valid' do
+ let(:report_data) do
+ {
+ 'version' => report_version,
+ 'vulnerabilities' => []
+ }
+ end
+
+ let(:expected_errors) do
+ [
+ "Version 12.37.0 for report type dast is unsupported, supported versions for this report type are: 14.0.0, 14.0.1, 14.0.2, 14.0.3, 14.0.4, 14.0.5, 14.0.6, 14.1.0, 14.1.1"
+ ]
+ end
+
+ it { is_expected.to match_array(expected_errors) }
+ end
+
+ context 'and the report is invalid' do
+ let(:report_data) do
+ {
+ 'version' => report_version
+ }
+ end
+
+ let(:expected_errors) do
+ [
+ "Version 12.37.0 for report type dast is unsupported, supported versions for this report type are: 14.0.0, 14.0.1, 14.0.2, 14.0.3, 14.0.4, 14.0.5, 14.0.6, 14.1.0, 14.1.1",
+ "root is missing required keys: vulnerabilities"
+ ]
+ end
+
+ it { is_expected.to match_array(expected_errors) }
+ end
+ end
+
+ context 'if enforce_security_report_validation is disabled' do
+ before do
+ stub_feature_flags(enforce_security_report_validation: false)
+ end
+
+ context 'and the report is valid' do
+ let(:report_data) do
+ {
+ 'version' => report_version,
+ 'vulnerabilities' => []
+ }
+ end
+
+ let(:expected_errors) { [] }
+
+ it { is_expected.to match_array(expected_errors) }
+ end
+
+ context 'and the report is invalid' do
+ let(:report_data) do
+ {
+ 'version' => report_version
+ }
+ end
+
+ let(:expected_errors) { [] }
+
+ it { is_expected.to match_array(expected_errors) }
+ end
+ end
+ end
+ end
+
+ describe '#deprecation_warnings' do
+ subject { validator.deprecation_warnings }
+
+ context 'when given a supported schema version' do
+ let(:report_type) { :dast }
+ let(:report_version) { described_class::SUPPORTED_VERSIONS[report_type].last }
+
+ let(:expected_deprecation_warnings) { [] }
+
+ context 'and the report is valid' do
+ let(:report_data) do
+ {
+ 'version' => report_version,
+ 'vulnerabilities' => []
+ }
+ end
+
+ it { is_expected.to match_array(expected_deprecation_warnings) }
+ end
+
+ context 'and the report is invalid' do
+ let(:report_data) do
+ {
+ 'version' => report_version
+ }
+ end
+
+ it { is_expected.to match_array(expected_deprecation_warnings) }
+ end
+ end
+
+ context 'when given a deprecated schema version' do
+ let(:report_type) { :dast }
+ let(:report_version) { described_class::DEPRECATED_VERSIONS[report_type].last }
+ let(:expected_deprecation_warnings) do
+ [
+ "Version V2.7.0 for report type dast has been deprecated, supported versions for this report type are: 14.0.0, 14.0.1, 14.0.2, 14.0.3, 14.0.4, 14.0.5, 14.0.6, 14.1.0, 14.1.1"
+ ]
+ end
+
+ context 'and the report passes schema validation' do
+ let(:report_data) do
+ {
+ 'version' => report_version,
+ 'vulnerabilities' => []
+ }
+ end
+
+ it { is_expected.to match_array(expected_deprecation_warnings) }
+ end
+
+ context 'and the report does not pass schema validation' do
+ let(:report_data) do
+ {
+ 'version' => 'V2.7.0'
+ }
+ end
+
+ it { is_expected.to match_array(expected_deprecation_warnings) }
+ end
+ end
+
+ context 'when given an unsupported schema version' do
+ let(:report_type) { :dast }
+ let(:report_version) { "21.37.0" }
+ let(:expected_deprecation_warnings) { [] }
+ let(:report_data) do
+ {
+ 'version' => report_version,
+ 'vulnerabilities' => []
+ }
+ end
+
+ it { is_expected.to match_array(expected_deprecation_warnings) }
+ end
+ end
+
+ describe '#warnings' do
+ subject { validator.warnings }
+
+ context 'when given a supported schema version' do
+ let(:report_type) { :dast }
+ let(:report_version) { described_class::SUPPORTED_VERSIONS[report_type].last }
+
+ context 'and the report is valid' do
+ let(:report_data) do
+ {
+ 'version' => report_version,
+ 'vulnerabilities' => []
+ }
+ end
+
+ let(:expected_warnings) { [] }
+
+ it { is_expected.to match_array(expected_warnings) }
+ end
+
+ context 'and the report is invalid' do
+ let(:report_data) do
+ {
+ 'version' => report_version
+ }
+ end
+
+ context 'if enforce_security_report_validation is enabled' do
+ before do
+ stub_feature_flags(enforce_security_report_validation: project)
+ end
+
+ let(:expected_warnings) { [] }
+
+ it { is_expected.to match_array(expected_warnings) }
+ end
+
+ context 'if enforce_security_report_validation is disabled' do
+ before do
+ stub_feature_flags(enforce_security_report_validation: false)
+ end
+
+ let(:expected_warnings) do
+ [
+ 'root is missing required keys: vulnerabilities'
+ ]
+ end
+
+ it { is_expected.to match_array(expected_warnings) }
+ end
+ end
+ end
+
+ context 'when given a deprecated schema version' do
+ let(:report_type) { :dast }
+ let(:report_version) { described_class::DEPRECATED_VERSIONS[report_type].last }
+
+ context 'and the report passes schema validation' do
+ let(:report_data) do
+ {
+ 'vulnerabilities' => []
+ }
+ end
+
+ let(:expected_warnings) { [] }
+
+ it { is_expected.to match_array(expected_warnings) }
+ end
+
+ context 'and the report does not pass schema validation' do
+ let(:report_data) do
+ {
+ 'version' => 'V2.7.0'
+ }
+ end
+
+ context 'and enforce_security_report_validation is enabled' do
+ before do
+ stub_feature_flags(enforce_security_report_validation: true)
+ end
+
+ let(:expected_warnings) { [] }
+
+ it { is_expected.to match_array(expected_warnings) }
+ end
+
+ context 'and enforce_security_report_validation is disabled' do
+ before do
+ stub_feature_flags(enforce_security_report_validation: false)
+ end
+
+ let(:expected_warnings) do
+ [
+ "property '/version' does not match pattern: ^[0-9]+\\.[0-9]+\\.[0-9]+$",
+ "root is missing required keys: vulnerabilities"
+ ]
+ end
+
+ it { is_expected.to match_array(expected_warnings) }
+ end
+ end
+ end
+
+ context 'when given an unsupported schema version' do
+ let(:report_type) { :dast }
+ let(:report_version) { "12.37.0" }
+
+ context 'if enforce_security_report_validation is enabled' do
+ before do
+ stub_feature_flags(enforce_security_report_validation: true)
+ end
+
+ context 'and the report is valid' do
+ let(:report_data) do
+ {
+ 'version' => report_version,
+ 'vulnerabilities' => []
+ }
+ end
+
+ let(:expected_warnings) { [] }
+
+ it { is_expected.to match_array(expected_warnings) }
+ end
+
+ context 'and the report is invalid' do
+ let(:report_data) do
+ {
+ 'version' => report_version
+ }
+ end
+
+ let(:expected_warnings) { [] }
+
+ it { is_expected.to match_array(expected_warnings) }
+ end
+ end
+
+ context 'if enforce_security_report_validation is disabled' do
+ before do
+ stub_feature_flags(enforce_security_report_validation: false)
+ end
+
+ context 'and the report is valid' do
+ let(:report_data) do
+ {
+ 'version' => report_version,
+ 'vulnerabilities' => []
+ }
+ end
+
+ let(:expected_warnings) do
+ [
+ "Version 12.37.0 for report type dast is unsupported, supported versions for this report type are: 14.0.0, 14.0.1, 14.0.2, 14.0.3, 14.0.4, 14.0.5, 14.0.6, 14.1.0, 14.1.1"
+ ]
+ end
+
+ it { is_expected.to match_array(expected_warnings) }
+ end
+
+ context 'and the report is invalid' do
+ let(:report_data) do
+ {
+ 'version' => report_version
+ }
+ end
+
+ let(:expected_warnings) do
+ [
+ "Version 12.37.0 for report type dast is unsupported, supported versions for this report type are: 14.0.0, 14.0.1, 14.0.2, 14.0.3, 14.0.4, 14.0.5, 14.0.6, 14.1.0, 14.1.1",
+ "root is missing required keys: vulnerabilities"
+ ]
+ end
+
+ it { is_expected.to match_array(expected_warnings) }
+ end
+ end
end
end
end
diff --git a/spec/lib/gitlab/ci/pipeline/chain/limit/rate_limit_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/limit/rate_limit_spec.rb
new file mode 100644
index 00000000000..aa8aec2af4a
--- /dev/null
+++ b/spec/lib/gitlab/ci/pipeline/chain/limit/rate_limit_spec.rb
@@ -0,0 +1,179 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::Gitlab::Ci::Pipeline::Chain::Limit::RateLimit, :freeze_time, :clean_gitlab_redis_rate_limiting do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:namespace) { create(:namespace) }
+ let_it_be(:project, reload: true) { create(:project, namespace: namespace) }
+
+ let(:save_incompleted) { false }
+ let(:throttle_message) do
+ 'Too many pipelines created in the last minute. Try again later.'
+ end
+
+ let(:command) do
+ Gitlab::Ci::Pipeline::Chain::Command.new(
+ project: project,
+ current_user: user,
+ save_incompleted: save_incompleted
+ )
+ end
+
+ let(:pipeline) { build(:ci_pipeline, project: project, source: source) }
+ let(:source) { 'push' }
+ let(:step) { described_class.new(pipeline, command) }
+
+ def perform(count: 2)
+ count.times { step.perform! }
+ end
+
+ context 'when the limit is exceeded' do
+ before do
+ allow(Gitlab::ApplicationRateLimiter).to receive(:rate_limits)
+ .and_return(pipelines_create: { threshold: 1, interval: 1.minute })
+
+ stub_feature_flags(ci_throttle_pipelines_creation_dry_run: false)
+ end
+
+ it 'does not persist the pipeline' do
+ perform
+
+ expect(pipeline).not_to be_persisted
+ expect(pipeline.errors.added?(:base, throttle_message)).to be_truthy
+ end
+
+ it 'breaks the chain' do
+ perform
+
+ expect(step.break?).to be_truthy
+ end
+
+ it 'creates a log entry' do
+ expect(Gitlab::AppJsonLogger).to receive(:info).with(
+ a_hash_including(
+ class: described_class.name,
+ project_id: project.id,
+ subscription_plan: project.actual_plan_name,
+ commit_sha: command.sha
+ )
+ )
+
+ perform
+ end
+
+ context 'with child pipelines' do
+ let(:source) { 'parent_pipeline' }
+
+ it 'does not break the chain' do
+ perform
+
+ expect(step.break?).to be_falsey
+ end
+
+ it 'does not invalidate the pipeline' do
+ perform
+
+ expect(pipeline.errors).to be_empty
+ end
+
+ it 'does not log anything' do
+ expect(Gitlab::AppJsonLogger).not_to receive(:info)
+
+ perform
+ end
+ end
+
+ context 'when saving incompleted pipelines' do
+ let(:save_incompleted) { true }
+
+ it 'does not persist the pipeline' do
+ perform
+
+ expect(pipeline).not_to be_persisted
+ expect(pipeline.errors.added?(:base, throttle_message)).to be_truthy
+ end
+
+ it 'breaks the chain' do
+ perform
+
+ expect(step.break?).to be_truthy
+ end
+ end
+
+ context 'when ci_throttle_pipelines_creation is disabled' do
+ before do
+ stub_feature_flags(ci_throttle_pipelines_creation: false)
+ end
+
+ it 'does not break the chain' do
+ perform
+
+ expect(step.break?).to be_falsey
+ end
+
+ it 'does not invalidate the pipeline' do
+ perform
+
+ expect(pipeline.errors).to be_empty
+ end
+
+ it 'does not log anything' do
+ expect(Gitlab::AppJsonLogger).not_to receive(:info)
+
+ perform
+ end
+ end
+
+ context 'when ci_throttle_pipelines_creation_dry_run is enabled' do
+ before do
+ stub_feature_flags(ci_throttle_pipelines_creation_dry_run: true)
+ end
+
+ it 'does not break the chain' do
+ perform
+
+ expect(step.break?).to be_falsey
+ end
+
+ it 'does not invalidate the pipeline' do
+ perform
+
+ expect(pipeline.errors).to be_empty
+ end
+
+ it 'creates a log entry' do
+ expect(Gitlab::AppJsonLogger).to receive(:info).with(
+ a_hash_including(
+ class: described_class.name,
+ project_id: project.id,
+ subscription_plan: project.actual_plan_name,
+ commit_sha: command.sha
+ )
+ )
+
+ perform
+ end
+ end
+ end
+
+ context 'when the limit is not exceeded' do
+ it 'does not break the chain' do
+ perform
+
+ expect(step.break?).to be_falsey
+ end
+
+ it 'does not invalidate the pipeline' do
+ perform
+
+ expect(pipeline.errors).to be_empty
+ end
+
+ it 'does not log anything' do
+ expect(Gitlab::AppJsonLogger).not_to receive(:info)
+
+ perform
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/pipeline/chain/template_usage_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/template_usage_spec.rb
index 8e0b032e68c..ddd0de69d79 100644
--- a/spec/lib/gitlab/ci/pipeline/chain/template_usage_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/chain/template_usage_spec.rb
@@ -28,7 +28,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::TemplateUsage do
%w(Template-1 Template-2).each do |expected_template|
expect(Gitlab::UsageDataCounters::CiTemplateUniqueCounter).to(
receive(:track_unique_project_event)
- .with(project_id: project.id, template: expected_template, config_source: pipeline.config_source)
+ .with(project: project, template: expected_template, config_source: pipeline.config_source, user: user)
)
end
diff --git a/spec/lib/gitlab/ci/reports/security/report_spec.rb b/spec/lib/gitlab/ci/reports/security/report_spec.rb
index 4dc1eca3859..ab0efb90901 100644
--- a/spec/lib/gitlab/ci/reports/security/report_spec.rb
+++ b/spec/lib/gitlab/ci/reports/security/report_spec.rb
@@ -184,6 +184,22 @@ RSpec.describe Gitlab::Ci::Reports::Security::Report do
end
end
+ describe 'warnings?' do
+ subject { report.warnings? }
+
+ context 'when the report does not have any errors' do
+ it { is_expected.to be_falsey }
+ end
+
+ context 'when the report has warnings' do
+ before do
+ report.add_warning('foo', 'bar')
+ end
+
+ it { is_expected.to be_truthy }
+ end
+ end
+
describe '#primary_scanner_order_to' do
let(:scanner_1) { build(:ci_reports_security_scanner) }
let(:scanner_2) { build(:ci_reports_security_scanner) }
diff --git a/spec/lib/gitlab/ci/reports/security/scanner_spec.rb b/spec/lib/gitlab/ci/reports/security/scanner_spec.rb
index 99f5d4723d3..eb406e01b24 100644
--- a/spec/lib/gitlab/ci/reports/security/scanner_spec.rb
+++ b/spec/lib/gitlab/ci/reports/security/scanner_spec.rb
@@ -109,6 +109,7 @@ RSpec.describe Gitlab::Ci::Reports::Security::Scanner do
{ external_id: 'gemnasium-maven', name: 'foo', vendor: 'bar' } | { external_id: 'gemnasium-python', name: 'foo', vendor: 'bar' } | -1
{ external_id: 'gemnasium-python', name: 'foo', vendor: 'bar' } | { external_id: 'bandit', name: 'foo', vendor: 'bar' } | 1
{ external_id: 'bandit', name: 'foo', vendor: 'bar' } | { external_id: 'semgrep', name: 'foo', vendor: 'bar' } | -1
+ { external_id: 'spotbugs', name: 'foo', vendor: 'bar' } | { external_id: 'semgrep', name: 'foo', vendor: 'bar' } | -1
{ external_id: 'semgrep', name: 'foo', vendor: 'bar' } | { external_id: 'unknown', name: 'foo', vendor: 'bar' } | -1
{ external_id: 'gemnasium', name: 'foo', vendor: 'bar' } | { external_id: 'gemnasium', name: 'foo', vendor: nil } | 1
end
diff --git a/spec/lib/gitlab/ci/runner_releases_spec.rb b/spec/lib/gitlab/ci/runner_releases_spec.rb
new file mode 100644
index 00000000000..9e4a8739c0f
--- /dev/null
+++ b/spec/lib/gitlab/ci/runner_releases_spec.rb
@@ -0,0 +1,114 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Ci::RunnerReleases do
+ subject { described_class.instance }
+
+ describe '#releases' do
+ before do
+ subject.reset!
+
+ stub_application_setting(public_runner_releases_url: 'the release API URL')
+ allow(Gitlab::HTTP).to receive(:try_get).with('the release API URL').once { mock_http_response(response) }
+ end
+
+ def releases
+ subject.releases
+ end
+
+ shared_examples 'requests that follow cache status' do |validity_period|
+ context "almost #{validity_period.inspect} later" do
+ let(:followup_request_interval) { validity_period - 0.001.seconds }
+
+ it 'returns cached releases' do
+ releases
+
+ travel followup_request_interval do
+ expect(Gitlab::HTTP).not_to receive(:try_get)
+
+ expect(releases).to eq(expected_result)
+ end
+ end
+ end
+
+ context "after #{validity_period.inspect}" do
+ let(:followup_request_interval) { validity_period + 1.second }
+ let(:followup_response) { (response || []) + [{ 'name' => 'v14.9.2' }] }
+
+ it 'checks new releases' do
+ releases
+
+ travel followup_request_interval do
+ expect(Gitlab::HTTP).to receive(:try_get).with('the release API URL').once { mock_http_response(followup_response) }
+
+ expect(releases).to eq((expected_result || []) + [Gitlab::VersionInfo.new(14, 9, 2)])
+ end
+ end
+ end
+ end
+
+ context 'when response is nil' do
+ let(:response) { nil }
+ let(:expected_result) { nil }
+
+ it 'returns nil' do
+ expect(releases).to be_nil
+ end
+
+ it_behaves_like 'requests that follow cache status', 5.seconds
+
+ it 'performs exponential backoff on requests', :aggregate_failures do
+ start_time = Time.now.utc.change(usec: 0)
+
+ http_call_timestamp_offsets = []
+ allow(Gitlab::HTTP).to receive(:try_get).with('the release API URL') do
+ http_call_timestamp_offsets << Time.now.utc - start_time
+ mock_http_response(response)
+ end
+
+ # An initial HTTP request fails
+ travel_to(start_time)
+ subject.reset!
+ expect(releases).to be_nil
+
+ # Successive failed requests result in HTTP requests only after specific backoff periods
+ backoff_periods = [5, 10, 20, 40, 80, 160, 320, 640, 1280, 2560, 3600].map(&:seconds)
+ backoff_periods.each do |period|
+ travel(period - 1.second)
+ expect(releases).to be_nil
+
+ travel 1.second
+ expect(releases).to be_nil
+ end
+
+ expect(http_call_timestamp_offsets).to eq([0, 5, 15, 35, 75, 155, 315, 635, 1275, 2555, 5115, 8715])
+
+ # Finally a successful HTTP request results in releases being returned
+ allow(Gitlab::HTTP).to receive(:try_get).with('the release API URL').once { mock_http_response([{ 'name' => 'v14.9.1' }]) }
+ travel 1.hour
+ expect(releases).not_to be_nil
+ end
+ end
+
+ context 'when response is not nil' do
+ let(:response) { [{ 'name' => 'v14.9.1' }, { 'name' => 'v14.9.0' }] }
+ let(:expected_result) { [Gitlab::VersionInfo.new(14, 9, 0), Gitlab::VersionInfo.new(14, 9, 1)] }
+
+ it 'returns parsed and sorted Gitlab::VersionInfo objects' do
+ expect(releases).to eq(expected_result)
+ end
+
+ it_behaves_like 'requests that follow cache status', 1.day
+ end
+
+ def mock_http_response(response)
+ http_response = instance_double(HTTParty::Response)
+
+ allow(http_response).to receive(:success?).and_return(response.present?)
+ allow(http_response).to receive(:parsed_response).and_return(response)
+
+ http_response
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/runner_upgrade_check_spec.rb b/spec/lib/gitlab/ci/runner_upgrade_check_spec.rb
new file mode 100644
index 00000000000..b430da376dd
--- /dev/null
+++ b/spec/lib/gitlab/ci/runner_upgrade_check_spec.rb
@@ -0,0 +1,89 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Ci::RunnerUpgradeCheck do
+ include StubVersion
+ using RSpec::Parameterized::TableSyntax
+
+ describe '#check_runner_upgrade_status' do
+ subject(:result) { described_class.instance.check_runner_upgrade_status(runner_version) }
+
+ before do
+ runner_releases_double = instance_double(Gitlab::Ci::RunnerReleases)
+
+ allow(Gitlab::Ci::RunnerReleases).to receive(:instance).and_return(runner_releases_double)
+ allow(runner_releases_double).to receive(:releases).and_return(available_runner_releases.map { |v| ::Gitlab::VersionInfo.parse(v) })
+ end
+
+ context 'with available_runner_releases configured up to 14.1.1' do
+ let(:available_runner_releases) { %w[13.9.0 13.9.1 13.9.2 13.10.0 13.10.1 14.0.0 14.0.1 14.0.2 14.1.0 14.1.1 14.1.1-rc3] }
+
+ context 'with nil runner_version' do
+ let(:runner_version) { nil }
+
+ it 'raises :unknown' do
+ is_expected.to eq(:unknown)
+ end
+ end
+
+ context 'with invalid runner_version' do
+ let(:runner_version) { 'junk' }
+
+ it 'raises ArgumentError' do
+ expect { subject }.to raise_error(ArgumentError)
+ end
+ end
+
+ context 'with Gitlab::VERSION set to 14.1.123' do
+ before do
+ stub_version('14.1.123', 'deadbeef')
+
+ described_class.instance.reset!
+ end
+
+ context 'with a runner_version that is too recent' do
+ let(:runner_version) { 'v14.2.0' }
+
+ it 'returns :not_available' do
+ is_expected.to eq(:not_available)
+ end
+ end
+ end
+
+ context 'with Gitlab::VERSION set to 14.0.123' do
+ before do
+ stub_version('14.0.123', 'deadbeef')
+
+ described_class.instance.reset!
+ end
+
+ context 'with valid params' do
+ where(:runner_version, :expected_result) do
+ 'v14.1.0-rc3' | :not_available # not available since the GitLab instance is still on 14.0.x
+ 'v14.1.0~beta.1574.gf6ea9389' | :not_available # suffixes are correctly handled
+ 'v14.1.0/1.1.0' | :not_available # suffixes are correctly handled
+ 'v14.1.0' | :not_available # not available since the GitLab instance is still on 14.0.x
+ 'v14.0.1' | :recommended # recommended upgrade since 14.0.2 is available
+ 'v14.0.2' | :not_available # not available since 14.0.2 is the latest 14.0.x release available
+ 'v13.10.1' | :available # available upgrade: 14.1.1
+ 'v13.10.1~beta.1574.gf6ea9389' | :available # suffixes are correctly handled
+ 'v13.10.1/1.1.0' | :available # suffixes are correctly handled
+ 'v13.10.0' | :recommended # recommended upgrade since 13.10.1 is available
+ 'v13.9.2' | :recommended # recommended upgrade since backports are no longer released for this version
+ 'v13.9.0' | :recommended # recommended upgrade since backports are no longer released for this version
+ 'v13.8.1' | :recommended # recommended upgrade since build is too old (missing in records)
+ 'v11.4.1' | :recommended # recommended upgrade since build is too old (missing in records)
+ end
+
+ with_them do
+ it 'returns symbol representing expected upgrade status' do
+ is_expected.to be_a(Symbol)
+ is_expected.to eq(expected_result)
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/status/build/manual_spec.rb b/spec/lib/gitlab/ci/status/build/manual_spec.rb
index 78193055139..150705c1e36 100644
--- a/spec/lib/gitlab/ci/status/build/manual_spec.rb
+++ b/spec/lib/gitlab/ci/status/build/manual_spec.rb
@@ -3,15 +3,27 @@
require 'spec_helper'
RSpec.describe Gitlab::Ci::Status::Build::Manual do
- let(:user) { create(:user) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:job) { create(:ci_build, :manual) }
subject do
- build = create(:ci_build, :manual)
- described_class.new(Gitlab::Ci::Status::Core.new(build, user))
+ described_class.new(Gitlab::Ci::Status::Core.new(job, user))
end
describe '#illustration' do
it { expect(subject.illustration).to include(:image, :size, :title, :content) }
+
+ context 'when the user can trigger the job' do
+ before do
+ job.project.add_maintainer(user)
+ end
+
+ it { expect(subject.illustration[:content]).to match /This job requires manual intervention to start/ }
+ end
+
+ context 'when the user can not trigger the job' do
+ it { expect(subject.illustration[:content]).to match /This job does not run automatically and must be started manually/ }
+ end
end
describe '.matches?' do
diff --git a/spec/lib/gitlab/ci/templates/MATLAB_spec.rb b/spec/lib/gitlab/ci/templates/MATLAB_spec.rb
new file mode 100644
index 00000000000..a12d69b67a6
--- /dev/null
+++ b/spec/lib/gitlab/ci/templates/MATLAB_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'MATLAB.gitlab-ci.yml' do
+ subject(:template) { Gitlab::Template::GitlabCiYmlTemplate.find('MATLAB') }
+
+ describe 'the created pipeline' do
+ let_it_be(:project) { create(:project, :auto_devops, :custom_repo, files: { 'README.md' => '' }) }
+
+ let(:user) { project.first_owner }
+ let(:default_branch) { 'master' }
+ let(:pipeline_branch) { default_branch }
+ let(:service) { Ci::CreatePipelineService.new(project, user, ref: pipeline_branch ) }
+ let(:pipeline) { service.execute!(:push).payload }
+ let(:build_names) { pipeline.builds.pluck(:name) }
+
+ before do
+ stub_ci_pipeline_yaml_file(template.content)
+ end
+
+ it 'creates all jobs' do
+ expect(build_names).to include('command', 'test', 'test_artifacts_job')
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/templates/templates_spec.rb b/spec/lib/gitlab/ci/templates/templates_spec.rb
index cdda7e953d0..ca096fcecc4 100644
--- a/spec/lib/gitlab/ci/templates/templates_spec.rb
+++ b/spec/lib/gitlab/ci/templates/templates_spec.rb
@@ -23,7 +23,8 @@ RSpec.describe 'CI YML Templates' do
exceptions = [
'Security/DAST.gitlab-ci.yml', # DAST stage is defined inside AutoDevops yml
'Security/DAST-API.gitlab-ci.yml', # no auto-devops
- 'Security/API-Fuzzing.gitlab-ci.yml' # no auto-devops
+ 'Security/API-Fuzzing.gitlab-ci.yml', # no auto-devops
+ 'ThemeKit.gitlab-ci.yml'
]
context 'when including available templates in a CI YAML configuration' do
diff --git a/spec/lib/gitlab/ci/templates/themekit_gitlab_ci_yaml_spec.rb b/spec/lib/gitlab/ci/templates/themekit_gitlab_ci_yaml_spec.rb
new file mode 100644
index 00000000000..4708108f404
--- /dev/null
+++ b/spec/lib/gitlab/ci/templates/themekit_gitlab_ci_yaml_spec.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'ThemeKit.gitlab-ci.yml' do
+ before do
+ allow(Gitlab::Template::GitlabCiYmlTemplate).to receive(:excluded_patterns).and_return([])
+ end
+
+ subject(:template) { Gitlab::Template::GitlabCiYmlTemplate.find('ThemeKit') }
+
+ describe 'the created pipeline' do
+ let(:pipeline_ref) { project.default_branch_or_main }
+ let(:project) { create(:project, :custom_repo, files: { 'README.md' => '' }) }
+ let(:user) { project.first_owner }
+ let(:service) { Ci::CreatePipelineService.new(project, user, ref: pipeline_ref) }
+ let(:pipeline) { service.execute!(:push).payload }
+ let(:build_names) { pipeline.builds.pluck(:name) }
+
+ before do
+ stub_ci_pipeline_yaml_file(template.content)
+ end
+
+ context 'on the default branch' do
+ it 'only creates staging deploy', :aggregate_failures do
+ expect(pipeline.errors).to be_empty
+ expect(build_names).to include('staging')
+ expect(build_names).not_to include('production')
+ end
+ end
+
+ context 'on a tag' do
+ let(:pipeline_ref) { '1.0' }
+
+ before do
+ project.repository.add_tag(user, pipeline_ref, project.default_branch_or_main)
+ end
+
+ it 'only creates a production deploy', :aggregate_failures do
+ expect(pipeline.errors).to be_empty
+ expect(build_names).to include('production')
+ expect(build_names).not_to include('staging')
+ end
+ end
+
+ context 'outside of the default branch' do
+ let(:pipeline_ref) { 'patch-1' }
+
+ before do
+ project.repository.create_branch(pipeline_ref, project.default_branch_or_main)
+ end
+
+ it 'has no jobs' do
+ expect { pipeline }.to raise_error(
+ Ci::CreatePipelineService::CreateError, 'No stages / jobs for this pipeline.'
+ )
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/variables/builder_spec.rb b/spec/lib/gitlab/ci/variables/builder_spec.rb
index 8552a06eab3..b9aa5f7c431 100644
--- a/spec/lib/gitlab/ci/variables/builder_spec.rb
+++ b/spec/lib/gitlab/ci/variables/builder_spec.rb
@@ -199,6 +199,20 @@ RSpec.describe Gitlab::Ci::Variables::Builder do
'O' => '15', 'P' => '15')
end
end
+
+ context 'with schedule variables' do
+ let_it_be(:schedule) { create(:ci_pipeline_schedule, project: project) }
+ let_it_be(:schedule_variable) { create(:ci_pipeline_schedule_variable, pipeline_schedule: schedule) }
+
+ before do
+ pipeline.update!(pipeline_schedule_id: schedule.id)
+ end
+
+ it 'includes schedule variables' do
+ expect(subject.to_runner_variables)
+ .to include(a_hash_including(key: schedule_variable.key, value: schedule_variable.value))
+ end
+ end
end
describe '#user_variables' do
@@ -278,6 +292,14 @@ RSpec.describe Gitlab::Ci::Variables::Builder do
end
shared_examples "secret CI variables" do
+ let(:protected_variable_item) do
+ Gitlab::Ci::Variables::Collection::Item.fabricate(protected_variable)
+ end
+
+ let(:unprotected_variable_item) do
+ Gitlab::Ci::Variables::Collection::Item.fabricate(unprotected_variable)
+ end
+
context 'when ref is branch' do
context 'when ref is protected' do
before do
@@ -338,189 +360,255 @@ RSpec.describe Gitlab::Ci::Variables::Builder do
let_it_be(:protected_variable) { create(:ci_instance_variable, protected: true) }
let_it_be(:unprotected_variable) { create(:ci_instance_variable, protected: false) }
- let(:protected_variable_item) { Gitlab::Ci::Variables::Collection::Item.fabricate(protected_variable) }
- let(:unprotected_variable_item) { Gitlab::Ci::Variables::Collection::Item.fabricate(unprotected_variable) }
-
include_examples "secret CI variables"
end
describe '#secret_group_variables' do
- subject { builder.secret_group_variables(ref: job.git_ref, environment: job.expanded_environment_name) }
+ subject { builder.secret_group_variables(environment: job.expanded_environment_name) }
let_it_be(:protected_variable) { create(:ci_group_variable, protected: true, group: group) }
let_it_be(:unprotected_variable) { create(:ci_group_variable, protected: false, group: group) }
- context 'with ci_variables_builder_memoize_secret_variables disabled' do
- before do
- stub_feature_flags(ci_variables_builder_memoize_secret_variables: false)
+ include_examples "secret CI variables"
+
+ context 'variables memoization' do
+ let_it_be(:scoped_variable) { create(:ci_group_variable, group: group, environment_scope: 'scoped') }
+
+ let(:environment) { job.expanded_environment_name }
+ let(:scoped_variable_item) { Gitlab::Ci::Variables::Collection::Item.fabricate(scoped_variable) }
+
+ context 'with protected environments' do
+ it 'memoizes the result by environment' do
+ expect(pipeline.project)
+ .to receive(:protected_for?)
+ .with(pipeline.jobs_git_ref)
+ .once.and_return(true)
+
+ expect_next_instance_of(described_class::Group) do |group_variables_builder|
+ expect(group_variables_builder)
+ .to receive(:secret_variables)
+ .with(environment: 'production', protected_ref: true)
+ .once
+ .and_call_original
+ end
+
+ 2.times do
+ expect(builder.secret_group_variables(environment: 'production'))
+ .to contain_exactly(unprotected_variable_item, protected_variable_item)
+ end
+ end
end
- let(:protected_variable_item) { protected_variable }
- let(:unprotected_variable_item) { unprotected_variable }
+ context 'with unprotected environments' do
+ it 'memoizes the result by environment' do
+ expect(pipeline.project)
+ .to receive(:protected_for?)
+ .with(pipeline.jobs_git_ref)
+ .once.and_return(false)
+
+ expect_next_instance_of(described_class::Group) do |group_variables_builder|
+ expect(group_variables_builder)
+ .to receive(:secret_variables)
+ .with(environment: nil, protected_ref: false)
+ .once
+ .and_call_original
+
+ expect(group_variables_builder)
+ .to receive(:secret_variables)
+ .with(environment: 'scoped', protected_ref: false)
+ .once
+ .and_call_original
+ end
+
+ 2.times do
+ expect(builder.secret_group_variables(environment: nil))
+ .to contain_exactly(unprotected_variable_item)
- include_examples "secret CI variables"
+ expect(builder.secret_group_variables(environment: 'scoped'))
+ .to contain_exactly(unprotected_variable_item, scoped_variable_item)
+ end
+ end
+ end
end
+ end
- context 'with ci_variables_builder_memoize_secret_variables enabled' do
- before do
- stub_feature_flags(ci_variables_builder_memoize_secret_variables: true)
- end
+ describe '#secret_project_variables' do
+ let_it_be(:protected_variable) { create(:ci_variable, protected: true, project: project) }
+ let_it_be(:unprotected_variable) { create(:ci_variable, protected: false, project: project) }
- let(:protected_variable_item) { Gitlab::Ci::Variables::Collection::Item.fabricate(protected_variable) }
- let(:unprotected_variable_item) { Gitlab::Ci::Variables::Collection::Item.fabricate(unprotected_variable) }
+ let(:environment) { job.expanded_environment_name }
- include_examples "secret CI variables"
+ subject { builder.secret_project_variables(environment: environment) }
- context 'variables memoization' do
- let_it_be(:scoped_variable) { create(:ci_group_variable, group: group, environment_scope: 'scoped') }
+ include_examples "secret CI variables"
- let(:ref) { job.git_ref }
- let(:environment) { job.expanded_environment_name }
- let(:scoped_variable_item) { Gitlab::Ci::Variables::Collection::Item.fabricate(scoped_variable) }
+ context 'variables memoization' do
+ let_it_be(:scoped_variable) { create(:ci_variable, project: project, environment_scope: 'scoped') }
- context 'with protected environments' do
- it 'memoizes the result by environment' do
- expect(pipeline.project)
- .to receive(:protected_for?)
- .with(pipeline.jobs_git_ref)
- .once.and_return(true)
+ let(:scoped_variable_item) { Gitlab::Ci::Variables::Collection::Item.fabricate(scoped_variable) }
- expect_next_instance_of(described_class::Group) do |group_variables_builder|
- expect(group_variables_builder)
- .to receive(:secret_variables)
- .with(environment: 'production', protected_ref: true)
- .once
- .and_call_original
- end
+ context 'with protected environments' do
+ it 'memoizes the result by environment' do
+ expect(pipeline.project)
+ .to receive(:protected_for?)
+ .with(pipeline.jobs_git_ref)
+ .once.and_return(true)
- 2.times do
- expect(builder.secret_group_variables(ref: ref, environment: 'production'))
- .to contain_exactly(unprotected_variable_item, protected_variable_item)
- end
+ expect_next_instance_of(described_class::Project) do |project_variables_builder|
+ expect(project_variables_builder)
+ .to receive(:secret_variables)
+ .with(environment: 'production', protected_ref: true)
+ .once
+ .and_call_original
+ end
+
+ 2.times do
+ expect(builder.secret_project_variables(environment: 'production'))
+ .to contain_exactly(unprotected_variable_item, protected_variable_item)
end
end
+ end
- context 'with unprotected environments' do
- it 'memoizes the result by environment' do
- expect(pipeline.project)
- .to receive(:protected_for?)
- .with(pipeline.jobs_git_ref)
- .once.and_return(false)
-
- expect_next_instance_of(described_class::Group) do |group_variables_builder|
- expect(group_variables_builder)
- .to receive(:secret_variables)
- .with(environment: nil, protected_ref: false)
- .once
- .and_call_original
-
- expect(group_variables_builder)
- .to receive(:secret_variables)
- .with(environment: 'scoped', protected_ref: false)
- .once
- .and_call_original
- end
-
- 2.times do
- expect(builder.secret_group_variables(ref: 'other', environment: nil))
- .to contain_exactly(unprotected_variable_item)
-
- expect(builder.secret_group_variables(ref: 'other', environment: 'scoped'))
- .to contain_exactly(unprotected_variable_item, scoped_variable_item)
- end
+ context 'with unprotected environments' do
+ it 'memoizes the result by environment' do
+ expect(pipeline.project)
+ .to receive(:protected_for?)
+ .with(pipeline.jobs_git_ref)
+ .once.and_return(false)
+
+ expect_next_instance_of(described_class::Project) do |project_variables_builder|
+ expect(project_variables_builder)
+ .to receive(:secret_variables)
+ .with(environment: nil, protected_ref: false)
+ .once
+ .and_call_original
+
+ expect(project_variables_builder)
+ .to receive(:secret_variables)
+ .with(environment: 'scoped', protected_ref: false)
+ .once
+ .and_call_original
+ end
+
+ 2.times do
+ expect(builder.secret_project_variables(environment: nil))
+ .to contain_exactly(unprotected_variable_item)
+
+ expect(builder.secret_project_variables(environment: 'scoped'))
+ .to contain_exactly(unprotected_variable_item, scoped_variable_item)
end
end
end
end
end
- describe '#secret_project_variables' do
- let_it_be(:protected_variable) { create(:ci_variable, protected: true, project: project) }
- let_it_be(:unprotected_variable) { create(:ci_variable, protected: false, project: project) }
+ describe '#config_variables' do
+ subject(:config_variables) { builder.config_variables }
- let(:ref) { job.git_ref }
- let(:environment) { job.expanded_environment_name }
+ context 'without project' do
+ before do
+ pipeline.update!(project_id: nil)
+ end
+
+ it { expect(config_variables.size).to eq(0) }
+ end
- subject { builder.secret_project_variables(ref: ref, environment: environment) }
+ context 'without repository' do
+ let(:project) { create(:project) }
+ let(:pipeline) { build(:ci_pipeline, ref: nil, sha: nil, project: project) }
- context 'with ci_variables_builder_memoize_secret_variables disabled' do
- before do
- stub_feature_flags(ci_variables_builder_memoize_secret_variables: false)
+ it { expect(config_variables['CI_COMMIT_SHA']).to be_nil }
+ end
+
+ context 'with protected variables' do
+ let_it_be(:instance_variable) do
+ create(:ci_instance_variable, :protected, key: 'instance_variable')
+ end
+
+ let_it_be(:group_variable) do
+ create(:ci_group_variable, :protected, group: group, key: 'group_variable')
end
- let(:protected_variable_item) { protected_variable }
- let(:unprotected_variable_item) { unprotected_variable }
+ let_it_be(:project_variable) do
+ create(:ci_variable, :protected, project: project, key: 'project_variable')
+ end
- include_examples "secret CI variables"
+ it 'does not include protected variables' do
+ expect(config_variables[instance_variable.key]).to be_nil
+ expect(config_variables[group_variable.key]).to be_nil
+ expect(config_variables[project_variable.key]).to be_nil
+ end
end
- context 'with ci_variables_builder_memoize_secret_variables enabled' do
- before do
- stub_feature_flags(ci_variables_builder_memoize_secret_variables: true)
+ context 'with scoped variables' do
+ let_it_be(:scoped_group_variable) do
+ create(:ci_group_variable,
+ group: group,
+ key: 'group_variable',
+ value: 'scoped',
+ environment_scope: 'scoped')
end
- let(:protected_variable_item) { Gitlab::Ci::Variables::Collection::Item.fabricate(protected_variable) }
- let(:unprotected_variable_item) { Gitlab::Ci::Variables::Collection::Item.fabricate(unprotected_variable) }
+ let_it_be(:group_variable) do
+ create(:ci_group_variable,
+ group: group,
+ key: 'group_variable',
+ value: 'unscoped')
+ end
- include_examples "secret CI variables"
+ let_it_be(:scoped_project_variable) do
+ create(:ci_variable,
+ project: project,
+ key: 'project_variable',
+ value: 'scoped',
+ environment_scope: 'scoped')
+ end
- context 'variables memoization' do
- let_it_be(:scoped_variable) { create(:ci_variable, project: project, environment_scope: 'scoped') }
+ let_it_be(:project_variable) do
+ create(:ci_variable,
+ project: project,
+ key: 'project_variable',
+ value: 'unscoped')
+ end
- let(:scoped_variable_item) { Gitlab::Ci::Variables::Collection::Item.fabricate(scoped_variable) }
+ it 'does not include scoped variables' do
+ expect(config_variables.to_hash[group_variable.key]).to eq('unscoped')
+ expect(config_variables.to_hash[project_variable.key]).to eq('unscoped')
+ end
+ end
- context 'with protected environments' do
- it 'memoizes the result by environment' do
- expect(pipeline.project)
- .to receive(:protected_for?)
- .with(pipeline.jobs_git_ref)
- .once.and_return(true)
+ context 'variables ordering' do
+ def var(name, value)
+ { key: name, value: value.to_s, public: true, masked: false }
+ end
- expect_next_instance_of(described_class::Project) do |project_variables_builder|
- expect(project_variables_builder)
- .to receive(:secret_variables)
- .with(environment: 'production', protected_ref: true)
- .once
- .and_call_original
- end
+ before do
+ allow(pipeline.project).to receive(:predefined_variables) { [var('A', 1), var('B', 1)] }
+ allow(pipeline).to receive(:predefined_variables) { [var('B', 2), var('C', 2)] }
+ allow(builder).to receive(:secret_instance_variables) { [var('C', 3), var('D', 3)] }
+ allow(builder).to receive(:secret_group_variables) { [var('D', 4), var('E', 4)] }
+ allow(builder).to receive(:secret_project_variables) { [var('E', 5), var('F', 5)] }
+ allow(pipeline).to receive(:variables) { [var('F', 6), var('G', 6)] }
+ allow(pipeline).to receive(:pipeline_schedule) { double(job_variables: [var('G', 7), var('H', 7)]) }
+ end
- 2.times do
- expect(builder.secret_project_variables(ref: ref, environment: 'production'))
- .to contain_exactly(unprotected_variable_item, protected_variable_item)
- end
- end
- end
+ it 'returns variables in order depending on resource hierarchy' do
+ expect(config_variables.to_runner_variables).to eq(
+ [var('A', 1), var('B', 1),
+ var('B', 2), var('C', 2),
+ var('C', 3), var('D', 3),
+ var('D', 4), var('E', 4),
+ var('E', 5), var('F', 5),
+ var('F', 6), var('G', 6),
+ var('G', 7), var('H', 7)])
+ end
- context 'with unprotected environments' do
- it 'memoizes the result by environment' do
- expect(pipeline.project)
- .to receive(:protected_for?)
- .with(pipeline.jobs_git_ref)
- .once.and_return(false)
-
- expect_next_instance_of(described_class::Project) do |project_variables_builder|
- expect(project_variables_builder)
- .to receive(:secret_variables)
- .with(environment: nil, protected_ref: false)
- .once
- .and_call_original
-
- expect(project_variables_builder)
- .to receive(:secret_variables)
- .with(environment: 'scoped', protected_ref: false)
- .once
- .and_call_original
- end
-
- 2.times do
- expect(builder.secret_project_variables(ref: 'other', environment: nil))
- .to contain_exactly(unprotected_variable_item)
-
- expect(builder.secret_project_variables(ref: 'other', environment: 'scoped'))
- .to contain_exactly(unprotected_variable_item, scoped_variable_item)
- end
- end
- end
+ it 'overrides duplicate keys depending on resource hierarchy' do
+ expect(config_variables.to_hash).to match(
+ 'A' => '1', 'B' => '2',
+ 'C' => '3', 'D' => '4',
+ 'E' => '5', 'F' => '6',
+ 'G' => '7', 'H' => '7')
end
end
end
diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb
index ebb5c91ebad..9b68ee2d6a2 100644
--- a/spec/lib/gitlab/ci/yaml_processor_spec.rb
+++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb
@@ -842,7 +842,7 @@ module Gitlab
describe "Image and service handling" do
context "when extended docker configuration is used" do
it "returns image and service when defined" do
- config = YAML.dump({ image: { name: "ruby:2.7", entrypoint: ["/usr/local/bin/init", "run"] },
+ config = YAML.dump({ image: { name: "image:1.0", entrypoint: ["/usr/local/bin/init", "run"] },
services: ["mysql", { name: "docker:dind", alias: "docker",
entrypoint: ["/usr/local/bin/init", "run"],
command: ["/usr/local/bin/init", "run"] }],
@@ -860,7 +860,7 @@ module Gitlab
options: {
before_script: ["pwd"],
script: ["rspec"],
- image: { name: "ruby:2.7", entrypoint: ["/usr/local/bin/init", "run"] },
+ image: { name: "image:1.0", entrypoint: ["/usr/local/bin/init", "run"] },
services: [{ name: "mysql" },
{ name: "docker:dind", alias: "docker", entrypoint: ["/usr/local/bin/init", "run"],
command: ["/usr/local/bin/init", "run"] }]
@@ -874,10 +874,10 @@ module Gitlab
end
it "returns image and service when overridden for job" do
- config = YAML.dump({ image: "ruby:2.7",
+ config = YAML.dump({ image: "image:1.0",
services: ["mysql"],
before_script: ["pwd"],
- rspec: { image: { name: "ruby:3.0", entrypoint: ["/usr/local/bin/init", "run"] },
+ rspec: { image: { name: "image:1.0", entrypoint: ["/usr/local/bin/init", "run"] },
services: [{ name: "postgresql", alias: "db-pg",
entrypoint: ["/usr/local/bin/init", "run"],
command: ["/usr/local/bin/init", "run"] }, "docker:dind"],
@@ -894,7 +894,7 @@ module Gitlab
options: {
before_script: ["pwd"],
script: ["rspec"],
- image: { name: "ruby:3.0", entrypoint: ["/usr/local/bin/init", "run"] },
+ image: { name: "image:1.0", entrypoint: ["/usr/local/bin/init", "run"] },
services: [{ name: "postgresql", alias: "db-pg", entrypoint: ["/usr/local/bin/init", "run"],
command: ["/usr/local/bin/init", "run"] },
{ name: "docker:dind" }]
@@ -910,7 +910,7 @@ module Gitlab
context "when etended docker configuration is not used" do
it "returns image and service when defined" do
- config = YAML.dump({ image: "ruby:2.7",
+ config = YAML.dump({ image: "image:1.0",
services: ["mysql", "docker:dind"],
before_script: ["pwd"],
rspec: { script: "rspec" } })
@@ -926,7 +926,7 @@ module Gitlab
options: {
before_script: ["pwd"],
script: ["rspec"],
- image: { name: "ruby:2.7" },
+ image: { name: "image:1.0" },
services: [{ name: "mysql" }, { name: "docker:dind" }]
},
allow_failure: false,
@@ -938,10 +938,10 @@ module Gitlab
end
it "returns image and service when overridden for job" do
- config = YAML.dump({ image: "ruby:2.7",
+ config = YAML.dump({ image: "image:1.0",
services: ["mysql"],
before_script: ["pwd"],
- rspec: { image: "ruby:3.0", services: ["postgresql", "docker:dind"], script: "rspec" } })
+ rspec: { image: "image:1.0", services: ["postgresql", "docker:dind"], script: "rspec" } })
config_processor = Gitlab::Ci::YamlProcessor.new(config).execute
@@ -954,7 +954,7 @@ module Gitlab
options: {
before_script: ["pwd"],
script: ["rspec"],
- image: { name: "ruby:3.0" },
+ image: { name: "image:1.0" },
services: [{ name: "postgresql" }, { name: "docker:dind" }]
},
allow_failure: false,
@@ -1557,7 +1557,7 @@ module Gitlab
describe "Artifacts" do
it "returns artifacts when defined" do
config = YAML.dump({
- image: "ruby:2.7",
+ image: "image:1.0",
services: ["mysql"],
before_script: ["pwd"],
rspec: {
@@ -1583,7 +1583,7 @@ module Gitlab
options: {
before_script: ["pwd"],
script: ["rspec"],
- image: { name: "ruby:2.7" },
+ image: { name: "image:1.0" },
services: [{ name: "mysql" }],
artifacts: {
name: "custom_name",
@@ -2327,7 +2327,7 @@ module Gitlab
context 'when hidden job have a script definition' do
let(:config) do
YAML.dump({
- '.hidden_job' => { image: 'ruby:2.7', script: 'test' },
+ '.hidden_job' => { image: 'image:1.0', script: 'test' },
'normal_job' => { script: 'test' }
})
end
@@ -2338,7 +2338,7 @@ module Gitlab
context "when hidden job doesn't have a script definition" do
let(:config) do
YAML.dump({
- '.hidden_job' => { image: 'ruby:2.7' },
+ '.hidden_job' => { image: 'image:1.0' },
'normal_job' => { script: 'test' }
})
end
diff --git a/spec/lib/gitlab/config/loader/yaml_spec.rb b/spec/lib/gitlab/config/loader/yaml_spec.rb
index be568a8e5f9..66ea931a42c 100644
--- a/spec/lib/gitlab/config/loader/yaml_spec.rb
+++ b/spec/lib/gitlab/config/loader/yaml_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe Gitlab::Config::Loader::Yaml do
let(:yml) do
<<~YAML
- image: 'ruby:2.7'
+ image: 'image:1.0'
texts:
nested_key: 'value1'
more_text:
@@ -34,7 +34,7 @@ RSpec.describe Gitlab::Config::Loader::Yaml do
end
context 'when yaml syntax is correct' do
- let(:yml) { 'image: ruby:2.7' }
+ let(:yml) { 'image: image:1.0' }
describe '#valid?' do
it 'returns true' do
@@ -44,7 +44,7 @@ RSpec.describe Gitlab::Config::Loader::Yaml do
describe '#load!' do
it 'returns a valid hash' do
- expect(loader.load!).to eq(image: 'ruby:2.7')
+ expect(loader.load!).to eq(image: 'image:1.0')
end
end
end
@@ -164,7 +164,7 @@ RSpec.describe Gitlab::Config::Loader::Yaml do
describe '#load_raw!' do
it 'loads keys as strings' do
expect(loader.load_raw!).to eq(
- 'image' => 'ruby:2.7',
+ 'image' => 'image:1.0',
'texts' => {
'nested_key' => 'value1',
'more_text' => {
@@ -178,7 +178,7 @@ RSpec.describe Gitlab::Config::Loader::Yaml do
describe '#load!' do
it 'symbolizes keys' do
expect(loader.load!).to eq(
- image: 'ruby:2.7',
+ image: 'image:1.0',
texts: {
nested_key: 'value1',
more_text: {
diff --git a/spec/lib/gitlab/content_security_policy/config_loader_spec.rb b/spec/lib/gitlab/content_security_policy/config_loader_spec.rb
index 08d29f7842c..44e2cb21677 100644
--- a/spec/lib/gitlab/content_security_policy/config_loader_spec.rb
+++ b/spec/lib/gitlab/content_security_policy/config_loader_spec.rb
@@ -107,24 +107,8 @@ RSpec.describe Gitlab::ContentSecurityPolicy::ConfigLoader do
stub_env('CUSTOMER_PORTAL_URL', customer_portal_url)
end
- context 'when in production' do
- before do
- allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new('production'))
- end
-
- it 'does not add CUSTOMER_PORTAL_URL to CSP' do
- expect(directives['frame_src']).to eq(::Gitlab::ContentSecurityPolicy::Directives.frame_src + " http://localhost/admin/ http://localhost/assets/ http://localhost/-/speedscope/index.html http://localhost/-/sandbox/mermaid")
- end
- end
-
- context 'when in development' do
- before do
- allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new('development'))
- end
-
- it 'adds CUSTOMER_PORTAL_URL to CSP' do
- expect(directives['frame_src']).to eq(::Gitlab::ContentSecurityPolicy::Directives.frame_src + " http://localhost/rails/letter_opener/ https://customers.example.com http://localhost/admin/ http://localhost/assets/ http://localhost/-/speedscope/index.html http://localhost/-/sandbox/mermaid")
- end
+ it 'adds CUSTOMER_PORTAL_URL to CSP' do
+ expect(directives['frame_src']).to eq(::Gitlab::ContentSecurityPolicy::Directives.frame_src + " http://localhost/admin/ http://localhost/assets/ http://localhost/-/speedscope/index.html http://localhost/-/sandbox/mermaid #{customer_portal_url}")
end
end
diff --git a/spec/lib/gitlab/data_builder/deployment_spec.rb b/spec/lib/gitlab/data_builder/deployment_spec.rb
index ab8c8a51694..e8fe80f75cb 100644
--- a/spec/lib/gitlab/data_builder/deployment_spec.rb
+++ b/spec/lib/gitlab/data_builder/deployment_spec.rb
@@ -46,5 +46,42 @@ RSpec.describe Gitlab::DataBuilder::Deployment do
expect(data[:deployable_url]).to be_nil
end
+
+ context 'when commit does not exist in the repository' do
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:deployment) { create(:deployment, project: project) }
+
+ subject(:data) { described_class.build(deployment, Time.current) }
+
+ before(:all) do
+ project.repository.remove
+ end
+
+ it 'returns nil for commit_url' do
+ expect(data[:commit_url]).to be_nil
+ end
+
+ it 'returns nil for commit_title' do
+ expect(data[:commit_title]).to be_nil
+ end
+ end
+
+ context 'when deployed_by is nil' do
+ let_it_be(:deployment) { create(:deployment, user: nil, deployable: nil) }
+
+ subject(:data) { described_class.build(deployment, Time.current) }
+
+ before(:all) do
+ deployment.user = nil
+ end
+
+ it 'returns nil for user' do
+ expect(data[:user]).to be_nil
+ end
+
+ it 'returns nil for user_url' do
+ expect(data[:user_url]).to be_nil
+ end
+ end
end
end
diff --git a/spec/lib/gitlab/data_builder/note_spec.rb b/spec/lib/gitlab/data_builder/note_spec.rb
index 90ca5430526..3fa535dd800 100644
--- a/spec/lib/gitlab/data_builder/note_spec.rb
+++ b/spec/lib/gitlab/data_builder/note_spec.rb
@@ -8,18 +8,22 @@ RSpec.describe Gitlab::DataBuilder::Note do
let(:data) { described_class.build(note, user) }
let(:fixed_time) { Time.at(1425600000) } # Avoid time precision errors
- before do
- expect(data).to have_key(:object_attributes)
- expect(data[:object_attributes]).to have_key(:url)
- expect(data[:object_attributes][:url])
- .to eq(Gitlab::UrlBuilder.build(note))
- expect(data[:object_kind]).to eq('note')
- expect(data[:user]).to eq(user.hook_attrs)
+ shared_examples 'includes general data' do
+ specify do
+ expect(data).to have_key(:object_attributes)
+ expect(data[:object_attributes]).to have_key(:url)
+ expect(data[:object_attributes][:url])
+ .to eq(Gitlab::UrlBuilder.build(note))
+ expect(data[:object_kind]).to eq('note')
+ expect(data[:user]).to eq(user.hook_attrs)
+ end
end
describe 'When asking for a note on commit' do
let(:note) { create(:note_on_commit, project: project) }
+ it_behaves_like 'includes general data'
+
it 'returns the note and commit-specific data' do
expect(data).to have_key(:commit)
end
@@ -31,6 +35,8 @@ RSpec.describe Gitlab::DataBuilder::Note do
describe 'When asking for a note on commit diff' do
let(:note) { create(:diff_note_on_commit, project: project) }
+ it_behaves_like 'includes general data'
+
it 'returns the note and commit-specific data' do
expect(data).to have_key(:commit)
end
@@ -51,22 +57,21 @@ RSpec.describe Gitlab::DataBuilder::Note do
create(:note_on_issue, noteable: issue, project: project)
end
+ it_behaves_like 'includes general data'
+
it 'returns the note and issue-specific data' do
- without_timestamps = lambda { |label| label.except('created_at', 'updated_at') }
- hook_attrs = issue.reload.hook_attrs
+ expect_next_instance_of(Gitlab::HookData::IssueBuilder) do |issue_data_builder|
+ expect(issue_data_builder).to receive(:build).and_return('Issue data')
+ end
- expect(data).to have_key(:issue)
- expect(data[:issue].except('updated_at', 'labels'))
- .to eq(hook_attrs.except('updated_at', 'labels'))
- expect(data[:issue]['updated_at'])
- .to be >= hook_attrs['updated_at']
- expect(data[:issue]['labels'].map(&without_timestamps))
- .to eq(hook_attrs['labels'].map(&without_timestamps))
+ expect(data[:issue]).to eq('Issue data')
end
context 'with confidential issue' do
let(:issue) { create(:issue, project: project, confidential: true) }
+ it_behaves_like 'includes general data'
+
it 'sets event_type to confidential_note' do
expect(data[:event_type]).to eq('confidential_note')
end
@@ -77,10 +82,12 @@ RSpec.describe Gitlab::DataBuilder::Note do
end
describe 'When asking for a note on merge request' do
+ let(:label) { create(:label, project: project) }
let(:merge_request) do
- create(:merge_request, created_at: fixed_time,
+ create(:labeled_merge_request, created_at: fixed_time,
updated_at: fixed_time,
- source_project: project)
+ source_project: project,
+ labels: [label])
end
let(:note) do
@@ -88,12 +95,14 @@ RSpec.describe Gitlab::DataBuilder::Note do
project: project)
end
- it 'returns the note and merge request data' do
- expect(data).to have_key(:merge_request)
- expect(data[:merge_request].except('updated_at'))
- .to eq(merge_request.reload.hook_attrs.except('updated_at'))
- expect(data[:merge_request]['updated_at'])
- .to be >= merge_request.hook_attrs['updated_at']
+ it_behaves_like 'includes general data'
+
+ it 'returns the merge request data' do
+ expect_next_instance_of(Gitlab::HookData::MergeRequestBuilder) do |mr_data_builder|
+ expect(mr_data_builder).to receive(:build).and_return('MR data')
+ end
+
+ expect(data[:merge_request]).to eq('MR data')
end
include_examples 'project hook data'
@@ -101,9 +110,11 @@ RSpec.describe Gitlab::DataBuilder::Note do
end
describe 'When asking for a note on merge request diff' do
+ let(:label) { create(:label, project: project) }
let(:merge_request) do
- create(:merge_request, created_at: fixed_time, updated_at: fixed_time,
- source_project: project)
+ create(:labeled_merge_request, created_at: fixed_time, updated_at: fixed_time,
+ source_project: project,
+ labels: [label])
end
let(:note) do
@@ -111,12 +122,14 @@ RSpec.describe Gitlab::DataBuilder::Note do
project: project)
end
- it 'returns the note and merge request diff data' do
- expect(data).to have_key(:merge_request)
- expect(data[:merge_request].except('updated_at'))
- .to eq(merge_request.reload.hook_attrs.except('updated_at'))
- expect(data[:merge_request]['updated_at'])
- .to be >= merge_request.hook_attrs['updated_at']
+ it_behaves_like 'includes general data'
+
+ it 'returns the merge request data' do
+ expect_next_instance_of(Gitlab::HookData::MergeRequestBuilder) do |mr_data_builder|
+ expect(mr_data_builder).to receive(:build).and_return('MR data')
+ end
+
+ expect(data[:merge_request]).to eq('MR data')
end
include_examples 'project hook data'
@@ -134,6 +147,8 @@ RSpec.describe Gitlab::DataBuilder::Note do
project: project)
end
+ it_behaves_like 'includes general data'
+
it 'returns the note and project snippet data' do
expect(data).to have_key(:snippet)
expect(data[:snippet].except('updated_at'))
diff --git a/spec/lib/gitlab/database/background_migration/batch_metrics_spec.rb b/spec/lib/gitlab/database/background_migration/batch_metrics_spec.rb
index 66983733411..6db3081ca7e 100644
--- a/spec/lib/gitlab/database/background_migration/batch_metrics_spec.rb
+++ b/spec/lib/gitlab/database/background_migration/batch_metrics_spec.rb
@@ -10,7 +10,6 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchMetrics do
expect(batch_metrics.timings).to be_empty
expect(Gitlab::Metrics::System).to receive(:monotonic_time)
- .exactly(6).times
.and_return(0.0, 111.0, 200.0, 290.0, 300.0, 410.0)
batch_metrics.time_operation(:my_label) do
@@ -28,4 +27,33 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchMetrics do
expect(batch_metrics.timings).to eq(my_label: [111.0, 110.0], my_other_label: [90.0])
end
end
+
+ describe '#instrument_operation' do
+ it 'tracks duration and affected rows' do
+ expect(batch_metrics.timings).to be_empty
+ expect(batch_metrics.affected_rows).to be_empty
+
+ expect(Gitlab::Metrics::System).to receive(:monotonic_time)
+ .and_return(0.0, 111.0, 200.0, 290.0, 300.0, 410.0, 420.0, 450.0)
+
+ batch_metrics.instrument_operation(:my_label) do
+ 3
+ end
+
+ batch_metrics.instrument_operation(:my_other_label) do
+ 42
+ end
+
+ batch_metrics.instrument_operation(:my_label) do
+ 2
+ end
+
+ batch_metrics.instrument_operation(:my_other_label) do
+ :not_an_integer
+ end
+
+ expect(batch_metrics.timings).to eq(my_label: [111.0, 110.0], my_other_label: [90.0, 30.0])
+ expect(batch_metrics.affected_rows).to eq(my_label: [3, 2], my_other_label: [42])
+ end
+ end
end
diff --git a/spec/lib/gitlab/database/background_migration/batched_job_spec.rb b/spec/lib/gitlab/database/background_migration/batched_job_spec.rb
index 8c663ff9f8a..c39f6a78e93 100644
--- a/spec/lib/gitlab/database/background_migration/batched_job_spec.rb
+++ b/spec/lib/gitlab/database/background_migration/batched_job_spec.rb
@@ -21,7 +21,19 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedJob, type: :model d
context 'when a job is running' do
it 'logs the transition' do
- expect(Gitlab::AppLogger).to receive(:info).with( { batched_job_id: job.id, message: 'BatchedJob transition', new_state: :running, previous_state: :failed } )
+ expect(Gitlab::AppLogger).to receive(:info).with(
+ {
+ batched_job_id: job.id,
+ batched_migration_id: job.batched_background_migration_id,
+ exception_class: nil,
+ exception_message: nil,
+ job_arguments: job.batched_migration.job_arguments,
+ job_class_name: job.batched_migration.job_class_name,
+ message: 'BatchedJob transition',
+ new_state: :running,
+ previous_state: :failed
+ }
+ )
expect { job.run! }.to change(job, :started_at)
end
@@ -31,7 +43,19 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedJob, type: :model d
let(:job) { create(:batched_background_migration_job, :running) }
it 'logs the transition' do
- expect(Gitlab::AppLogger).to receive(:info).with( { batched_job_id: job.id, message: 'BatchedJob transition', new_state: :succeeded, previous_state: :running } )
+ expect(Gitlab::AppLogger).to receive(:info).with(
+ {
+ batched_job_id: job.id,
+ batched_migration_id: job.batched_background_migration_id,
+ exception_class: nil,
+ exception_message: nil,
+ job_arguments: job.batched_migration.job_arguments,
+ job_class_name: job.batched_migration.job_class_name,
+ message: 'BatchedJob transition',
+ new_state: :succeeded,
+ previous_state: :running
+ }
+ )
job.succeed!
end
@@ -89,7 +113,15 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedJob, type: :model d
end
it 'logs the error' do
- expect(Gitlab::AppLogger).to receive(:error).with( { message: error_message, batched_job_id: job.id } )
+ expect(Gitlab::AppLogger).to receive(:error).with(
+ {
+ batched_job_id: job.id,
+ batched_migration_id: job.batched_background_migration_id,
+ job_arguments: job.batched_migration.job_arguments,
+ job_class_name: job.batched_migration.job_class_name,
+ message: error_message
+ }
+ )
job.failure!(error: exception)
end
@@ -100,13 +132,32 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedJob, type: :model d
let(:job) { create(:batched_background_migration_job, :running) }
it 'logs the transition' do
- expect(Gitlab::AppLogger).to receive(:info).with( { batched_job_id: job.id, message: 'BatchedJob transition', new_state: :failed, previous_state: :running } )
+ expect(Gitlab::AppLogger).to receive(:info).with(
+ {
+ batched_job_id: job.id,
+ batched_migration_id: job.batched_background_migration_id,
+ exception_class: RuntimeError,
+ exception_message: 'error',
+ job_arguments: job.batched_migration.job_arguments,
+ job_class_name: job.batched_migration.job_class_name,
+ message: 'BatchedJob transition',
+ new_state: :failed,
+ previous_state: :running
+ }
+ )
- job.failure!
+ job.failure!(error: RuntimeError.new('error'))
end
it 'tracks the exception' do
- expect(Gitlab::ErrorTracking).to receive(:track_exception).with(RuntimeError, { batched_job_id: job.id } )
+ expect(Gitlab::ErrorTracking).to receive(:track_exception).with(
+ RuntimeError,
+ {
+ batched_job_id: job.id,
+ job_arguments: job.batched_migration.job_arguments,
+ job_class_name: job.batched_migration.job_class_name
+ }
+ )
job.failure!(error: RuntimeError.new)
end
@@ -130,13 +181,11 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedJob, type: :model d
describe 'scopes' do
let_it_be(:fixed_time) { Time.new(2021, 04, 27, 10, 00, 00, 00) }
- let_it_be(:pending_job) { create(:batched_background_migration_job, :pending, updated_at: fixed_time) }
- let_it_be(:running_job) { create(:batched_background_migration_job, :running, updated_at: fixed_time) }
- let_it_be(:stuck_job) { create(:batched_background_migration_job, :pending, updated_at: fixed_time - described_class::STUCK_JOBS_TIMEOUT) }
- let_it_be(:failed_job) { create(:batched_background_migration_job, :failed, attempts: 1) }
-
- let!(:max_attempts_failed_job) { create(:batched_background_migration_job, :failed, attempts: described_class::MAX_ATTEMPTS) }
- let!(:succeeded_job) { create(:batched_background_migration_job, :succeeded) }
+ let_it_be(:pending_job) { create(:batched_background_migration_job, :pending, created_at: fixed_time - 2.days, updated_at: fixed_time) }
+ let_it_be(:running_job) { create(:batched_background_migration_job, :running, created_at: fixed_time - 2.days, updated_at: fixed_time) }
+ let_it_be(:stuck_job) { create(:batched_background_migration_job, :pending, created_at: fixed_time, updated_at: fixed_time - described_class::STUCK_JOBS_TIMEOUT) }
+ let_it_be(:failed_job) { create(:batched_background_migration_job, :failed, created_at: fixed_time, attempts: 1) }
+ let_it_be(:max_attempts_failed_job) { create(:batched_background_migration_job, :failed, created_at: fixed_time, attempts: described_class::MAX_ATTEMPTS) }
before do
travel_to fixed_time
@@ -165,6 +214,12 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedJob, type: :model d
expect(described_class.retriable).to contain_exactly(failed_job, stuck_job)
end
end
+
+ describe '.created_since' do
+ it 'returns jobs since a given time' do
+ expect(described_class.created_since(fixed_time)).to contain_exactly(stuck_job, failed_job, max_attempts_failed_job)
+ end
+ end
end
describe 'delegated batched_migration attributes' do
@@ -194,6 +249,12 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedJob, type: :model d
expect(batched_job.migration_job_arguments).to eq(batched_migration.job_arguments)
end
end
+
+ describe '#migration_job_class_name' do
+ it 'returns the migration job_class_name' do
+ expect(batched_job.migration_job_class_name).to eq(batched_migration.job_class_name)
+ end
+ end
end
describe '#can_split?' do
diff --git a/spec/lib/gitlab/database/background_migration/batched_migration_runner_spec.rb b/spec/lib/gitlab/database/background_migration/batched_migration_runner_spec.rb
index 124d204cb62..f147e8204e6 100644
--- a/spec/lib/gitlab/database/background_migration/batched_migration_runner_spec.rb
+++ b/spec/lib/gitlab/database/background_migration/batched_migration_runner_spec.rb
@@ -3,8 +3,16 @@
require 'spec_helper'
RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationRunner do
+ let(:connection) { Gitlab::Database.database_base_models[:main].connection }
let(:migration_wrapper) { double('test wrapper') }
- let(:runner) { described_class.new(migration_wrapper) }
+
+ let(:runner) { described_class.new(connection: connection, migration_wrapper: migration_wrapper) }
+
+ around do |example|
+ Gitlab::Database::SharedModel.using_connection(connection) do
+ example.run
+ end
+ end
describe '#run_migration_job' do
shared_examples_for 'it has completed the migration' do
@@ -86,6 +94,19 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationRunner do
end
end
+ context 'when the migration should stop' do
+ let(:migration) { create(:batched_background_migration, :active) }
+
+ let!(:job) { create(:batched_background_migration_job, :failed, batched_migration: migration) }
+
+ it 'changes the status to failure' do
+ expect(migration).to receive(:should_stop?).and_return(true)
+ expect(migration_wrapper).to receive(:perform).and_return(job)
+
+ expect { runner.run_migration_job(migration) }.to change { migration.status_name }.from(:active).to(:failed)
+ end
+ end
+
context 'when the migration has previous jobs' do
let!(:event1) { create(:event) }
let!(:event2) { create(:event) }
@@ -282,7 +303,9 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationRunner do
end
describe '#finalize' do
- let(:migration_wrapper) { Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper.new }
+ let(:migration_wrapper) do
+ Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper.new(connection: connection)
+ end
let(:migration_helpers) { ActiveRecord::Migration.new }
let(:table_name) { :_test_batched_migrations_test_table }
@@ -293,8 +316,7 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationRunner do
let!(:batched_migration) do
create(
- :batched_background_migration,
- status: migration_status,
+ :batched_background_migration, migration_status,
max_value: 8,
batch_size: 2,
sub_batch_size: 1,
@@ -339,7 +361,7 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationRunner do
.with('CopyColumnUsingBackgroundMigrationJob', table_name, column_name, job_arguments)
.and_return(batched_migration)
- expect(batched_migration).to receive(:finalizing!).and_call_original
+ expect(batched_migration).to receive(:finalize!).and_call_original
expect do
runner.finalize(
@@ -348,7 +370,7 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationRunner do
column_name,
job_arguments
)
- end.to change { batched_migration.reload.status }.from('active').to('finished')
+ end.to change { batched_migration.reload.status_name }.from(:active).to(:finished)
expect(batched_migration.batched_jobs).to all(be_succeeded)
@@ -390,7 +412,7 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationRunner do
expect(Gitlab::AppLogger).to receive(:warn)
.with("Batched background migration for the given configuration is already finished: #{configuration}")
- expect(batched_migration).not_to receive(:finalizing!)
+ expect(batched_migration).not_to receive(:finalize!)
runner.finalize(
batched_migration.job_class_name,
@@ -417,7 +439,7 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationRunner do
expect(Gitlab::AppLogger).to receive(:warn)
.with("Could not find batched background migration for the given configuration: #{configuration}")
- expect(batched_migration).not_to receive(:finalizing!)
+ expect(batched_migration).not_to receive(:finalize!)
runner.finalize(
batched_migration.job_class_name,
@@ -431,8 +453,6 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationRunner do
describe '.finalize' do
context 'when the connection is passed' do
- let(:connection) { double('connection') }
-
let(:table_name) { :_test_batched_migrations_test_table }
let(:column_name) { :some_id }
let(:job_arguments) { [:some, :other, :arguments] }
diff --git a/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb b/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb
index 803123e8e34..7a433be0e2f 100644
--- a/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb
+++ b/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb
@@ -27,28 +27,46 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigration, type: :m
it { is_expected.to validate_uniqueness_of(:job_arguments).scoped_to(:job_class_name, :table_name, :column_name) }
context 'when there are failed jobs' do
- let(:batched_migration) { create(:batched_background_migration, status: :active, total_tuple_count: 100) }
+ let(:batched_migration) { create(:batched_background_migration, :active, total_tuple_count: 100) }
let!(:batched_job) { create(:batched_background_migration_job, :failed, batched_migration: batched_migration) }
it 'raises an exception' do
- expect { batched_migration.finished! }.to raise_error(ActiveRecord::RecordInvalid)
+ expect { batched_migration.finish! }.to raise_error(StateMachines::InvalidTransition)
- expect(batched_migration.reload.status).to eql 'active'
+ expect(batched_migration.reload.status_name).to be :active
end
end
context 'when the jobs are completed' do
- let(:batched_migration) { create(:batched_background_migration, status: :active, total_tuple_count: 100) }
+ let(:batched_migration) { create(:batched_background_migration, :active, total_tuple_count: 100) }
let!(:batched_job) { create(:batched_background_migration_job, :succeeded, batched_migration: batched_migration) }
it 'finishes the migration' do
- batched_migration.finished!
+ batched_migration.finish!
- expect(batched_migration.status).to eql 'finished'
+ expect(batched_migration.status_name).to be :finished
end
end
end
+ describe 'state machine' do
+ context 'when a migration is executed' do
+ let!(:batched_migration) { create(:batched_background_migration) }
+
+ it 'updates the started_at' do
+ expect { batched_migration.execute! }.to change(batched_migration, :started_at).from(nil).to(Time)
+ end
+ end
+ end
+
+ describe '.valid_status' do
+ valid_status = [:paused, :active, :finished, :failed, :finalizing]
+
+ it 'returns valid status' do
+ expect(described_class.valid_status).to eq(valid_status)
+ end
+ end
+
describe '.queue_order' do
let!(:migration1) { create(:batched_background_migration) }
let!(:migration2) { create(:batched_background_migration) }
@@ -61,12 +79,23 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigration, type: :m
describe '.active_migration' do
let!(:migration1) { create(:batched_background_migration, :finished) }
- let!(:migration2) { create(:batched_background_migration, :active) }
- let!(:migration3) { create(:batched_background_migration, :active) }
- it 'returns the first active migration according to queue order' do
- expect(described_class.active_migration).to eq(migration2)
- create(:batched_background_migration_job, :succeeded, batched_migration: migration1, batch_size: 1000)
+ context 'without migrations on hold' do
+ let!(:migration2) { create(:batched_background_migration, :active) }
+ let!(:migration3) { create(:batched_background_migration, :active) }
+
+ it 'returns the first active migration according to queue order' do
+ expect(described_class.active_migration).to eq(migration2)
+ end
+ end
+
+ context 'with migrations are on hold' do
+ let!(:migration2) { create(:batched_background_migration, :active, on_hold_until: 10.minutes.from_now) }
+ let!(:migration3) { create(:batched_background_migration, :active, on_hold_until: 2.minutes.ago) }
+
+ it 'returns the first active migration that is not on hold according to queue order' do
+ expect(described_class.active_migration).to eq(migration3)
+ end
end
end
@@ -287,7 +316,7 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigration, type: :m
it 'moves the status of the migration to active' do
retry_failed_jobs
- expect(batched_migration.status).to eql 'active'
+ expect(batched_migration.status_name).to be :active
end
it 'changes the number of attempts to 0' do
@@ -301,8 +330,59 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigration, type: :m
it 'moves the status of the migration to active' do
retry_failed_jobs
- expect(batched_migration.status).to eql 'active'
+ expect(batched_migration.status_name).to be :active
+ end
+ end
+ end
+
+ describe '#should_stop?' do
+ subject(:should_stop?) { batched_migration.should_stop? }
+
+ let(:batched_migration) { create(:batched_background_migration, started_at: started_at) }
+
+ before do
+ stub_const('Gitlab::Database::BackgroundMigration::BatchedMigration::MINIMUM_JOBS', 1)
+ end
+
+ context 'when the started_at is nil' do
+ let(:started_at) { nil }
+
+ it { expect(should_stop?).to be_falsey }
+ end
+
+ context 'when the number of jobs is lesser than the MINIMUM_JOBS' do
+ let(:started_at) { Time.zone.now - 6.days }
+
+ before do
+ stub_const('Gitlab::Database::BackgroundMigration::BatchedMigration::MINIMUM_JOBS', 10)
+ stub_const('Gitlab::Database::BackgroundMigration::BatchedMigration::MAXIMUM_FAILED_RATIO', 0.70)
+ create_list(:batched_background_migration_job, 1, :succeeded, batched_migration: batched_migration)
+ create_list(:batched_background_migration_job, 3, :failed, batched_migration: batched_migration)
+ end
+
+ it { expect(should_stop?).to be_falsey }
+ end
+
+ context 'when the calculated value is greater than the threshold' do
+ let(:started_at) { Time.zone.now - 6.days }
+
+ before do
+ stub_const('Gitlab::Database::BackgroundMigration::BatchedMigration::MAXIMUM_FAILED_RATIO', 0.70)
+ create_list(:batched_background_migration_job, 1, :succeeded, batched_migration: batched_migration)
+ create_list(:batched_background_migration_job, 3, :failed, batched_migration: batched_migration)
+ end
+
+ it { expect(should_stop?).to be_truthy }
+ end
+
+ context 'when the calculated value is lesser than the threshold' do
+ let(:started_at) { Time.zone.now - 6.days }
+
+ before do
+ create_list(:batched_background_migration_job, 2, :succeeded, batched_migration: batched_migration)
end
+
+ it { expect(should_stop?).to be_falsey }
end
end
@@ -449,6 +529,20 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigration, type: :m
end
end
+ describe '#hold!', :freeze_time do
+ subject { create(:batched_background_migration) }
+
+ let(:time) { 5.minutes.from_now }
+
+ it 'updates on_hold_until property' do
+ expect { subject.hold!(until_time: time) }.to change { subject.on_hold_until }.from(nil).to(time)
+ end
+
+ it 'defaults to 10 minutes' do
+ expect { subject.hold! }.to change { subject.on_hold_until }.from(nil).to(10.minutes.from_now)
+ end
+ end
+
describe '.for_configuration' do
let!(:migration) do
create(
diff --git a/spec/lib/gitlab/database/background_migration/batched_migration_wrapper_spec.rb b/spec/lib/gitlab/database/background_migration/batched_migration_wrapper_spec.rb
index d6c984c7adb..6a4ac317cad 100644
--- a/spec/lib/gitlab/database/background_migration/batched_migration_wrapper_spec.rb
+++ b/spec/lib/gitlab/database/background_migration/batched_migration_wrapper_spec.rb
@@ -3,8 +3,10 @@
require 'spec_helper'
RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper, '#perform' do
- subject { described_class.new.perform(job_record) }
+ subject { described_class.new(connection: connection, metrics: metrics_tracker).perform(job_record) }
+ let(:connection) { Gitlab::Database.database_base_models[:main].connection }
+ let(:metrics_tracker) { instance_double('::Gitlab::Database::BackgroundMigration::PrometheusMetrics', track: nil) }
let(:job_class) { Gitlab::BackgroundMigration::CopyColumnUsingBackgroundMigrationJob }
let_it_be(:pause_ms) { 250 }
@@ -19,6 +21,12 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper, '
let(:job_instance) { double('job instance', batch_metrics: {}) }
+ around do |example|
+ Gitlab::Database::SharedModel.using_connection(connection) do
+ example.run
+ end
+ end
+
before do
allow(job_class).to receive(:new).and_return(job_instance)
end
@@ -78,86 +86,6 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper, '
end
end
- context 'reporting prometheus metrics' do
- let(:labels) { job_record.batched_migration.prometheus_labels }
-
- before do
- allow(job_instance).to receive(:perform)
- end
-
- it 'reports batch_size' do
- expect(described_class.metrics[:gauge_batch_size]).to receive(:set).with(labels, job_record.batch_size)
-
- subject
- end
-
- it 'reports sub_batch_size' do
- expect(described_class.metrics[:gauge_sub_batch_size]).to receive(:set).with(labels, job_record.sub_batch_size)
-
- subject
- end
-
- it 'reports interval' do
- expect(described_class.metrics[:gauge_interval]).to receive(:set).with(labels, job_record.batched_migration.interval)
-
- subject
- end
-
- it 'reports updated tuples (currently based on batch_size)' do
- expect(described_class.metrics[:counter_updated_tuples]).to receive(:increment).with(labels, job_record.batch_size)
-
- subject
- end
-
- it 'reports migrated tuples' do
- count = double
- expect(job_record.batched_migration).to receive(:migrated_tuple_count).and_return(count)
- expect(described_class.metrics[:gauge_migrated_tuples]).to receive(:set).with(labels, count)
-
- subject
- end
-
- it 'reports summary of query timings' do
- metrics = { 'timings' => { 'update_all' => [1, 2, 3, 4, 5] } }
-
- expect(job_instance).to receive(:batch_metrics).and_return(metrics)
-
- metrics['timings'].each do |key, timings|
- summary_labels = labels.merge(operation: key)
- timings.each do |timing|
- expect(described_class.metrics[:histogram_timings]).to receive(:observe).with(summary_labels, timing)
- end
- end
-
- subject
- end
-
- it 'reports job duration' do
- freeze_time do
- expect(Time).to receive(:current).and_return(Time.zone.now - 5.seconds).ordered
- allow(Time).to receive(:current).and_call_original
-
- expect(described_class.metrics[:gauge_job_duration]).to receive(:set).with(labels, 5.seconds)
-
- subject
- end
- end
-
- it 'reports the total tuple count for the migration' do
- expect(described_class.metrics[:gauge_total_tuple_count]).to receive(:set).with(labels, job_record.batched_migration.total_tuple_count)
-
- subject
- end
-
- it 'reports last updated at timestamp' do
- freeze_time do
- expect(described_class.metrics[:gauge_last_update_time]).to receive(:set).with(labels, Time.current.to_i)
-
- subject
- end
- end
- end
-
context 'when the migration job does not raise an error' do
it 'marks the tracking record as succeeded' do
expect(job_instance).to receive(:perform).with(1, 10, 'events', 'id', 1, pause_ms, 'id', 'other_id')
@@ -171,6 +99,13 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper, '
expect(reloaded_job_record.finished_at).to eq(Time.current)
end
end
+
+ it 'tracks metrics of the execution' do
+ expect(job_instance).to receive(:perform)
+ expect(metrics_tracker).to receive(:track).with(job_record)
+
+ subject
+ end
end
context 'when the migration job raises an error' do
@@ -189,6 +124,13 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper, '
expect(reloaded_job_record.finished_at).to eq(Time.current)
end
end
+
+ it 'tracks metrics of the execution' do
+ expect(job_instance).to receive(:perform).and_raise(error_class)
+ expect(metrics_tracker).to receive(:track).with(job_record)
+
+ expect { subject }.to raise_error(error_class)
+ end
end
it_behaves_like 'an error is raised', RuntimeError.new('Something broke!')
@@ -203,7 +145,6 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper, '
stub_const('Gitlab::BackgroundMigration::Foo', migration_class)
end
- let(:connection) { double(:connection) }
let(:active_migration) { create(:batched_background_migration, :active, job_class_name: 'Foo') }
let!(:job_record) { create(:batched_background_migration_job, batched_migration: active_migration) }
@@ -212,12 +153,11 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper, '
expect(job_instance).to receive(:perform)
- described_class.new(connection: connection).perform(job_record)
+ subject
end
end
context 'when the batched background migration inherits from BaseJob' do
- let(:connection) { double(:connection) }
let(:active_migration) { create(:batched_background_migration, :active, job_class_name: 'Foo') }
let!(:job_record) { create(:batched_background_migration_job, batched_migration: active_migration) }
@@ -232,7 +172,7 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper, '
expect(job_instance).to receive(:perform)
- described_class.new(connection: connection).perform(job_record)
+ subject
end
end
end
diff --git a/spec/lib/gitlab/database/background_migration/prometheus_metrics_spec.rb b/spec/lib/gitlab/database/background_migration/prometheus_metrics_spec.rb
new file mode 100644
index 00000000000..1f256de35ec
--- /dev/null
+++ b/spec/lib/gitlab/database/background_migration/prometheus_metrics_spec.rb
@@ -0,0 +1,118 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::BackgroundMigration::PrometheusMetrics, :prometheus do
+ describe '#track' do
+ let(:job_record) do
+ build(:batched_background_migration_job, :succeeded,
+ started_at: Time.current - 2.minutes,
+ finished_at: Time.current - 1.minute,
+ updated_at: Time.current,
+ metrics: { 'timings' => { 'update_all' => [0.05, 0.2, 0.4, 0.9, 4] } })
+ end
+
+ let(:labels) { job_record.batched_migration.prometheus_labels }
+
+ subject(:track_job_record_metrics) { described_class.new.track(job_record) }
+
+ it 'reports batch_size' do
+ track_job_record_metrics
+
+ expect(metric_for_job_by_name(:gauge_batch_size)).to eq(job_record.batch_size)
+ end
+
+ it 'reports sub_batch_size' do
+ track_job_record_metrics
+
+ expect(metric_for_job_by_name(:gauge_sub_batch_size)).to eq(job_record.sub_batch_size)
+ end
+
+ it 'reports interval' do
+ track_job_record_metrics
+
+ expect(metric_for_job_by_name(:gauge_interval)).to eq(job_record.batched_migration.interval)
+ end
+
+ it 'reports job duration' do
+ freeze_time do
+ track_job_record_metrics
+
+ expect(metric_for_job_by_name(:gauge_job_duration)).to eq(1.minute)
+ end
+ end
+
+ it 'increments updated tuples (currently based on batch_size)' do
+ expect(described_class.metrics[:counter_updated_tuples]).to receive(:increment)
+ .with(labels, job_record.batch_size)
+ .twice
+ .and_call_original
+
+ track_job_record_metrics
+
+ expect(metric_for_job_by_name(:counter_updated_tuples)).to eq(job_record.batch_size)
+
+ described_class.new.track(job_record)
+
+ expect(metric_for_job_by_name(:counter_updated_tuples)).to eq(job_record.batch_size * 2)
+ end
+
+ it 'reports migrated tuples' do
+ expect(job_record.batched_migration).to receive(:migrated_tuple_count).and_return(20)
+
+ track_job_record_metrics
+
+ expect(metric_for_job_by_name(:gauge_migrated_tuples)).to eq(20)
+ end
+
+ it 'reports the total tuple count for the migration' do
+ track_job_record_metrics
+
+ expect(metric_for_job_by_name(:gauge_total_tuple_count)).to eq(job_record.batched_migration.total_tuple_count)
+ end
+
+ it 'reports last updated at timestamp' do
+ freeze_time do
+ track_job_record_metrics
+
+ expect(metric_for_job_by_name(:gauge_last_update_time)).to eq(Time.current.to_i)
+ end
+ end
+
+ it 'reports summary of query timings' do
+ summary_labels = labels.merge(operation: 'update_all')
+
+ job_record.metrics['timings']['update_all'].each do |timing|
+ expect(described_class.metrics[:histogram_timings]).to receive(:observe)
+ .with(summary_labels, timing)
+ .and_call_original
+ end
+
+ track_job_record_metrics
+
+ expect(metric_for_job_by_name(:histogram_timings, job_labels: summary_labels))
+ .to eq({ 0.1 => 1.0, 0.25 => 2.0, 0.5 => 3.0, 1 => 4.0, 5 => 5.0 })
+ end
+
+ context 'when the tracking record does not having timing metrics' do
+ before do
+ job_record.metrics = {}
+ end
+
+ it 'does not attempt to report query timings' do
+ summary_labels = labels.merge(operation: 'update_all')
+
+ expect(described_class.metrics[:histogram_timings]).not_to receive(:observe)
+
+ track_job_record_metrics
+
+ expect(metric_for_job_by_name(:histogram_timings, job_labels: summary_labels))
+ .to eq({ 0.1 => 0.0, 0.25 => 0.0, 0.5 => 0.0, 1 => 0.0, 5 => 0.0 })
+ end
+ end
+
+ def metric_for_job_by_name(name, job_labels: labels)
+ described_class.metrics[name].values[job_labels].get
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/consistency_checker_spec.rb b/spec/lib/gitlab/database/consistency_checker_spec.rb
new file mode 100644
index 00000000000..2ff79d20786
--- /dev/null
+++ b/spec/lib/gitlab/database/consistency_checker_spec.rb
@@ -0,0 +1,189 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::ConsistencyChecker do
+ let(:batch_size) { 10 }
+ let(:max_batches) { 4 }
+ let(:max_runtime) { described_class::MAX_RUNTIME }
+ let(:metrics_counter) { Gitlab::Metrics.registry.get(:consistency_checks) }
+
+ subject(:consistency_checker) do
+ described_class.new(
+ source_model: Namespace,
+ target_model: Ci::NamespaceMirror,
+ source_columns: %w[id traversal_ids],
+ target_columns: %w[namespace_id traversal_ids]
+ )
+ end
+
+ before do
+ stub_const("#{described_class.name}::BATCH_SIZE", batch_size)
+ stub_const("#{described_class.name}::MAX_BATCHES", max_batches)
+ redis_shared_state_cleanup! # For Prometheus Counters
+ end
+
+ after do
+ Gitlab::Metrics.reset_registry!
+ end
+
+ describe '#over_time_limit?' do
+ before do
+ allow(consistency_checker).to receive(:start_time).and_return(0)
+ end
+
+ it 'returns true only if the running time has exceeded MAX_RUNTIME' do
+ allow(consistency_checker).to receive(:monotonic_time).and_return(0, max_runtime - 1, max_runtime + 1)
+ expect(consistency_checker.monotonic_time).to eq(0)
+ expect(consistency_checker.send(:over_time_limit?)).to eq(false)
+ expect(consistency_checker.send(:over_time_limit?)).to eq(true)
+ end
+ end
+
+ describe '#execute' do
+ context 'when empty tables' do
+ it 'returns an empty response' do
+ expected_result = { matches: 0, mismatches: 0, batches: 0, mismatches_details: [], next_start_id: nil }
+ expect(consistency_checker.execute(start_id: 1)).to eq(expected_result)
+ end
+ end
+
+ context 'when the tables contain matching items' do
+ before do
+ create_list(:namespace, 50) # This will also create Ci::NameSpaceMirror objects
+ end
+
+ it 'does not process more than MAX_BATCHES' do
+ max_batches = 3
+ stub_const("#{described_class.name}::MAX_BATCHES", max_batches)
+ result = consistency_checker.execute(start_id: Namespace.minimum(:id))
+ expect(result[:batches]).to eq(max_batches)
+ expect(result[:matches]).to eq(max_batches * batch_size)
+ end
+
+ it 'doesn not exceed the MAX_RUNTIME' do
+ allow(consistency_checker).to receive(:monotonic_time).and_return(0, max_runtime - 1, max_runtime + 1)
+ result = consistency_checker.execute(start_id: Namespace.minimum(:id))
+ expect(result[:batches]).to eq(1)
+ expect(result[:matches]).to eq(1 * batch_size)
+ end
+
+ it 'returns the correct number of matches and batches checked' do
+ expected_result = {
+ next_start_id: Namespace.minimum(:id) + described_class::MAX_BATCHES * described_class::BATCH_SIZE,
+ batches: max_batches,
+ matches: max_batches * batch_size,
+ mismatches: 0,
+ mismatches_details: []
+ }
+ expect(consistency_checker.execute(start_id: Namespace.minimum(:id))).to eq(expected_result)
+ end
+
+ it 'returns the min_id as the next_start_id if the check reaches the last element' do
+ expect(Gitlab::Metrics).to receive(:counter).at_most(:once)
+ .with(:consistency_checks, "Consistency Check Results")
+ .and_call_original
+
+ # Starting from the 5th last element
+ start_id = Namespace.all.order(id: :desc).limit(5).pluck(:id).last
+ expected_result = {
+ next_start_id: Namespace.first.id,
+ batches: 1,
+ matches: 5,
+ mismatches: 0,
+ mismatches_details: []
+ }
+ expect(consistency_checker.execute(start_id: start_id)).to eq(expected_result)
+
+ expect(metrics_counter.get(source_table: "namespaces", result: "mismatch")).to eq(0)
+ expect(metrics_counter.get(source_table: "namespaces", result: "match")).to eq(5)
+ end
+ end
+
+ context 'when some items are missing from the first table' do
+ let(:missing_namespace) { Namespace.all.order(:id).limit(2).last }
+
+ before do
+ create_list(:namespace, 50) # This will also create Ci::NameSpaceMirror objects
+ missing_namespace.delete
+ end
+
+ it 'reports the missing elements' do
+ expected_result = {
+ next_start_id: Namespace.first.id + described_class::MAX_BATCHES * described_class::BATCH_SIZE,
+ batches: max_batches,
+ matches: 39,
+ mismatches: 1,
+ mismatches_details: [{
+ id: missing_namespace.id,
+ source_table: nil,
+ target_table: [missing_namespace.traversal_ids]
+ }]
+ }
+ expect(consistency_checker.execute(start_id: Namespace.first.id)).to eq(expected_result)
+
+ expect(metrics_counter.get(source_table: "namespaces", result: "mismatch")).to eq(1)
+ expect(metrics_counter.get(source_table: "namespaces", result: "match")).to eq(39)
+ end
+ end
+
+ context 'when some items are missing from the second table' do
+ let(:missing_ci_namespace_mirror) { Ci::NamespaceMirror.all.order(:id).limit(2).last }
+
+ before do
+ create_list(:namespace, 50) # This will also create Ci::NameSpaceMirror objects
+ missing_ci_namespace_mirror.delete
+ end
+
+ it 'reports the missing elements' do
+ expected_result = {
+ next_start_id: Namespace.first.id + described_class::MAX_BATCHES * described_class::BATCH_SIZE,
+ batches: 4,
+ matches: 39,
+ mismatches: 1,
+ mismatches_details: [{
+ id: missing_ci_namespace_mirror.namespace_id,
+ source_table: [missing_ci_namespace_mirror.traversal_ids],
+ target_table: nil
+ }]
+ }
+ expect(consistency_checker.execute(start_id: Namespace.first.id)).to eq(expected_result)
+
+ expect(metrics_counter.get(source_table: "namespaces", result: "mismatch")).to eq(1)
+ expect(metrics_counter.get(source_table: "namespaces", result: "match")).to eq(39)
+ end
+ end
+
+ context 'when elements are different between the two tables' do
+ let(:different_namespaces) { Namespace.order(:id).limit(max_batches * batch_size).sample(3).sort_by(&:id) }
+
+ before do
+ create_list(:namespace, 50) # This will also create Ci::NameSpaceMirror objects
+
+ different_namespaces.each do |namespace|
+ namespace.update_attribute(:traversal_ids, [])
+ end
+ end
+
+ it 'reports the difference between the two tables' do
+ expected_result = {
+ next_start_id: Namespace.first.id + described_class::MAX_BATCHES * described_class::BATCH_SIZE,
+ batches: 4,
+ matches: 37,
+ mismatches: 3,
+ mismatches_details: different_namespaces.map do |namespace|
+ {
+ id: namespace.id,
+ source_table: [[]],
+ target_table: [[namespace.id]] # old traversal_ids of the namespace
+ }
+ end
+ }
+ expect(consistency_checker.execute(start_id: Namespace.first.id)).to eq(expected_result)
+
+ expect(metrics_counter.get(source_table: "namespaces", result: "mismatch")).to eq(3)
+ expect(metrics_counter.get(source_table: "namespaces", result: "match")).to eq(37)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/each_database_spec.rb b/spec/lib/gitlab/database/each_database_spec.rb
index d46c1ca8681..191f7017b4c 100644
--- a/spec/lib/gitlab/database/each_database_spec.rb
+++ b/spec/lib/gitlab/database/each_database_spec.rb
@@ -58,6 +58,15 @@ RSpec.describe Gitlab::Database::EachDatabase do
end
end
end
+
+ context 'when shared connections are not included' do
+ it 'only yields the unshared connections' do
+ expect(Gitlab::Database).to receive(:db_config_share_with).twice.and_return(nil, 'main')
+
+ expect { |b| described_class.each_database_connection(include_shared: false, &b) }
+ .to yield_successive_args([ActiveRecord::Base.connection, 'main'])
+ end
+ end
end
describe '.each_model_connection' do
diff --git a/spec/lib/gitlab/database/load_balancing/setup_spec.rb b/spec/lib/gitlab/database/load_balancing/setup_spec.rb
index 4d565ce137a..c44637b8d06 100644
--- a/spec/lib/gitlab/database/load_balancing/setup_spec.rb
+++ b/spec/lib/gitlab/database/load_balancing/setup_spec.rb
@@ -10,7 +10,6 @@ RSpec.describe Gitlab::Database::LoadBalancing::Setup do
expect(setup).to receive(:configure_connection)
expect(setup).to receive(:setup_connection_proxy)
expect(setup).to receive(:setup_service_discovery)
- expect(setup).to receive(:setup_feature_flag_to_model_load_balancing)
setup.setup
end
@@ -120,120 +119,46 @@ RSpec.describe Gitlab::Database::LoadBalancing::Setup do
end
end
- describe '#setup_feature_flag_to_model_load_balancing', :reestablished_active_record_base do
+ context 'uses correct base models', :reestablished_active_record_base do
using RSpec::Parameterized::TableSyntax
where do
{
- "with model LB enabled it picks a dedicated CI connection" => {
- env_GITLAB_USE_MODEL_LOAD_BALANCING: 'true',
+ "it picks a dedicated CI connection" => {
env_GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci: nil,
request_store_active: false,
- ff_use_model_load_balancing: nil,
ff_force_no_sharing_primary_model: false,
expectations: {
main: { read: 'main_replica', write: 'main' },
ci: { read: 'ci_replica', write: 'ci' }
}
},
- "with model LB enabled and re-use of primary connection it uses CI connection for reads" => {
- env_GITLAB_USE_MODEL_LOAD_BALANCING: 'true',
+ "with re-use of primary connection it uses CI connection for reads" => {
env_GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci: 'main',
request_store_active: false,
- ff_use_model_load_balancing: nil,
ff_force_no_sharing_primary_model: false,
expectations: {
main: { read: 'main_replica', write: 'main' },
ci: { read: 'ci_replica', write: 'main' }
}
},
- "with model LB disabled it fallbacks to use main" => {
- env_GITLAB_USE_MODEL_LOAD_BALANCING: 'false',
- env_GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci: nil,
- request_store_active: false,
- ff_use_model_load_balancing: nil,
- ff_force_no_sharing_primary_model: false,
- expectations: {
- main: { read: 'main_replica', write: 'main' },
- ci: { read: 'main_replica', write: 'main' }
- }
- },
- "with model LB disabled, but re-use configured it fallbacks to use main" => {
- env_GITLAB_USE_MODEL_LOAD_BALANCING: 'false',
+ "with re-use and FF force_no_sharing_primary_model enabled with RequestStore it sticks FF and uses CI connection for reads and writes" => {
env_GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci: 'main',
- request_store_active: false,
- ff_use_model_load_balancing: nil,
- ff_force_no_sharing_primary_model: false,
- expectations: {
- main: { read: 'main_replica', write: 'main' },
- ci: { read: 'main_replica', write: 'main' }
- }
- },
- "with FF use_model_load_balancing disabled without RequestStore it uses main" => {
- env_GITLAB_USE_MODEL_LOAD_BALANCING: nil,
- env_GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci: nil,
- request_store_active: false,
- ff_use_model_load_balancing: false,
- ff_force_no_sharing_primary_model: false,
- expectations: {
- main: { read: 'main_replica', write: 'main' },
- ci: { read: 'main_replica', write: 'main' }
- }
- },
- "with FF use_model_load_balancing enabled without RequestStore sticking of FF does not work, so it fallbacks to use main" => {
- env_GITLAB_USE_MODEL_LOAD_BALANCING: nil,
- env_GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci: nil,
- request_store_active: false,
- ff_use_model_load_balancing: true,
- ff_force_no_sharing_primary_model: false,
- expectations: {
- main: { read: 'main_replica', write: 'main' },
- ci: { read: 'main_replica', write: 'main' }
- }
- },
- "with FF use_model_load_balancing disabled with RequestStore it uses main" => {
- env_GITLAB_USE_MODEL_LOAD_BALANCING: nil,
- env_GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci: nil,
- request_store_active: true,
- ff_use_model_load_balancing: false,
- ff_force_no_sharing_primary_model: false,
- expectations: {
- main: { read: 'main_replica', write: 'main' },
- ci: { read: 'main_replica', write: 'main' }
- }
- },
- "with FF use_model_load_balancing enabled with RequestStore it sticks FF and uses CI connection" => {
- env_GITLAB_USE_MODEL_LOAD_BALANCING: nil,
- env_GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci: nil,
request_store_active: true,
- ff_use_model_load_balancing: true,
- ff_force_no_sharing_primary_model: false,
+ ff_force_no_sharing_primary_model: true,
expectations: {
main: { read: 'main_replica', write: 'main' },
ci: { read: 'ci_replica', write: 'ci' }
}
},
- "with re-use and ff_use_model_load_balancing enabled and FF force_no_sharing_primary_model disabled with RequestStore it sticks FF and uses CI connection for reads" => {
- env_GITLAB_USE_MODEL_LOAD_BALANCING: nil,
+ "with re-use and FF force_no_sharing_primary_model enabled without RequestStore it doesn't use FF and uses CI connection for reads only" => {
env_GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci: 'main',
request_store_active: true,
- ff_use_model_load_balancing: true,
ff_force_no_sharing_primary_model: false,
expectations: {
main: { read: 'main_replica', write: 'main' },
ci: { read: 'ci_replica', write: 'main' }
}
- },
- "with re-use and ff_use_model_load_balancing enabled and FF force_no_sharing_primary_model enabled with RequestStore it sticks FF and uses CI connection for reads" => {
- env_GITLAB_USE_MODEL_LOAD_BALANCING: nil,
- env_GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci: 'main',
- request_store_active: true,
- ff_use_model_load_balancing: true,
- ff_force_no_sharing_primary_model: true,
- expectations: {
- main: { read: 'main_replica', write: 'main' },
- ci: { read: 'ci_replica', write: 'ci' }
- }
}
}
end
@@ -285,9 +210,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::Setup do
end
end
- stub_env('GITLAB_USE_MODEL_LOAD_BALANCING', env_GITLAB_USE_MODEL_LOAD_BALANCING)
stub_env('GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci', env_GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci)
- stub_feature_flags(use_model_load_balancing: ff_use_model_load_balancing)
# Make load balancer to force init with a dedicated replicas connections
models.each do |_, model|
diff --git a/spec/lib/gitlab/database/migration_helpers/restrict_gitlab_schema_spec.rb b/spec/lib/gitlab/database/migration_helpers/restrict_gitlab_schema_spec.rb
index ad9a3a6e257..e7b5bad8626 100644
--- a/spec/lib/gitlab/database/migration_helpers/restrict_gitlab_schema_spec.rb
+++ b/spec/lib/gitlab/database/migration_helpers/restrict_gitlab_schema_spec.rb
@@ -240,7 +240,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers::RestrictGitlabSchema, query_a
end
def software_license_class
- Class.new(ActiveRecord::Base) do
+ Class.new(Gitlab::Database::Migration[2.0]::MigrationRecord) do
self.table_name = 'software_licenses'
end
end
@@ -272,7 +272,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers::RestrictGitlabSchema, query_a
end
def ci_instance_variables_class
- Class.new(ActiveRecord::Base) do
+ Class.new(Gitlab::Database::Migration[2.0]::MigrationRecord) do
self.table_name = 'ci_instance_variables'
end
end
@@ -303,7 +303,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers::RestrictGitlabSchema, query_a
end
def detached_partitions_class
- Class.new(ActiveRecord::Base) do
+ Class.new(Gitlab::Database::Migration[2.0]::MigrationRecord) do
self.table_name = 'detached_partitions'
end
end
@@ -496,11 +496,16 @@ RSpec.describe Gitlab::Database::MigrationHelpers::RestrictGitlabSchema, query_a
Gitlab::Database.database_base_models.each do |db_config_name, model|
context "for db_config_name=#{db_config_name}" do
around do |example|
+ verbose_was = ActiveRecord::Migration.verbose
+ ActiveRecord::Migration.verbose = false
+
with_reestablished_active_record_base do
reconfigure_db_connection(model: ActiveRecord::Base, config_model: model)
example.run
end
+ ensure
+ ActiveRecord::Migration.verbose = verbose_was
end
before do
@@ -543,8 +548,15 @@ RSpec.describe Gitlab::Database::MigrationHelpers::RestrictGitlabSchema, query_a
expect { ignore_error(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas::DDLNotAllowedError) { migration_class.migrate(:down) } }.not_to raise_error
when :skipped
- expect { migration_class.migrate(:up) }.to raise_error(Gitlab::Database::MigrationHelpers::RestrictGitlabSchema::MigrationSkippedError)
- expect { migration_class.migrate(:down) }.to raise_error(Gitlab::Database::MigrationHelpers::RestrictGitlabSchema::MigrationSkippedError)
+ expect_next_instance_of(migration_class) do |migration_object|
+ expect(migration_object).to receive(:migration_skipped).and_call_original
+ expect(migration_object).not_to receive(:up)
+ expect(migration_object).not_to receive(:down)
+ expect(migration_object).not_to receive(:change)
+ end
+
+ migration_class.migrate(:up)
+ migration_class.migrate(:down)
end
end
end
diff --git a/spec/lib/gitlab/database/migration_helpers/v2_spec.rb b/spec/lib/gitlab/database/migration_helpers/v2_spec.rb
index acf775b3538..5c054795697 100644
--- a/spec/lib/gitlab/database/migration_helpers/v2_spec.rb
+++ b/spec/lib/gitlab/database/migration_helpers/v2_spec.rb
@@ -96,6 +96,12 @@ RSpec.describe Gitlab::Database::MigrationHelpers::V2 do
expect(new_record_1.reload).to have_attributes(status: 1, original: 'updated', renamed: 'updated')
expect(new_record_2.reload).to have_attributes(status: 1, original: nil, renamed: nil)
end
+
+ it 'requires the helper to run in ddl mode' do
+ expect(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas).to receive(:require_ddl_mode!)
+
+ migration.public_send(operation, :_test_table, :original, :renamed)
+ end
end
describe '#rename_column_concurrently' do
diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb
index 9505da8fd12..798eee0de3e 100644
--- a/spec/lib/gitlab/database/migration_helpers_spec.rb
+++ b/spec/lib/gitlab/database/migration_helpers_spec.rb
@@ -1390,6 +1390,8 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
end
it 'reverses the operations of cleanup_concurrent_column_type_change' do
+ expect(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas).to receive(:require_ddl_mode!)
+
expect(model).to receive(:check_trigger_permissions!).with(:users)
expect(model).to receive(:create_column_from).with(
@@ -1415,6 +1417,8 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
end
it 'passes the type_cast_function, batch_column_name and limit' do
+ expect(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas).to receive(:require_ddl_mode!)
+
expect(model).to receive(:column_exists?).with(:users, :other_batch_column).and_return(true)
expect(model).to receive(:check_trigger_permissions!).with(:users)
@@ -2096,7 +2100,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
end
end
- let(:migration_relation) { Gitlab::Database::BackgroundMigration::BatchedMigration.active }
+ let(:migration_relation) { Gitlab::Database::BackgroundMigration::BatchedMigration.with_status(:active) }
before do
model.initialize_conversion_of_integer_to_bigint(table, columns)
@@ -2218,7 +2222,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
subject(:ensure_batched_background_migration_is_finished) { model.ensure_batched_background_migration_is_finished(**configuration) }
it 'raises an error when migration exists and is not marked as finished' do
- create(:batched_background_migration, configuration.merge(status: :active))
+ create(:batched_background_migration, :active, configuration)
expect { ensure_batched_background_migration_is_finished }
.to raise_error "Expected batched background migration for the given configuration to be marked as 'finished', but it is 'active':" \
@@ -2234,7 +2238,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
end
it 'does not raise error when migration exists and is marked as finished' do
- create(:batched_background_migration, configuration.merge(status: :finished))
+ create(:batched_background_migration, :finished, configuration)
expect { ensure_batched_background_migration_is_finished }
.not_to raise_error
@@ -2422,7 +2426,8 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
def setup
namespace = namespaces.create!(name: 'foo', path: 'foo', type: Namespaces::UserNamespace.sti_name)
- projects.create!(namespace_id: namespace.id)
+ project_namespace = namespaces.create!(name: 'project-foo', path: 'project-foo', type: 'Project', parent_id: namespace.id, visibility_level: 20)
+ projects.create!(namespace_id: namespace.id, project_namespace_id: project_namespace.id)
end
it 'generates iids properly for models created after the migration' do
diff --git a/spec/lib/gitlab/database/migration_spec.rb b/spec/lib/gitlab/database/migration_spec.rb
index 287e738c24e..18bbc6c1dd3 100644
--- a/spec/lib/gitlab/database/migration_spec.rb
+++ b/spec/lib/gitlab/database/migration_spec.rb
@@ -32,7 +32,7 @@ RSpec.describe Gitlab::Database::Migration do
# This breaks upon Rails upgrade. In that case, we'll add a new version in Gitlab::Database::Migration::MIGRATION_CLASSES,
# bump .current_version and leave existing migrations and already defined versions of Gitlab::Database::Migration
# untouched.
- expect(described_class[described_class.current_version].superclass).to eq(ActiveRecord::Migration::Current)
+ expect(described_class[described_class.current_version]).to be < ActiveRecord::Migration::Current
end
end
diff --git a/spec/lib/gitlab/database/migrations/batched_background_migration_helpers_spec.rb b/spec/lib/gitlab/database/migrations/batched_background_migration_helpers_spec.rb
index 37efff165c7..f9347a174c4 100644
--- a/spec/lib/gitlab/database/migrations/batched_background_migration_helpers_spec.rb
+++ b/spec/lib/gitlab/database/migrations/batched_background_migration_helpers_spec.rb
@@ -75,7 +75,7 @@ RSpec.describe Gitlab::Database::Migrations::BatchedBackgroundMigrationHelpers d
max_batch_size: 10000,
sub_batch_size: 10,
job_arguments: %w[],
- status: 'active',
+ status_name: :active,
total_tuple_count: pgclass_info.cardinality_estimate)
end
diff --git a/spec/lib/gitlab/database/migrations/instrumentation_spec.rb b/spec/lib/gitlab/database/migrations/instrumentation_spec.rb
index fd8303c379c..c31244060ec 100644
--- a/spec/lib/gitlab/database/migrations/instrumentation_spec.rb
+++ b/spec/lib/gitlab/database/migrations/instrumentation_spec.rb
@@ -11,6 +11,10 @@ RSpec.describe Gitlab::Database::Migrations::Instrumentation do
describe '#observe' do
subject { described_class.new(result_dir: result_dir) }
+ def load_observation(result_dir, migration_name)
+ Gitlab::Json.parse(File.read(File.join(result_dir, migration_name, described_class::STATS_FILENAME)))
+ end
+
let(:migration_name) { 'test' }
let(:migration_version) { '12345' }
@@ -87,7 +91,7 @@ RSpec.describe Gitlab::Database::Migrations::Instrumentation do
end
context 'retrieving observations' do
- subject { instance.observations.first }
+ subject { load_observation(result_dir, migration_name) }
before do
observe
@@ -98,10 +102,10 @@ RSpec.describe Gitlab::Database::Migrations::Instrumentation do
end
it 'records a valid observation', :aggregate_failures do
- expect(subject.walltime).not_to be_nil
- expect(subject.success).to be_falsey
- expect(subject.version).to eq(migration_version)
- expect(subject.name).to eq(migration_name)
+ expect(subject['walltime']).not_to be_nil
+ expect(subject['success']).to be_falsey
+ expect(subject['version']).to eq(migration_version)
+ expect(subject['name']).to eq(migration_name)
end
end
end
@@ -113,11 +117,18 @@ RSpec.describe Gitlab::Database::Migrations::Instrumentation do
let(:migration1) { double('migration1', call: nil) }
let(:migration2) { double('migration2', call: nil) }
+ let(:migration_name_2) { 'other_migration' }
+ let(:migration_version_2) { '98765' }
+
it 'records observations for all migrations' do
subject.observe(version: migration_version, name: migration_name, connection: connection) {}
- subject.observe(version: migration_version, name: migration_name, connection: connection) { raise 'something went wrong' } rescue nil
+ subject.observe(version: migration_version_2, name: migration_name_2, connection: connection) { raise 'something went wrong' } rescue nil
+
+ expect { load_observation(result_dir, migration_name) }.not_to raise_error
+ expect { load_observation(result_dir, migration_name_2) }.not_to raise_error
- expect(subject.observations.size).to eq(2)
+ # Each observation is a subdirectory of the result_dir, so here we check that we didn't record an extra one
+ expect(Pathname(result_dir).children.map { |d| d.basename.to_s }).to contain_exactly(migration_name, migration_name_2)
end
end
end
diff --git a/spec/lib/gitlab/database/migrations/runner_spec.rb b/spec/lib/gitlab/database/migrations/runner_spec.rb
index 84482e6b450..8b1ccf05eb1 100644
--- a/spec/lib/gitlab/database/migrations/runner_spec.rb
+++ b/spec/lib/gitlab/database/migrations/runner_spec.rb
@@ -124,4 +124,16 @@ RSpec.describe Gitlab::Database::Migrations::Runner do
expect(metadata).to match('version' => described_class::SCHEMA_VERSION)
end
end
+
+ describe '.background_migrations' do
+ it 'is a TestBackgroundRunner' do
+ expect(described_class.background_migrations).to be_a(Gitlab::Database::Migrations::TestBackgroundRunner)
+ end
+
+ it 'is configured with a result dir of /background_migrations' do
+ runner = described_class.background_migrations
+
+ expect(runner.result_dir).to eq(described_class::BASE_RESULT_DIR.join( 'background_migrations'))
+ end
+ end
end
diff --git a/spec/lib/gitlab/database/migrations/test_background_runner_spec.rb b/spec/lib/gitlab/database/migrations/test_background_runner_spec.rb
index c6fe88a7c2d..9407efad91f 100644
--- a/spec/lib/gitlab/database/migrations/test_background_runner_spec.rb
+++ b/spec/lib/gitlab/database/migrations/test_background_runner_spec.rb
@@ -11,11 +11,17 @@ RSpec.describe Gitlab::Database::Migrations::TestBackgroundRunner, :redis do
Sidekiq::Testing.disable! { ex.run }
end
+ let(:result_dir) { Dir.mktmpdir }
+
+ after do
+ FileUtils.rm_rf(result_dir)
+ end
+
context 'without jobs to run' do
it 'returns immediately' do
- runner = described_class.new
+ runner = described_class.new(result_dir: result_dir)
expect(runner).not_to receive(:run_job)
- described_class.new.run_jobs(for_duration: 1.second)
+ described_class.new(result_dir: result_dir).run_jobs(for_duration: 1.second)
end
end
@@ -30,7 +36,7 @@ RSpec.describe Gitlab::Database::Migrations::TestBackgroundRunner, :redis do
context 'finding pending background jobs' do
it 'finds all the migrations' do
- expect(described_class.new.traditional_background_migrations.to_a.size).to eq(5)
+ expect(described_class.new(result_dir: result_dir).traditional_background_migrations.to_a.size).to eq(5)
end
end
@@ -53,12 +59,28 @@ RSpec.describe Gitlab::Database::Migrations::TestBackgroundRunner, :redis do
end
end
+ def expect_recorded_migration_runs(migrations_to_runs)
+ migrations_to_runs.each do |migration, runs|
+ path = File.join(result_dir, migration.name.demodulize)
+ num_subdirs = Pathname(path).children.count(&:directory?)
+ expect(num_subdirs).to eq(runs)
+ end
+ end
+
+ def expect_migration_runs(migrations_to_run_counts)
+ expect_migration_call_counts(migrations_to_run_counts)
+
+ yield
+
+ expect_recorded_migration_runs(migrations_to_run_counts)
+ end
+
it 'runs the migration class correctly' do
calls = []
define_background_migration(migration_name) do |i|
calls << i
end
- described_class.new.run_jobs(for_duration: 1.second) # Any time would work here as we do not advance time
+ described_class.new(result_dir: result_dir).run_jobs(for_duration: 1.second) # Any time would work here as we do not advance time
expect(calls).to contain_exactly(1, 2, 3, 4, 5)
end
@@ -67,9 +89,9 @@ RSpec.describe Gitlab::Database::Migrations::TestBackgroundRunner, :redis do
travel(1.minute)
end
- expect_migration_call_counts(migration => 3)
-
- described_class.new.run_jobs(for_duration: 3.minutes)
+ expect_migration_runs(migration => 3) do
+ described_class.new(result_dir: result_dir).run_jobs(for_duration: 3.minutes)
+ end
end
context 'with multiple migrations to run' do
@@ -90,12 +112,12 @@ RSpec.describe Gitlab::Database::Migrations::TestBackgroundRunner, :redis do
travel(2.minutes)
end
- expect_migration_call_counts(
+ expect_migration_runs(
migration => 2, # 1 minute jobs for 90 seconds, can finish the first and start the second
other_migration => 1 # 2 minute jobs for 90 seconds, past deadline after a single job
- )
-
- described_class.new.run_jobs(for_duration: 3.minutes)
+ ) do
+ described_class.new(result_dir: result_dir).run_jobs(for_duration: 3.minutes)
+ end
end
it 'does not give leftover time to extra migrations' do
@@ -107,12 +129,13 @@ RSpec.describe Gitlab::Database::Migrations::TestBackgroundRunner, :redis do
other_migration = define_background_migration(other_migration_name) do
travel(1.minute)
end
- expect_migration_call_counts(
+
+ expect_migration_runs(
migration => 5,
other_migration => 2
- )
-
- described_class.new.run_jobs(for_duration: 3.minutes)
+ ) do
+ described_class.new(result_dir: result_dir).run_jobs(for_duration: 3.minutes)
+ end
end
end
end
diff --git a/spec/lib/gitlab/database/partitioning_migration_helpers/table_management_helpers_spec.rb b/spec/lib/gitlab/database/partitioning_migration_helpers/table_management_helpers_spec.rb
index 4f1d6302331..1026b4370a5 100644
--- a/spec/lib/gitlab/database/partitioning_migration_helpers/table_management_helpers_spec.rb
+++ b/spec/lib/gitlab/database/partitioning_migration_helpers/table_management_helpers_spec.rb
@@ -125,6 +125,17 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHe
expect_table_partitioned_by(partitioned_table, [partition_column])
end
+ it 'requires the migration helper to be run in DDL mode' do
+ expect(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas).to receive(:require_ddl_mode!)
+
+ migration.partition_table_by_date source_table, partition_column, min_date: min_date, max_date: max_date
+
+ expect(connection.table_exists?(partitioned_table)).to be(true)
+ expect(connection.primary_key(partitioned_table)).to eq(new_primary_key)
+
+ expect_table_partitioned_by(partitioned_table, [partition_column])
+ end
+
it 'changes the primary key datatype to bigint' do
migration.partition_table_by_date source_table, partition_column, min_date: min_date, max_date: max_date
@@ -191,6 +202,8 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHe
end
it 'creates a partition spanning over each month from the first record' do
+ expect(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas).to receive(:with_suppressed).and_yield
+
migration.partition_table_by_date source_table, partition_column, max_date: max_date
expect_range_partitions_for(partitioned_table, {
@@ -206,6 +219,8 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHe
context 'without data' do
it 'creates the catchall partition plus two actual partition' do
+ expect(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas).to receive(:with_suppressed).and_yield
+
migration.partition_table_by_date source_table, partition_column, max_date: max_date
expect_range_partitions_for(partitioned_table, {
@@ -536,6 +551,16 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHe
migration.finalize_backfilling_partitioned_table source_table
end
+
+ it 'requires the migration helper to execute in DML mode' do
+ expect(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas).to receive(:require_dml_mode!)
+
+ expect(Gitlab::BackgroundMigration).to receive(:steal)
+ .with(described_class::MIGRATION_CLASS_NAME)
+ .and_yield(background_job)
+
+ migration.finalize_backfilling_partitioned_table source_table
+ end
end
context 'when there is missed data' do
@@ -627,6 +652,7 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHe
allow(backfill).to receive(:perform).and_return(1)
end
+ expect(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas).to receive(:with_suppressed).and_yield
expect(migration).to receive(:disable_statement_timeout).and_call_original
expect(migration).to receive(:execute).with("VACUUM FREEZE ANALYZE #{partitioned_table}")
diff --git a/spec/lib/gitlab/database/query_analyzers/gitlab_schemas_metrics_spec.rb b/spec/lib/gitlab/database/query_analyzers/gitlab_schemas_metrics_spec.rb
index 86e74cf5177..b8c1ecd9089 100644
--- a/spec/lib/gitlab/database/query_analyzers/gitlab_schemas_metrics_spec.rb
+++ b/spec/lib/gitlab/database/query_analyzers/gitlab_schemas_metrics_spec.rb
@@ -17,7 +17,7 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::GitlabSchemasMetrics, query_ana
process_sql(ActiveRecord::Base, "SELECT 1 FROM projects")
end
- context 'properly observes all queries', :add_ci_connection do
+ context 'properly observes all queries', :add_ci_connection, :request_store do
using RSpec::Parameterized::TableSyntax
where do
@@ -28,7 +28,8 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::GitlabSchemasMetrics, query_ana
expectations: {
gitlab_schemas: "gitlab_main",
db_config_name: "main"
- }
+ },
+ setup: nil
},
"for query accessing gitlab_ci and gitlab_main" => {
model: ApplicationRecord,
@@ -36,7 +37,8 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::GitlabSchemasMetrics, query_ana
expectations: {
gitlab_schemas: "gitlab_ci,gitlab_main",
db_config_name: "main"
- }
+ },
+ setup: nil
},
"for query accessing gitlab_ci and gitlab_main the gitlab_schemas is always ordered" => {
model: ApplicationRecord,
@@ -44,7 +46,8 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::GitlabSchemasMetrics, query_ana
expectations: {
gitlab_schemas: "gitlab_ci,gitlab_main",
db_config_name: "main"
- }
+ },
+ setup: nil
},
"for query accessing CI database" => {
model: Ci::ApplicationRecord,
@@ -53,6 +56,62 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::GitlabSchemasMetrics, query_ana
gitlab_schemas: "gitlab_ci",
db_config_name: "ci"
}
+ },
+ "for query accessing CI database with re-use and disabled sharing" => {
+ model: Ci::ApplicationRecord,
+ sql: "SELECT 1 FROM ci_builds",
+ expectations: {
+ gitlab_schemas: "gitlab_ci",
+ db_config_name: "ci",
+ ci_dedicated_primary_connection: true
+ },
+ setup: ->(_) do
+ skip_if_multiple_databases_not_setup
+ stub_env('GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci', 'main')
+ stub_feature_flags(force_no_sharing_primary_model: true)
+ end
+ },
+ "for query accessing CI database with re-use and enabled sharing" => {
+ model: Ci::ApplicationRecord,
+ sql: "SELECT 1 FROM ci_builds",
+ expectations: {
+ gitlab_schemas: "gitlab_ci",
+ db_config_name: "ci",
+ ci_dedicated_primary_connection: false
+ },
+ setup: ->(_) do
+ skip_if_multiple_databases_not_setup
+ stub_env('GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci', 'main')
+ stub_feature_flags(force_no_sharing_primary_model: false)
+ end
+ },
+ "for query accessing CI database without re-use and disabled sharing" => {
+ model: Ci::ApplicationRecord,
+ sql: "SELECT 1 FROM ci_builds",
+ expectations: {
+ gitlab_schemas: "gitlab_ci",
+ db_config_name: "ci",
+ ci_dedicated_primary_connection: true
+ },
+ setup: ->(_) do
+ skip_if_multiple_databases_not_setup
+ stub_env('GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci', nil)
+ stub_feature_flags(force_no_sharing_primary_model: true)
+ end
+ },
+ "for query accessing CI database without re-use and enabled sharing" => {
+ model: Ci::ApplicationRecord,
+ sql: "SELECT 1 FROM ci_builds",
+ expectations: {
+ gitlab_schemas: "gitlab_ci",
+ db_config_name: "ci",
+ ci_dedicated_primary_connection: true
+ },
+ setup: ->(_) do
+ skip_if_multiple_databases_not_setup
+ stub_env('GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci', nil)
+ stub_feature_flags(force_no_sharing_primary_model: false)
+ end
}
}
end
@@ -63,8 +122,15 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::GitlabSchemasMetrics, query_ana
end
it do
+ stub_env('GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci', nil)
+
+ instance_eval(&setup) if setup
+
+ allow(::Ci::ApplicationRecord.load_balancer).to receive(:configuration)
+ .and_return(Gitlab::Database::LoadBalancing::Configuration.for_model(::Ci::ApplicationRecord))
+
expect(described_class.schemas_metrics).to receive(:increment)
- .with(expectations).and_call_original
+ .with({ ci_dedicated_primary_connection: anything }.merge(expectations)).and_call_original
process_sql(model, sql)
end
diff --git a/spec/lib/gitlab/database/reindexing/grafana_notifier_spec.rb b/spec/lib/gitlab/database/reindexing/grafana_notifier_spec.rb
index e76718fe48a..34670696787 100644
--- a/spec/lib/gitlab/database/reindexing/grafana_notifier_spec.rb
+++ b/spec/lib/gitlab/database/reindexing/grafana_notifier_spec.rb
@@ -74,8 +74,28 @@ RSpec.describe Gitlab::Database::Reindexing::GrafanaNotifier do
end
describe '#notify_start' do
- context 'additional tag is nil' do
- subject { described_class.new(api_key, api_url, nil).notify_start(action) }
+ context 'when Grafana is configured using application settings' do
+ subject { described_class.new.notify_start(action) }
+
+ let(:payload) do
+ {
+ time: (action.action_start.utc.to_f * 1000).to_i,
+ tags: ['reindex', additional_tag, action.index.tablename, action.index.name],
+ text: "Started reindexing of #{action.index.name} on #{action.index.tablename}"
+ }
+ end
+
+ before do
+ stub_application_setting(database_grafana_api_key: api_key)
+ stub_application_setting(database_grafana_api_url: api_url)
+ stub_application_setting(database_grafana_tag: additional_tag)
+ end
+
+ it_behaves_like 'interacting with Grafana annotations API'
+ end
+
+ context 'when there is no additional tag' do
+ subject { described_class.new(api_key: api_key, api_url: api_url, additional_tag: '').notify_start(action) }
let(:payload) do
{
@@ -88,8 +108,8 @@ RSpec.describe Gitlab::Database::Reindexing::GrafanaNotifier do
it_behaves_like 'interacting with Grafana annotations API'
end
- context 'additional tag is not nil' do
- subject { described_class.new(api_key, api_url, additional_tag).notify_start(action) }
+ context 'additional tag is provided' do
+ subject { described_class.new(api_key: api_key, api_url: api_url, additional_tag: additional_tag).notify_start(action) }
let(:payload) do
{
@@ -104,8 +124,30 @@ RSpec.describe Gitlab::Database::Reindexing::GrafanaNotifier do
end
describe '#notify_end' do
- context 'additional tag is nil' do
- subject { described_class.new(api_key, api_url, nil).notify_end(action) }
+ context 'when Grafana is configured using application settings' do
+ subject { described_class.new.notify_end(action) }
+
+ let(:payload) do
+ {
+ time: (action.action_start.utc.to_f * 1000).to_i,
+ tags: ['reindex', additional_tag, action.index.tablename, action.index.name],
+ text: "Finished reindexing of #{action.index.name} on #{action.index.tablename} (#{action.state})",
+ timeEnd: (action.action_end.utc.to_f * 1000).to_i,
+ isRegion: true
+ }
+ end
+
+ before do
+ stub_application_setting(database_grafana_api_key: api_key)
+ stub_application_setting(database_grafana_api_url: api_url)
+ stub_application_setting(database_grafana_tag: additional_tag)
+ end
+
+ it_behaves_like 'interacting with Grafana annotations API'
+ end
+
+ context 'when there is no additional tag' do
+ subject { described_class.new(api_key: api_key, api_url: api_url, additional_tag: '').notify_end(action) }
let(:payload) do
{
@@ -120,8 +162,8 @@ RSpec.describe Gitlab::Database::Reindexing::GrafanaNotifier do
it_behaves_like 'interacting with Grafana annotations API'
end
- context 'additional tag is not nil' do
- subject { described_class.new(api_key, api_url, additional_tag).notify_end(action) }
+ context 'additional tag is provided' do
+ subject { described_class.new(api_key: api_key, api_url: api_url, additional_tag: additional_tag).notify_end(action) }
let(:payload) do
{
diff --git a/spec/lib/gitlab/database/schema_cache_with_renamed_table_spec.rb b/spec/lib/gitlab/database/schema_cache_with_renamed_table_spec.rb
index 7caee414719..0bea348e6b4 100644
--- a/spec/lib/gitlab/database/schema_cache_with_renamed_table_spec.rb
+++ b/spec/lib/gitlab/database/schema_cache_with_renamed_table_spec.rb
@@ -68,8 +68,8 @@ RSpec.describe Gitlab::Database::SchemaCacheWithRenamedTable do
describe 'when the table behind a model is actually a view' do
let(:group) { create(:group) }
- let(:project_attributes) { attributes_for(:project, namespace_id: group.id).except(:creator) }
- let(:record) { old_model.create!(project_attributes) }
+ let(:attrs) { attributes_for(:project, namespace_id: group.id, project_namespace_id: group.id).except(:creator) }
+ let(:record) { old_model.create!(attrs) }
it 'can persist records' do
expect(record.reload.attributes).to eq(new_model.find(record.id).attributes)
diff --git a/spec/lib/gitlab/database_spec.rb b/spec/lib/gitlab/database_spec.rb
index c58dba213ee..ac8616f84a7 100644
--- a/spec/lib/gitlab/database_spec.rb
+++ b/spec/lib/gitlab/database_spec.rb
@@ -185,16 +185,6 @@ RSpec.describe Gitlab::Database do
end
end
- describe '.nulls_last_order' do
- it { expect(described_class.nulls_last_order('column', 'ASC')).to eq 'column ASC NULLS LAST'}
- it { expect(described_class.nulls_last_order('column', 'DESC')).to eq 'column DESC NULLS LAST'}
- end
-
- describe '.nulls_first_order' do
- it { expect(described_class.nulls_first_order('column', 'ASC')).to eq 'column ASC NULLS FIRST'}
- it { expect(described_class.nulls_first_order('column', 'DESC')).to eq 'column DESC NULLS FIRST'}
- end
-
describe '.db_config_for_connection' do
context 'when the regular connection is used' do
it 'returns db_config' do
@@ -245,15 +235,32 @@ RSpec.describe Gitlab::Database do
end
end
+ describe '.db_config_names' do
+ let(:expected) { %w[foo bar] }
+
+ it 'includes only main by default' do
+ allow(::ActiveRecord::Base).to receive(:configurations).and_return(
+ double(configs_for: %w[foo bar].map { |x| double(name: x) })
+ )
+
+ expect(described_class.db_config_names).to eq(expected)
+ end
+
+ it 'excludes geo when that is included' do
+ allow(::ActiveRecord::Base).to receive(:configurations).and_return(
+ double(configs_for: %w[foo bar geo].map { |x| double(name: x) })
+ )
+
+ expect(described_class.db_config_names).to eq(expected)
+ end
+ end
+
describe '.gitlab_schemas_for_connection' do
it 'does raise exception for invalid connection' do
expect { described_class.gitlab_schemas_for_connection(:invalid) }.to raise_error /key not found: "unknown"/
end
it 'does return a valid schema depending on a base model used', :request_store do
- # This is currently required as otherwise the `Ci::Build.connection` == `Project.connection`
- # ENV due to lib/gitlab/database/load_balancing/setup.rb:93
- stub_env('GITLAB_USE_MODEL_LOAD_BALANCING', '1')
# FF due to lib/gitlab/database/load_balancing/configuration.rb:92
stub_feature_flags(force_no_sharing_primary_model: true)
@@ -268,6 +275,47 @@ RSpec.describe Gitlab::Database do
expect(described_class.gitlab_schemas_for_connection(ActiveRecord::Base.connection)).to include(:gitlab_ci, :gitlab_shared)
end
end
+
+ context "when there's CI connection", :request_store do
+ before do
+ skip_if_multiple_databases_not_setup
+
+ # FF due to lib/gitlab/database/load_balancing/configuration.rb:92
+ # Requires usage of `:request_store`
+ stub_feature_flags(force_no_sharing_primary_model: true)
+ end
+
+ context 'when CI uses database_tasks: false does indicate that ci: is subset of main:' do
+ before do
+ allow(Ci::ApplicationRecord.connection_db_config).to receive(:database_tasks?).and_return(false)
+ end
+
+ it 'does return gitlab_ci when accessing via main: connection' do
+ expect(described_class.gitlab_schemas_for_connection(Project.connection)).to include(:gitlab_ci, :gitlab_main, :gitlab_shared)
+ end
+
+ it 'does not return gitlab_main when accessing via ci: connection' do
+ expect(described_class.gitlab_schemas_for_connection(Ci::Build.connection)).to include(:gitlab_ci, :gitlab_shared)
+ expect(described_class.gitlab_schemas_for_connection(Ci::Build.connection)).not_to include(:gitlab_main)
+ end
+ end
+
+ context 'when CI uses database_tasks: true does indicate that ci: has own database' do
+ before do
+ allow(Ci::ApplicationRecord.connection_db_config).to receive(:database_tasks?).and_return(true)
+ end
+
+ it 'does not return gitlab_ci when accessing via main: connection' do
+ expect(described_class.gitlab_schemas_for_connection(Project.connection)).to include(:gitlab_main, :gitlab_shared)
+ expect(described_class.gitlab_schemas_for_connection(Project.connection)).not_to include(:gitlab_ci)
+ end
+
+ it 'does not return gitlab_main when accessing via ci: connection' do
+ expect(described_class.gitlab_schemas_for_connection(Ci::Build.connection)).to include(:gitlab_ci, :gitlab_shared)
+ expect(described_class.gitlab_schemas_for_connection(Ci::Build.connection)).not_to include(:gitlab_main)
+ end
+ end
+ end
end
describe '#true_value' do
diff --git a/spec/lib/gitlab/diff/custom_diff_spec.rb b/spec/lib/gitlab/diff/custom_diff_spec.rb
index 246508d2e1e..77d2a6cbcd6 100644
--- a/spec/lib/gitlab/diff/custom_diff_spec.rb
+++ b/spec/lib/gitlab/diff/custom_diff_spec.rb
@@ -34,6 +34,59 @@ RSpec.describe Gitlab::Diff::CustomDiff do
expect(described_class.transformed_for_diff?(blob)).to be_falsey
end
end
+
+ context 'timeout' do
+ subject { described_class.preprocess_before_diff(ipynb_blob.path, nil, ipynb_blob) }
+
+ it 'falls back to nil on timeout' do
+ allow(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception)
+ expect(Timeout).to receive(:timeout).and_raise(Timeout::Error)
+
+ expect(subject).to be_nil
+ end
+
+ context 'when in foreground' do
+ it 'utilizes timeout for web' do
+ expect(Timeout).to receive(:timeout).with(described_class::RENDERED_TIMEOUT_FOREGROUND).and_call_original
+
+ expect(subject).not_to include('cells')
+ end
+
+ it 'increments metrics' do
+ counter = Gitlab::Metrics.counter(:ipynb_semantic_diff_timeouts_total, 'desc')
+
+ expect(Timeout).to receive(:timeout).and_raise(Timeout::Error)
+ expect { subject }.to change { counter.get(source: described_class::FOREGROUND_EXECUTION) }.by(1)
+ end
+ end
+
+ context 'when in background' do
+ before do
+ allow(Gitlab::Runtime).to receive(:sidekiq?).and_return(true)
+ end
+
+ it 'utilizes longer timeout for sidekiq' do
+ expect(Timeout).to receive(:timeout).with(described_class::RENDERED_TIMEOUT_BACKGROUND).and_call_original
+
+ expect(subject).not_to include('cells')
+ end
+
+ it 'increments metrics' do
+ counter = Gitlab::Metrics.counter(:ipynb_semantic_diff_timeouts_total, 'desc')
+
+ expect(Timeout).to receive(:timeout).and_raise(Timeout::Error)
+ expect { subject }.to change { counter.get(source: described_class::BACKGROUND_EXECUTION) }.by(1)
+ end
+ end
+ end
+
+ context 'when invalid ipynb' do
+ it 'returns nil' do
+ expect(ipynb_blob).to receive(:data).and_return('invalid ipynb')
+
+ expect(described_class.preprocess_before_diff(ipynb_blob.path, nil, ipynb_blob)).to be_nil
+ end
+ end
end
describe '#transformed_blob_data' do
diff --git a/spec/lib/gitlab/diff/file_spec.rb b/spec/lib/gitlab/diff/file_spec.rb
index f2212ec9b09..0d7a183bb11 100644
--- a/spec/lib/gitlab/diff/file_spec.rb
+++ b/spec/lib/gitlab/diff/file_spec.rb
@@ -51,6 +51,54 @@ RSpec.describe Gitlab::Diff::File do
project.commit(branch_name).diffs.diff_files.first
end
+ describe '#initialize' do
+ let(:commit) { project.commit("532c837") }
+
+ context 'when file is ipynb' do
+ let(:ipynb_semantic_diff) { false }
+ let(:rendered_diffs_viewer) { false }
+
+ before do
+ stub_feature_flags(ipynb_semantic_diff: ipynb_semantic_diff, rendered_diffs_viewer: rendered_diffs_viewer)
+ end
+
+ context 'when ipynb_semantic_diff is off, and rendered_viewer is off' do
+ it 'does not generate notebook diffs' do
+ expect(Gitlab::Diff::CustomDiff).not_to receive(:preprocess_before_diff)
+ expect(diff_file.rendered).to be_nil
+ end
+ end
+
+ context 'when ipynb_semantic_diff is off, and rendered_viewer is on' do
+ let(:rendered_diffs_viewer) { true }
+
+ it 'does not generate rendered diff' do
+ expect(Gitlab::Diff::CustomDiff).not_to receive(:preprocess_before_diff)
+ expect(diff_file.rendered).to be_nil
+ end
+ end
+
+ context 'when ipynb_semantic_diff is on, and rendered_viewer is off' do
+ let(:ipynb_semantic_diff) { true }
+
+ it 'transforms using custom diff CustomDiff' do
+ expect(Gitlab::Diff::CustomDiff).to receive(:preprocess_before_diff).and_call_original
+ expect(diff_file.rendered).to be_nil
+ end
+ end
+
+ context 'when ipynb_semantic_diff is on, and rendered_viewer is on' do
+ let(:ipynb_semantic_diff) { true }
+ let(:rendered_diffs_viewer) { true }
+
+ it 'transforms diff using NotebookDiffFile' do
+ expect(Gitlab::Diff::CustomDiff).not_to receive(:preprocess_before_diff)
+ expect(diff_file.rendered).not_to be_nil
+ end
+ end
+ end
+ end
+
describe '#has_renderable?' do
context 'file is ipynb' do
let(:commit) { project.commit("532c837") }
@@ -66,14 +114,58 @@ RSpec.describe Gitlab::Diff::File do
it 'does not have renderable viewer' do
expect(diff_file.has_renderable?).to be_falsey
end
+
+ it 'does not create a Notebook DiffFile' do
+ expect(diff_file.rendered).to be_nil
+
+ expect(::Gitlab::Diff::Rendered::Notebook::DiffFile).not_to receive(:new)
+ end
end
end
describe '#rendered' do
- let(:commit) { project.commit("532c837") }
+ context 'when not ipynb' do
+ it 'is nil' do
+ expect(diff_file.rendered).to be_nil
+ end
+ end
+
+ context 'when ipynb' do
+ let(:commit) { project.commit("532c837") }
+
+ it 'creates a NotebookDiffFile for rendering' do
+ expect(diff_file.rendered).to be_kind_of(Gitlab::Diff::Rendered::Notebook::DiffFile)
+ end
+
+ context 'when too large' do
+ it 'is nil' do
+ expect(diff).to receive(:too_large?).and_return(true)
+
+ expect(diff_file.rendered).to be_nil
+ end
+ end
+
+ context 'when not modified' do
+ it 'is nil' do
+ expect(diff_file).to receive(:modified_file?).and_return(false)
+
+ expect(diff_file.rendered).to be_nil
+ end
+ end
+
+ context 'when semantic ipynb is off' do
+ before do
+ stub_feature_flags(ipynb_semantic_diff: false)
+ end
+
+ it 'returns nil' do
+ expect(diff_file).not_to receive(:modified_file?)
+ expect(diff_file).not_to receive(:ipynb?)
+ expect(diff).not_to receive(:too_large?)
- it 'creates a NotebookDiffFile for rendering' do
- expect(diff_file.rendered).to be_kind_of(Gitlab::Diff::Rendered::Notebook::DiffFile)
+ expect(diff_file.rendered).to be_nil
+ end
+ end
end
end
diff --git a/spec/lib/gitlab/diff/rendered/notebook/diff_file_spec.rb b/spec/lib/gitlab/diff/rendered/notebook/diff_file_spec.rb
index 15edbc22460..89b284feee0 100644
--- a/spec/lib/gitlab/diff/rendered/notebook/diff_file_spec.rb
+++ b/spec/lib/gitlab/diff/rendered/notebook/diff_file_spec.rb
@@ -63,6 +63,28 @@ RSpec.describe Gitlab::Diff::Rendered::Notebook::DiffFile do
expect(nb_file.diff).to be_nil
end
end
+
+ context 'timeout' do
+ it 'utilizes timeout for web' do
+ expect(Timeout).to receive(:timeout).with(described_class::RENDERED_TIMEOUT_FOREGROUND).and_call_original
+
+ nb_file.diff
+ end
+
+ it 'falls back to nil on timeout' do
+ allow(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception)
+ expect(Timeout).to receive(:timeout).and_raise(Timeout::Error)
+
+ expect(nb_file.diff).to be_nil
+ end
+
+ it 'utilizes longer timeout for sidekiq' do
+ allow(Gitlab::Runtime).to receive(:sidekiq?).and_return(true)
+ expect(Timeout).to receive(:timeout).with(described_class::RENDERED_TIMEOUT_BACKGROUND).and_call_original
+
+ nb_file.diff
+ end
+ end
end
describe '#has_renderable?' do
diff --git a/spec/lib/gitlab/email/handler/service_desk_handler_spec.rb b/spec/lib/gitlab/email/handler/service_desk_handler_spec.rb
index 913e197708f..8d008986464 100644
--- a/spec/lib/gitlab/email/handler/service_desk_handler_spec.rb
+++ b/spec/lib/gitlab/email/handler/service_desk_handler_spec.rb
@@ -477,20 +477,6 @@ RSpec.describe Gitlab::Email::Handler::ServiceDeskHandler do
end
end
- context 'when there is a reply-to address and a from address' do
- let(:email_raw) { email_fixture('emails/service_desk_reply_to_and_from.eml') }
-
- it 'shows both from and reply-to addresses in the issue header' do
- setup_attachment
-
- expect { receiver.execute }.to change { Issue.count }.by(1)
-
- new_issue = Issue.last
-
- expect(new_issue.external_author).to eq('finn@adventuretime.ooo (reply to: marceline@adventuretime.ooo)')
- end
- end
-
context 'when service desk is not enabled for project' do
before do
allow(Gitlab::ServiceDesk).to receive(:enabled?).and_return(false)
diff --git a/spec/lib/gitlab/email/message/in_product_marketing/base_spec.rb b/spec/lib/gitlab/email/message/in_product_marketing/base_spec.rb
index 0521123f1ef..8bd873cf008 100644
--- a/spec/lib/gitlab/email/message/in_product_marketing/base_spec.rb
+++ b/spec/lib/gitlab/email/message/in_product_marketing/base_spec.rb
@@ -100,7 +100,6 @@ RSpec.describe Gitlab::Email::Message::InProductMarketing::Base do
:trial | true
:team | true
:experience | true
- :invite_team | false
end
with_them do
diff --git a/spec/lib/gitlab/email/message/in_product_marketing/invite_team_spec.rb b/spec/lib/gitlab/email/message/in_product_marketing/invite_team_spec.rb
deleted file mode 100644
index 8319560f594..00000000000
--- a/spec/lib/gitlab/email/message/in_product_marketing/invite_team_spec.rb
+++ /dev/null
@@ -1,39 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::Email::Message::InProductMarketing::InviteTeam do
- let_it_be(:group) { build(:group) }
- let_it_be(:user) { build(:user) }
-
- let(:series) { 0 }
-
- subject(:message) { described_class.new(group: group, user: user, series: series) }
-
- describe 'initialize' do
- context 'when series is valid' do
- it 'does not raise error' do
- expect { subject }.not_to raise_error(ArgumentError)
- end
- end
-
- context 'when series is invalid' do
- let(:series) { 1 }
-
- it 'raises error' do
- expect { subject }.to raise_error(ArgumentError)
- end
- end
- end
-
- it 'contains the correct message', :aggregate_failures do
- expect(message.subject_line).to eq 'Invite your teammates to GitLab'
- expect(message.tagline).to be_empty
- expect(message.title).to eq 'GitLab is better with teammates to help out!'
- expect(message.subtitle).to be_empty
- expect(message.body_line1).to eq 'Invite your teammates today and build better code together. You can even assign tasks to new teammates such as setting up CI/CD, to help get projects up and running.'
- expect(message.body_line2).to be_empty
- expect(message.cta_text).to eq 'Invite your teammates to help'
- expect(message.logo_path).to eq 'mailers/in_product_marketing/team-0.png'
- end
-end
diff --git a/spec/lib/gitlab/email/message/in_product_marketing_spec.rb b/spec/lib/gitlab/email/message/in_product_marketing_spec.rb
index 594df7440bb..40351bef8b9 100644
--- a/spec/lib/gitlab/email/message/in_product_marketing_spec.rb
+++ b/spec/lib/gitlab/email/message/in_product_marketing_spec.rb
@@ -18,7 +18,6 @@ RSpec.describe Gitlab::Email::Message::InProductMarketing do
:trial | described_class::Trial
:team | described_class::Team
:experience | described_class::Experience
- :invite_team | described_class::InviteTeam
end
with_them do
diff --git a/spec/lib/gitlab/encoding_helper_spec.rb b/spec/lib/gitlab/encoding_helper_spec.rb
index 98170ef437c..4c1fbb93c13 100644
--- a/spec/lib/gitlab/encoding_helper_spec.rb
+++ b/spec/lib/gitlab/encoding_helper_spec.rb
@@ -265,4 +265,14 @@ RSpec.describe Gitlab::EncodingHelper do
end
end
end
+
+ describe '#unquote_path' do
+ it do
+ expect(described_class.unquote_path('unquoted')).to eq('unquoted')
+ expect(described_class.unquote_path('"quoted"')).to eq('quoted')
+ expect(described_class.unquote_path('"\\311\\240\\304\\253\\305\\247\\305\\200\\310\\247\\306\\200"')).to eq('ɠīŧŀȧƀ')
+ expect(described_class.unquote_path('"\\\\303\\\\251"')).to eq('\303\251')
+ expect(described_class.unquote_path('"\a\b\e\f\n\r\t\v\""')).to eq("\a\b\e\f\n\r\t\v\"")
+ end
+ end
end
diff --git a/spec/lib/gitlab/gfm/uploads_rewriter_spec.rb b/spec/lib/gitlab/gfm/uploads_rewriter_spec.rb
index 5b78acc3b1d..f878f02f410 100644
--- a/spec/lib/gitlab/gfm/uploads_rewriter_spec.rb
+++ b/spec/lib/gitlab/gfm/uploads_rewriter_spec.rb
@@ -67,7 +67,7 @@ RSpec.describe Gitlab::Gfm::UploadsRewriter do
it 'does not rewrite plain links as embedded' do
embedded_link = image_uploader.markdown_link
- plain_image_link = embedded_link.sub(/\A!/, "")
+ plain_image_link = embedded_link.delete_prefix('!')
text = "#{plain_image_link} and #{embedded_link}"
moved_text = described_class.new(text, old_project, user).rewrite(new_project)
diff --git a/spec/lib/gitlab/git/blame_spec.rb b/spec/lib/gitlab/git/blame_spec.rb
index 495cb16ebab..7dd7460b142 100644
--- a/spec/lib/gitlab/git/blame_spec.rb
+++ b/spec/lib/gitlab/git/blame_spec.rb
@@ -4,71 +4,81 @@ require "spec_helper"
RSpec.describe Gitlab::Git::Blame, :seed_helper do
let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '', 'group/project') }
- let(:blame) do
- Gitlab::Git::Blame.new(repository, SeedRepo::Commit::ID, "CONTRIBUTING.md")
+
+ let(:sha) { SeedRepo::Commit::ID }
+ let(:path) { 'CONTRIBUTING.md' }
+ let(:range) { nil }
+
+ subject(:blame) { Gitlab::Git::Blame.new(repository, sha, path, range: range) }
+
+ let(:result) do
+ [].tap do |data|
+ blame.each do |commit, line, previous_path|
+ data << { commit: commit, line: line, previous_path: previous_path }
+ end
+ end
end
describe 'blaming a file' do
- context "each count" do
- it do
- data = []
- blame.each do |commit, line|
- data << {
- commit: commit,
- line: line
- }
- end
+ it 'has the right number of lines' do
+ expect(result.size).to eq(95)
+ expect(result.first[:commit]).to be_kind_of(Gitlab::Git::Commit)
+ expect(result.first[:line]).to eq("# Contribute to GitLab")
+ expect(result.first[:line]).to be_utf8
+ end
- expect(data.size).to eq(95)
- expect(data.first[:commit]).to be_kind_of(Gitlab::Git::Commit)
- expect(data.first[:line]).to eq("# Contribute to GitLab")
- expect(data.first[:line]).to be_utf8
+ context 'blaming a range' do
+ let(:range) { 2..4 }
+
+ it 'only returns the range' do
+ expect(result.size).to eq(range.size)
+ expect(result.map {|r| r[:line] }).to eq(['', 'This guide details how contribute to GitLab.', ''])
end
end
context "ISO-8859 encoding" do
- let(:blame) do
- Gitlab::Git::Blame.new(repository, SeedRepo::EncodingCommit::ID, "encoding/iso8859.txt")
- end
+ let(:sha) { SeedRepo::EncodingCommit::ID }
+ let(:path) { 'encoding/iso8859.txt' }
it 'converts to UTF-8' do
- data = []
- blame.each do |commit, line|
- data << {
- commit: commit,
- line: line
- }
- end
-
- expect(data.size).to eq(1)
- expect(data.first[:commit]).to be_kind_of(Gitlab::Git::Commit)
- expect(data.first[:line]).to eq("Ä ü")
- expect(data.first[:line]).to be_utf8
+ expect(result.size).to eq(1)
+ expect(result.first[:commit]).to be_kind_of(Gitlab::Git::Commit)
+ expect(result.first[:line]).to eq("Ä ü")
+ expect(result.first[:line]).to be_utf8
end
end
context "unknown encoding" do
- let(:blame) do
- Gitlab::Git::Blame.new(repository, SeedRepo::EncodingCommit::ID, "encoding/iso8859.txt")
- end
+ let(:sha) { SeedRepo::EncodingCommit::ID }
+ let(:path) { 'encoding/iso8859.txt' }
it 'converts to UTF-8' do
expect_next_instance_of(CharlockHolmes::EncodingDetector) do |detector|
expect(detector).to receive(:detect).and_return(nil)
end
- data = []
- blame.each do |commit, line|
- data << {
- commit: commit,
- line: line
- }
- end
+ expect(result.size).to eq(1)
+ expect(result.first[:commit]).to be_kind_of(Gitlab::Git::Commit)
+ expect(result.first[:line]).to eq(" ")
+ expect(result.first[:line]).to be_utf8
+ end
+ end
+
+ context "renamed file" do
+ let(:project) { create(:project, :repository) }
+ let(:repository) { project.repository.raw_repository }
+ let(:commit) { project.commit('blame-on-renamed') }
+ let(:sha) { commit.id }
+ let(:path) { 'files/plain_text/renamed' }
+
+ it 'includes the previous path' do
+ expect(result.size).to eq(5)
- expect(data.size).to eq(1)
- expect(data.first[:commit]).to be_kind_of(Gitlab::Git::Commit)
- expect(data.first[:line]).to eq(" ")
- expect(data.first[:line]).to be_utf8
+ expect(result[0]).to include(line: 'Initial commit', previous_path: nil)
+ expect(result[1]).to include(line: 'Initial commit', previous_path: nil)
+ expect(result[2]).to include(line: 'Renamed as "filename"', previous_path: 'files/plain_text/initial-commit')
+ expect(result[3]).to include(line: 'Renamed as renamed', previous_path: 'files/plain_text/"filename"')
+ expect(result[4]).to include(line: 'Last edit, no rename', previous_path: path)
end
end
end
diff --git a/spec/lib/gitlab/git/diff_spec.rb b/spec/lib/gitlab/git/diff_spec.rb
index 17bb83d0f2f..46f544797bb 100644
--- a/spec/lib/gitlab/git/diff_spec.rb
+++ b/spec/lib/gitlab/git/diff_spec.rb
@@ -161,6 +161,52 @@ EOT
expect(diff).not_to have_binary_notice
end
end
+
+ context 'when diff contains invalid characters' do
+ let(:bad_string) { [0xae].pack("C*") }
+ let(:bad_string_two) { [0x89].pack("C*") }
+
+ let(:diff) { described_class.new(@raw_diff_hash.merge({ diff: bad_string })) }
+ let(:diff_two) { described_class.new(@raw_diff_hash.merge({ diff: bad_string_two })) }
+
+ context 'when replace_invalid_utf8_chars is true' do
+ it 'will convert invalid characters and not cause an encoding error' do
+ expect(diff.diff).to include(Gitlab::EncodingHelper::UNICODE_REPLACEMENT_CHARACTER)
+ expect(diff_two.diff).to include(Gitlab::EncodingHelper::UNICODE_REPLACEMENT_CHARACTER)
+
+ expect { Oj.dump(diff) }.not_to raise_error(EncodingError)
+ expect { Oj.dump(diff_two) }.not_to raise_error(EncodingError)
+ end
+
+ context 'when the diff is binary' do
+ let(:project) { create(:project, :repository) }
+
+ it 'will not try to replace characters' do
+ expect(Gitlab::EncodingHelper).not_to receive(:encode_utf8_with_replacement_character?)
+ expect(binary_diff(project).diff).not_to be_empty
+ end
+ end
+
+ context 'when convert_diff_to_utf8_with_replacement_symbol feature flag is disabled' do
+ before do
+ stub_feature_flags(convert_diff_to_utf8_with_replacement_symbol: false)
+ end
+
+ it 'will not try to convert invalid characters' do
+ expect(Gitlab::EncodingHelper).not_to receive(:encode_utf8_with_replacement_character?)
+ end
+ end
+ end
+
+ context 'when replace_invalid_utf8_chars is false' do
+ let(:not_replaced_diff) { described_class.new(@raw_diff_hash.merge({ diff: bad_string, replace_invalid_utf8_chars: false }) ) }
+ let(:not_replaced_diff_two) { described_class.new(@raw_diff_hash.merge({ diff: bad_string_two, replace_invalid_utf8_chars: false }) ) }
+
+ it 'will not try to convert invalid characters' do
+ expect(Gitlab::EncodingHelper).not_to receive(:encode_utf8_with_replacement_character?)
+ end
+ end
+ end
end
describe 'straight diffs' do
@@ -255,12 +301,11 @@ EOT
let(:project) { create(:project, :repository) }
it 'fake binary message when it detects binary' do
- # Rugged will not detect this as binary, but we can fake it
diff_message = "Binary files files/images/icn-time-tracking.pdf and files/images/icn-time-tracking.pdf differ\n"
- binary_diff = described_class.between(project.repository, 'add-pdf-text-binary', 'add-pdf-text-binary^').first
- expect(binary_diff.diff).not_to be_empty
- expect(binary_diff.json_safe_diff).to eq(diff_message)
+ diff = binary_diff(project)
+ expect(diff.diff).not_to be_empty
+ expect(diff.json_safe_diff).to eq(diff_message)
end
it 'leave non-binary diffs as-is' do
@@ -374,4 +419,9 @@ EOT
expect(diff.line_count).to eq(0)
end
end
+
+ def binary_diff(project)
+ # rugged will not detect this as binary, but we can fake it
+ described_class.between(project.repository, 'add-pdf-text-binary', 'add-pdf-text-binary^').first
+ end
end
diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb
index ae6ca728573..47688c4b3e6 100644
--- a/spec/lib/gitlab/git/repository_spec.rb
+++ b/spec/lib/gitlab/git/repository_spec.rb
@@ -2448,7 +2448,7 @@ RSpec.describe Gitlab::Git::Repository, :seed_helper do
it 'delegates to Gitaly' do
expect_next_instance_of(Gitlab::GitalyClient::RepositoryService) do |svc|
- expect(svc).to receive(:import_repository).with(url).and_return(nil)
+ expect(svc).to receive(:import_repository).with(url, http_authorization_header: '', mirror: false).and_return(nil)
end
repository.import_repository(url)
diff --git a/spec/lib/gitlab/gitaly_client/commit_service_spec.rb b/spec/lib/gitlab/gitaly_client/commit_service_spec.rb
index 8d9ab5db886..50a0f20e775 100644
--- a/spec/lib/gitlab/gitaly_client/commit_service_spec.rb
+++ b/spec/lib/gitlab/gitaly_client/commit_service_spec.rb
@@ -563,4 +563,39 @@ RSpec.describe Gitlab::GitalyClient::CommitService do
expect(response).not_to have_key 'nonexistent'
end
end
+
+ describe '#raw_blame' do
+ let(:project) { create(:project, :test_repo) }
+ let(:revision) { 'blame-on-renamed' }
+ let(:path) { 'files/plain_text/renamed' }
+
+ let(:blame_headers) do
+ [
+ '405a45736a75e439bb059e638afaa9a3c2eeda79 1 1 2',
+ '405a45736a75e439bb059e638afaa9a3c2eeda79 2 2',
+ 'bed1d1610ebab382830ee888288bf939c43873bb 3 3 1',
+ '3685515c40444faf92774e72835e1f9c0e809672 4 4 1',
+ '32c33da59f8a1a9f90bdeda570337888b00b244d 5 5 1'
+ ]
+ end
+
+ subject(:blame) { client.raw_blame(revision, path, range: range).split("\n") }
+
+ context 'without a range' do
+ let(:range) { nil }
+
+ it 'blames a whole file' do
+ is_expected.to include(*blame_headers)
+ end
+ end
+
+ context 'with a range' do
+ let(:range) { '3,4' }
+
+ it 'blames part of a file' do
+ is_expected.to include(blame_headers[2], blame_headers[3])
+ is_expected.not_to include(blame_headers[0], blame_headers[1], blame_headers[4])
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/github_import/importer/diff_notes_importer_spec.rb b/spec/lib/gitlab/github_import/importer/diff_notes_importer_spec.rb
index 1c7b35ed928..6eb92cdeab9 100644
--- a/spec/lib/gitlab/github_import/importer/diff_notes_importer_spec.rb
+++ b/spec/lib/gitlab/github_import/importer/diff_notes_importer_spec.rb
@@ -98,9 +98,9 @@ RSpec.describe Gitlab::GithubImport::Importer::DiffNotesImporter do
.to receive(:each_object_to_import)
.and_yield(github_comment)
- expect(Gitlab::GithubImport::ImportDiffNoteWorker)
- .to receive(:perform_async)
- .with(project.id, an_instance_of(Hash), an_instance_of(String))
+ expect(Gitlab::GithubImport::ImportDiffNoteWorker).to receive(:bulk_perform_in).with(1.second, [
+ [project.id, an_instance_of(Hash), an_instance_of(String)]
+ ], batch_size: 1000, batch_delay: 1.minute)
waiter = importer.parallel_import
diff --git a/spec/lib/gitlab/github_import/importer/issues_importer_spec.rb b/spec/lib/gitlab/github_import/importer/issues_importer_spec.rb
index 2c2b6a2aff0..6b807bdf098 100644
--- a/spec/lib/gitlab/github_import/importer/issues_importer_spec.rb
+++ b/spec/lib/gitlab/github_import/importer/issues_importer_spec.rb
@@ -91,9 +91,9 @@ RSpec.describe Gitlab::GithubImport::Importer::IssuesImporter do
.to receive(:each_object_to_import)
.and_yield(github_issue)
- expect(Gitlab::GithubImport::ImportIssueWorker)
- .to receive(:perform_async)
- .with(project.id, an_instance_of(Hash), an_instance_of(String))
+ expect(Gitlab::GithubImport::ImportIssueWorker).to receive(:bulk_perform_in).with(1.second, [
+ [project.id, an_instance_of(Hash), an_instance_of(String)]
+ ], batch_size: 1000, batch_delay: 1.minute)
waiter = importer.parallel_import
diff --git a/spec/lib/gitlab/github_import/importer/lfs_objects_importer_spec.rb b/spec/lib/gitlab/github_import/importer/lfs_objects_importer_spec.rb
index a2c7d51214a..6dfd4424342 100644
--- a/spec/lib/gitlab/github_import/importer/lfs_objects_importer_spec.rb
+++ b/spec/lib/gitlab/github_import/importer/lfs_objects_importer_spec.rb
@@ -118,9 +118,9 @@ RSpec.describe Gitlab::GithubImport::Importer::LfsObjectsImporter do
expect(service).to receive(:execute).and_return([lfs_download_object])
end
- expect(Gitlab::GithubImport::ImportLfsObjectWorker)
- .to receive(:perform_async)
- .with(project.id, an_instance_of(Hash), an_instance_of(String))
+ expect(Gitlab::GithubImport::ImportLfsObjectWorker).to receive(:bulk_perform_in).with(1.second, [
+ [project.id, an_instance_of(Hash), an_instance_of(String)]
+ ], batch_size: 1000, batch_delay: 1.minute)
waiter = importer.parallel_import
diff --git a/spec/lib/gitlab/github_import/importer/notes_importer_spec.rb b/spec/lib/gitlab/github_import/importer/notes_importer_spec.rb
index 3782dab5ee3..3b4fe652da8 100644
--- a/spec/lib/gitlab/github_import/importer/notes_importer_spec.rb
+++ b/spec/lib/gitlab/github_import/importer/notes_importer_spec.rb
@@ -84,9 +84,9 @@ RSpec.describe Gitlab::GithubImport::Importer::NotesImporter do
.to receive(:each_object_to_import)
.and_yield(github_comment)
- expect(Gitlab::GithubImport::ImportNoteWorker)
- .to receive(:perform_async)
- .with(project.id, an_instance_of(Hash), an_instance_of(String))
+ expect(Gitlab::GithubImport::ImportNoteWorker).to receive(:bulk_perform_in).with(1.second, [
+ [project.id, an_instance_of(Hash), an_instance_of(String)]
+ ], batch_size: 1000, batch_delay: 1.minute)
waiter = importer.parallel_import
diff --git a/spec/lib/gitlab/github_import/object_counter_spec.rb b/spec/lib/gitlab/github_import/object_counter_spec.rb
index c9e4ac67061..e522f74416c 100644
--- a/spec/lib/gitlab/github_import/object_counter_spec.rb
+++ b/spec/lib/gitlab/github_import/object_counter_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe Gitlab::GithubImport::ObjectCounter, :clean_gitlab_redis_cache do
- let_it_be(:project) { create(:project) }
+ let_it_be(:project) { create(:project, :import_started, import_type: 'github') }
it 'validates the operation being incremented' do
expect { described_class.increment(project, :issue, :unknown) }
@@ -49,4 +49,12 @@ RSpec.describe Gitlab::GithubImport::ObjectCounter, :clean_gitlab_redis_cache do
'imported' => {}
})
end
+
+ it 'expires etag cache of relevant realtime change endpoints on increment' do
+ expect_next_instance_of(Gitlab::EtagCaching::Store) do |instance|
+ expect(instance).to receive(:touch).with(Gitlab::Routing.url_helpers.realtime_changes_import_github_path(format: :json))
+ end
+
+ described_class.increment(project, :issue, :fetched)
+ end
end
diff --git a/spec/lib/gitlab/github_import/parallel_scheduling_spec.rb b/spec/lib/gitlab/github_import/parallel_scheduling_spec.rb
index 6a19afbc60d..200898f8f03 100644
--- a/spec/lib/gitlab/github_import/parallel_scheduling_spec.rb
+++ b/spec/lib/gitlab/github_import/parallel_scheduling_spec.rb
@@ -22,10 +22,6 @@ RSpec.describe Gitlab::GithubImport::ParallelScheduling do
def collection_method
:issues
end
-
- def parallel_import_batch
- { size: 10, delay: 1.minute }
- end
end
end
@@ -261,7 +257,7 @@ RSpec.describe Gitlab::GithubImport::ParallelScheduling do
let(:repr_class) { double(:representation) }
let(:worker_class) { double(:worker) }
let(:object) { double(:object) }
- let(:batch_size) { 200 }
+ let(:batch_size) { 1000 }
let(:batch_delay) { 1.minute }
before do
@@ -281,7 +277,6 @@ RSpec.describe Gitlab::GithubImport::ParallelScheduling do
context 'with multiple objects' do
before do
- allow(importer).to receive(:parallel_import_batch) { { size: batch_size, delay: batch_delay } }
expect(importer).to receive(:each_object_to_import).and_yield(object).and_yield(object).and_yield(object)
end
@@ -296,9 +291,9 @@ RSpec.describe Gitlab::GithubImport::ParallelScheduling do
end
end
- context 'when FF is disabled' do
+ context 'when distribute_github_parallel_import feature flag is disabled' do
before do
- stub_feature_flags(spread_parallel_import: false)
+ stub_feature_flags(distribute_github_parallel_import: false)
end
it 'imports data in parallel' do
diff --git a/spec/lib/gitlab/gon_helper_spec.rb b/spec/lib/gitlab/gon_helper_spec.rb
index 047873d8237..28cb9125af1 100644
--- a/spec/lib/gitlab/gon_helper_spec.rb
+++ b/spec/lib/gitlab/gon_helper_spec.rb
@@ -64,6 +64,34 @@ RSpec.describe Gitlab::GonHelper do
end
end
+ describe '#push_force_frontend_feature_flag' do
+ let(:gon) { class_double('Gon') }
+
+ before do
+ skip_feature_flags_yaml_validation
+
+ allow(helper)
+ .to receive(:gon)
+ .and_return(gon)
+ end
+
+ it 'pushes a feature flag to the frontend with the provided value' do
+ expect(gon)
+ .to receive(:push)
+ .with({ features: { 'myFeatureFlag' => true } }, true)
+
+ helper.push_force_frontend_feature_flag(:my_feature_flag, true)
+ end
+
+ it 'pushes a disabled feature flag if provided value is nil' do
+ expect(gon)
+ .to receive(:push)
+ .with({ features: { 'myFeatureFlag' => false } }, true)
+
+ helper.push_force_frontend_feature_flag(:my_feature_flag, nil)
+ end
+ end
+
describe '#default_avatar_url' do
it 'returns an absolute URL' do
url = helper.default_avatar_url
diff --git a/spec/lib/gitlab/graphql/known_operations_spec.rb b/spec/lib/gitlab/graphql/known_operations_spec.rb
index 411c0876f82..3ebfefbb43c 100644
--- a/spec/lib/gitlab/graphql/known_operations_spec.rb
+++ b/spec/lib/gitlab/graphql/known_operations_spec.rb
@@ -19,7 +19,7 @@ RSpec.describe Gitlab::Graphql::KnownOperations do
describe "#from_query" do
where(:query_string, :expected) do
- "query { helloWorld }" | described_class::ANONYMOUS
+ "query { helloWorld }" | described_class::UNKNOWN
"query fuzzyyy { helloWorld }" | described_class::UNKNOWN
"query foo { helloWorld }" | described_class::Operation.new("foo")
end
@@ -35,13 +35,13 @@ RSpec.describe Gitlab::Graphql::KnownOperations do
describe "#operations" do
it "returns array of known operations" do
- expect(subject.operations.map(&:name)).to match_array(%w(anonymous unknown foo bar))
+ expect(subject.operations.map(&:name)).to match_array(%w(unknown foo bar))
end
end
describe "Operation#to_caller_id" do
where(:query_string, :expected) do
- "query { helloWorld }" | "graphql:#{described_class::ANONYMOUS.name}"
+ "query { helloWorld }" | "graphql:#{described_class::UNKNOWN.name}"
"query foo { helloWorld }" | "graphql:foo"
end
diff --git a/spec/lib/gitlab/graphql/pagination/active_record_array_connection_spec.rb b/spec/lib/gitlab/graphql/pagination/active_record_array_connection_spec.rb
new file mode 100644
index 00000000000..320c6b52308
--- /dev/null
+++ b/spec/lib/gitlab/graphql/pagination/active_record_array_connection_spec.rb
@@ -0,0 +1,135 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Graphql::Pagination::ActiveRecordArrayConnection do
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be(:items) { create_list(:package_build_info, 3) }
+
+ let_it_be(:context) do
+ GraphQL::Query::Context.new(
+ query: GraphQL::Query.new(GitlabSchema, document: nil, context: {}, variables: {}),
+ values: {},
+ object: nil
+ )
+ end
+
+ let(:first) { nil }
+ let(:last) { nil }
+ let(:after) { nil }
+ let(:before) { nil }
+ let(:max_page_size) { nil }
+
+ let(:connection) do
+ described_class.new(
+ items,
+ context: context,
+ first: first,
+ last: last,
+ after: after,
+ before: before,
+ max_page_size: max_page_size
+ )
+ end
+
+ it_behaves_like 'a connection with collection methods'
+
+ it_behaves_like 'a redactable connection' do
+ let(:unwanted) { items[1] }
+ end
+
+ describe '#nodes' do
+ subject { connection.nodes }
+
+ it { is_expected.to match_array(items) }
+
+ context 'with first set' do
+ let(:first) { 2 }
+
+ it { is_expected.to match_array([items[0], items[1]]) }
+ end
+
+ context 'with last set' do
+ let(:last) { 2 }
+
+ it { is_expected.to match_array([items[1], items[2]]) }
+ end
+ end
+
+ describe '#next_page?' do
+ subject { connection.next_page? }
+
+ where(:before, :first, :max_page_size, :result) do
+ nil | nil | nil | false
+ 1 | nil | nil | true
+ nil | 1 | nil | true
+ nil | 10 | nil | false
+ nil | 1 | 1 | true
+ nil | 1 | 10 | true
+ nil | 10 | 10 | false
+ end
+
+ with_them do
+ it { is_expected.to eq(result) }
+ end
+ end
+
+ describe '#previous_page?' do
+ subject { connection.previous_page? }
+
+ where(:after, :last, :max_page_size, :result) do
+ nil | nil | nil | false
+ 1 | nil | nil | true
+ nil | 1 | nil | true
+ nil | 10 | nil | false
+ nil | 1 | 1 | true
+ nil | 1 | 10 | true
+ nil | 10 | 10 | false
+ end
+
+ with_them do
+ it { is_expected.to eq(result) }
+ end
+ end
+
+ describe '#cursor_for' do
+ let(:item) { items[0] }
+ let(:expected_result) do
+ GitlabSchema.cursor_encoder.encode(
+ Gitlab::Json.dump(id: item.id.to_s),
+ nonce: true
+ )
+ end
+
+ subject { connection.cursor_for(item) }
+
+ it { is_expected.to eq(expected_result) }
+
+ context 'with a BatchLoader::GraphQL item' do
+ let_it_be(:user) { create(:user) }
+
+ let(:item) { ::Gitlab::Graphql::Loaders::BatchModelLoader.new(::User, user.id).find }
+ let(:expected_result) do
+ GitlabSchema.cursor_encoder.encode(
+ Gitlab::Json.dump(id: user.id.to_s),
+ nonce: true
+ )
+ end
+
+ it { is_expected.to eq(expected_result) }
+ end
+ end
+
+ describe '#dup' do
+ subject { connection.dup }
+
+ it 'properly handles items duplication' do
+ connection2 = subject
+
+ connection2 << create(:package_build_info)
+
+ expect(connection.items).not_to eq(connection2.items)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/graphql/pagination/keyset/connection_generic_keyset_spec.rb b/spec/lib/gitlab/graphql/pagination/keyset/connection_generic_keyset_spec.rb
index 0741088c915..86e7d4e344c 100644
--- a/spec/lib/gitlab/graphql/pagination/keyset/connection_generic_keyset_spec.rb
+++ b/spec/lib/gitlab/graphql/pagination/keyset/connection_generic_keyset_spec.rb
@@ -19,8 +19,8 @@ RSpec.describe Gitlab::Graphql::Pagination::Keyset::Connection do
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'last_repository_check_at',
column_expression: Project.arel_table[:last_repository_check_at],
- order_expression: Gitlab::Database.nulls_last_order('last_repository_check_at', :asc),
- reversed_order_expression: Gitlab::Database.nulls_last_order('last_repository_check_at', :desc),
+ order_expression: Project.arel_table[:last_repository_check_at].asc.nulls_last,
+ reversed_order_expression: Project.arel_table[:last_repository_check_at].desc.nulls_last,
order_direction: :asc,
nullable: :nulls_last,
distinct: false)
@@ -30,8 +30,8 @@ RSpec.describe Gitlab::Graphql::Pagination::Keyset::Connection do
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'last_repository_check_at',
column_expression: Project.arel_table[:last_repository_check_at],
- order_expression: Gitlab::Database.nulls_last_order('last_repository_check_at', :desc),
- reversed_order_expression: Gitlab::Database.nulls_last_order('last_repository_check_at', :asc),
+ order_expression: Project.arel_table[:last_repository_check_at].desc.nulls_last,
+ reversed_order_expression: Project.arel_table[:last_repository_check_at].asc.nulls_last,
order_direction: :desc,
nullable: :nulls_last,
distinct: false)
@@ -256,11 +256,6 @@ RSpec.describe Gitlab::Graphql::Pagination::Keyset::Connection do
end
end
- # rubocop: disable RSpec/EmptyExampleGroup
- context 'when ordering uses LOWER' do
- end
- # rubocop: enable RSpec/EmptyExampleGroup
-
context 'when ordering by similarity' do
let_it_be(:project1) { create(:project, name: 'test') }
let_it_be(:project2) { create(:project, name: 'testing') }
diff --git a/spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb b/spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb
index b511a294f97..f31ec6c09fd 100644
--- a/spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb
+++ b/spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb
@@ -77,6 +77,17 @@ RSpec.describe Gitlab::Graphql::Pagination::Keyset::Connection do
expect(decoded_cursor(cursor)).to eq('id' => project.id.to_s)
end
+ context 'when SimpleOrderBuilder cannot build keyset paginated query' do
+ it 'increments the `old_keyset_pagination_usage` counter', :prometheus do
+ expect(Gitlab::Pagination::Keyset::SimpleOrderBuilder).to receive(:build).and_return([false, nil])
+
+ decoded_cursor(cursor)
+
+ counter = Gitlab::Metrics.registry.get(:old_keyset_pagination_usage)
+ expect(counter.get(model: 'Project')).to eq(1)
+ end
+ end
+
context 'when an order is specified' do
let(:nodes) { Project.order(:updated_at) }
@@ -222,91 +233,97 @@ RSpec.describe Gitlab::Graphql::Pagination::Keyset::Connection do
end
end
- context 'when multiple orders with nil values are defined' do
- let!(:project1) { create(:project, last_repository_check_at: 10.days.ago) } # Asc: project5 Desc: project3
- let!(:project2) { create(:project, last_repository_check_at: nil) } # Asc: project1 Desc: project1
- let!(:project3) { create(:project, last_repository_check_at: 5.days.ago) } # Asc: project3 Desc: project5
- let!(:project4) { create(:project, last_repository_check_at: nil) } # Asc: project2 Desc: project2
- let!(:project5) { create(:project, last_repository_check_at: 20.days.ago) } # Asc: project4 Desc: project4
+ context 'when ordering uses LOWER' do
+ let!(:project1) { create(:project, name: 'A') } # Asc: project1 Desc: project4
+ let!(:project2) { create(:project, name: 'c') } # Asc: project5 Desc: project2
+ let!(:project3) { create(:project, name: 'b') } # Asc: project3 Desc: project3
+ let!(:project4) { create(:project, name: 'd') } # Asc: project2 Desc: project5
+ let!(:project5) { create(:project, name: 'a') } # Asc: project4 Desc: project1
context 'when ascending' do
let(:nodes) do
- Project.order(Arel.sql('projects.last_repository_check_at IS NULL')).order(last_repository_check_at: :asc).order(id: :asc)
+ Project.order(Arel::Table.new(:projects)['name'].lower.asc).order(id: :asc)
end
- let(:ascending_nodes) { [project5, project1, project3, project2, project4] }
+ let(:ascending_nodes) { [project1, project5, project3, project2, project4] }
it_behaves_like 'nodes are in ascending order'
-
- context 'when before cursor value is NULL' do
- let(:arguments) { { before: encoded_cursor(project4) } }
-
- it 'returns all projects before the cursor' do
- expect(subject.sliced_nodes).to eq([project5, project1, project3, project2])
- end
- end
-
- context 'when after cursor value is NULL' do
- let(:arguments) { { after: encoded_cursor(project2) } }
-
- it 'returns all projects after the cursor' do
- expect(subject.sliced_nodes).to eq([project4])
- end
- end
end
context 'when descending' do
let(:nodes) do
- Project.order(Arel.sql('projects.last_repository_check_at IS NULL')).order(last_repository_check_at: :desc).order(id: :asc)
+ Project.order(Arel::Table.new(:projects)['name'].lower.desc).order(id: :desc)
end
- let(:descending_nodes) { [project3, project1, project5, project2, project4] }
+ let(:descending_nodes) { [project4, project2, project3, project5, project1] }
it_behaves_like 'nodes are in descending order'
+ end
+ end
- context 'when before cursor value is NULL' do
- let(:arguments) { { before: encoded_cursor(project4) } }
+ context 'NULLS order' do
+ using RSpec::Parameterized::TableSyntax
- it 'returns all projects before the cursor' do
- expect(subject.sliced_nodes).to eq([project3, project1, project5, project2])
- end
- end
+ let_it_be(:issue1) { create(:issue, relative_position: nil) }
+ let_it_be(:issue2) { create(:issue, relative_position: 100) }
+ let_it_be(:issue3) { create(:issue, relative_position: 200) }
+ let_it_be(:issue4) { create(:issue, relative_position: nil) }
+ let_it_be(:issue5) { create(:issue, relative_position: 300) }
+
+ context 'when ascending NULLS LAST (ties broken by id DESC implicitly)' do
+ let(:ascending_nodes) { [issue2, issue3, issue5, issue4, issue1] }
- context 'when after cursor value is NULL' do
- let(:arguments) { { after: encoded_cursor(project2) } }
+ where(:nodes) do
+ [
+ lazy { Issue.order(Issue.arel_table[:relative_position].asc.nulls_last) }
+ ]
+ end
- it 'returns all projects after the cursor' do
- expect(subject.sliced_nodes).to eq([project4])
- end
+ with_them do
+ it_behaves_like 'nodes are in ascending order'
end
end
- end
- context 'when ordering uses LOWER' do
- let!(:project1) { create(:project, name: 'A') } # Asc: project1 Desc: project4
- let!(:project2) { create(:project, name: 'c') } # Asc: project5 Desc: project2
- let!(:project3) { create(:project, name: 'b') } # Asc: project3 Desc: project3
- let!(:project4) { create(:project, name: 'd') } # Asc: project2 Desc: project5
- let!(:project5) { create(:project, name: 'a') } # Asc: project4 Desc: project1
+ context 'when descending NULLS LAST (ties broken by id DESC implicitly)' do
+ let(:descending_nodes) { [issue5, issue3, issue2, issue4, issue1] }
- context 'when ascending' do
- let(:nodes) do
- Project.order(Arel::Table.new(:projects)['name'].lower.asc).order(id: :asc)
+ where(:nodes) do
+ [
+ lazy { Issue.order(Issue.arel_table[:relative_position].desc.nulls_last) }
+]
end
- let(:ascending_nodes) { [project1, project5, project3, project2, project4] }
-
- it_behaves_like 'nodes are in ascending order'
+ with_them do
+ it_behaves_like 'nodes are in descending order'
+ end
end
- context 'when descending' do
- let(:nodes) do
- Project.order(Arel::Table.new(:projects)['name'].lower.desc).order(id: :desc)
+ context 'when ascending NULLS FIRST with a tie breaker' do
+ let(:ascending_nodes) { [issue1, issue4, issue2, issue3, issue5] }
+
+ where(:nodes) do
+ [
+ lazy { Issue.order(Issue.arel_table[:relative_position].asc.nulls_first).order(id: :asc) }
+]
end
- let(:descending_nodes) { [project4, project2, project3, project5, project1] }
+ with_them do
+ it_behaves_like 'nodes are in ascending order'
+ end
+ end
- it_behaves_like 'nodes are in descending order'
+ context 'when descending NULLS FIRST with a tie breaker' do
+ let(:descending_nodes) { [issue1, issue4, issue5, issue3, issue2] }
+
+ where(:nodes) do
+ [
+ lazy { Issue.order(Issue.arel_table[:relative_position].desc.nulls_first).order(id: :asc) }
+]
+ end
+
+ with_them do
+ it_behaves_like 'nodes are in descending order'
+ end
end
end
diff --git a/spec/lib/gitlab/hook_data/issuable_builder_spec.rb b/spec/lib/gitlab/hook_data/issuable_builder_spec.rb
index f5ee8eba8bc..676396697fb 100644
--- a/spec/lib/gitlab/hook_data/issuable_builder_spec.rb
+++ b/spec/lib/gitlab/hook_data/issuable_builder_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe Gitlab::HookData::IssuableBuilder do
let_it_be(:user) { create(:user) }
# This shared example requires a `builder` and `user` variable
- shared_examples 'issuable hook data' do |kind|
+ shared_examples 'issuable hook data' do |kind, hook_data_issuable_builder_class|
let(:data) { builder.build(user: user) }
include_examples 'project hook data' do
@@ -20,7 +20,7 @@ RSpec.describe Gitlab::HookData::IssuableBuilder do
expect(data[:object_kind]).to eq(kind)
expect(data[:user]).to eq(user.hook_attrs)
expect(data[:project]).to eq(builder.issuable.project.hook_attrs)
- expect(data[:object_attributes]).to eq(builder.issuable.hook_attrs)
+ expect(data[:object_attributes]).to eq(hook_data_issuable_builder_class.new(issuable).build)
expect(data[:changes]).to eq({})
expect(data[:repository]).to eq(builder.issuable.project.hook_attrs.slice(:name, :url, :description, :homepage))
end
@@ -95,12 +95,12 @@ RSpec.describe Gitlab::HookData::IssuableBuilder do
end
describe '#build' do
- it_behaves_like 'issuable hook data', 'issue' do
+ it_behaves_like 'issuable hook data', 'issue', Gitlab::HookData::IssueBuilder do
let(:issuable) { create(:issue, description: 'A description') }
let(:builder) { described_class.new(issuable) }
end
- it_behaves_like 'issuable hook data', 'merge_request' do
+ it_behaves_like 'issuable hook data', 'merge_request', Gitlab::HookData::MergeRequestBuilder do
let(:issuable) { create(:merge_request, description: 'A description') }
let(:builder) { described_class.new(issuable) }
end
diff --git a/spec/lib/gitlab/hook_data/merge_request_builder_spec.rb b/spec/lib/gitlab/hook_data/merge_request_builder_spec.rb
index ddd681f75f0..771fc0218e2 100644
--- a/spec/lib/gitlab/hook_data/merge_request_builder_spec.rb
+++ b/spec/lib/gitlab/hook_data/merge_request_builder_spec.rb
@@ -62,6 +62,7 @@ RSpec.describe Gitlab::HookData::MergeRequestBuilder do
expect(data).to include(:human_time_estimate)
expect(data).to include(:human_total_time_spent)
expect(data).to include(:human_time_change)
+ expect(data).to include(:labels)
end
context 'when the MR has an image in the description' do
diff --git a/spec/lib/gitlab/http_connection_adapter_spec.rb b/spec/lib/gitlab/http_connection_adapter_spec.rb
index e9e517f1fe6..cde8376febd 100644
--- a/spec/lib/gitlab/http_connection_adapter_spec.rb
+++ b/spec/lib/gitlab/http_connection_adapter_spec.rb
@@ -27,16 +27,6 @@ RSpec.describe Gitlab::HTTPConnectionAdapter do
end
end
- context 'with header_read_timeout_buffered_io feature disabled' do
- before do
- stub_feature_flags(header_read_timeout_buffered_io: false)
- end
-
- it 'uses the regular Net::HTTP class' do
- expect(connection).to be_a(Net::HTTP)
- end
- end
-
context 'when local requests are allowed' do
let(:options) { { allow_local_requests: true } }
diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml
index 29a19e4cafd..730f9035293 100644
--- a/spec/lib/gitlab/import_export/all_models.yml
+++ b/spec/lib/gitlab/import_export/all_models.yml
@@ -665,6 +665,7 @@ protected_environments:
- project
- group
- deploy_access_levels
+- approval_rules
deploy_access_levels:
- protected_environment
- user
diff --git a/spec/lib/gitlab/import_export/command_line_util_spec.rb b/spec/lib/gitlab/import_export/command_line_util_spec.rb
index 59d97357045..f47f1ab58a8 100644
--- a/spec/lib/gitlab/import_export/command_line_util_spec.rb
+++ b/spec/lib/gitlab/import_export/command_line_util_spec.rb
@@ -114,7 +114,7 @@ RSpec.describe Gitlab::ImportExport::CommandLineUtil do
end
end
- %w[MOVED_PERMANENTLY FOUND TEMPORARY_REDIRECT].each do |code|
+ %w[MOVED_PERMANENTLY FOUND SEE_OTHER TEMPORARY_REDIRECT].each do |code|
context "with a redirect status code #{code}" do
let(:status) { HTTP::Status.const_get(code, false) }
diff --git a/spec/lib/gitlab/import_export/duration_measuring_spec.rb b/spec/lib/gitlab/import_export/duration_measuring_spec.rb
new file mode 100644
index 00000000000..cf8b6060741
--- /dev/null
+++ b/spec/lib/gitlab/import_export/duration_measuring_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+
+RSpec.describe Gitlab::ImportExport::DurationMeasuring do
+ subject do
+ Class.new do
+ include Gitlab::ImportExport::DurationMeasuring
+
+ def test
+ with_duration_measuring do
+ 'test'
+ end
+ end
+ end.new
+ end
+
+ it 'measures method execution duration' do
+ subject.test
+
+ expect(subject.duration_s).not_to be_nil
+ end
+
+ describe '#with_duration_measuring' do
+ it 'yields control' do
+ expect { |block| subject.with_duration_measuring(&block) }.to yield_control
+ end
+
+ it 'returns result of the yielded block' do
+ return_value = 'return_value'
+
+ expect(subject.with_duration_measuring { return_value }).to eq(return_value)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb b/spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb
index ba1cccf87ce..03f522ae490 100644
--- a/spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb
+++ b/spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb
@@ -115,7 +115,7 @@ RSpec.describe Gitlab::ImportExport::Json::StreamingSerializer do
end
it 'orders exported issues by custom column(relative_position)' do
- expected_issues = exportable.issues.reorder(::Gitlab::Database.nulls_first_order('relative_position', 'DESC')).order(id: :desc).map(&:to_json)
+ expected_issues = exportable.issues.reorder(Issue.arel_table[:relative_position].desc.nulls_first).order(id: :desc).map(&:to_json)
expect(json_writer).to receive(:write_relation_array).with(exportable_path, :issues, expected_issues)
diff --git a/spec/lib/gitlab/import_export/version_checker_spec.rb b/spec/lib/gitlab/import_export/version_checker_spec.rb
index 8b39330656f..9e69e04b17c 100644
--- a/spec/lib/gitlab/import_export/version_checker_spec.rb
+++ b/spec/lib/gitlab/import_export/version_checker_spec.rb
@@ -1,9 +1,10 @@
# frozen_string_literal: true
require 'spec_helper'
-include ImportExport::CommonUtil
RSpec.describe Gitlab::ImportExport::VersionChecker do
+ include ImportExport::CommonUtil
+
let!(:shared) { Gitlab::ImportExport::Shared.new(nil) }
describe 'bundle a project Git repo' do
diff --git a/spec/lib/gitlab/insecure_key_fingerprint_spec.rb b/spec/lib/gitlab/insecure_key_fingerprint_spec.rb
index 2f3489edcd8..3a281574563 100644
--- a/spec/lib/gitlab/insecure_key_fingerprint_spec.rb
+++ b/spec/lib/gitlab/insecure_key_fingerprint_spec.rb
@@ -10,16 +10,9 @@ RSpec.describe Gitlab::InsecureKeyFingerprint do
'Jw0='
end
- let(:fingerprint) { "3f:a2:ee:de:b5:de:53:c3:aa:2f:9c:45:24:4c:47:7b" }
let(:fingerprint_sha256) { "MQHWhS9nhzUezUdD42ytxubZoBKrZLbyBZzxCkmnxXc" }
- describe "#fingerprint" do
- it "generates the key's fingerprint" do
- expect(described_class.new(key.split[1]).fingerprint_md5).to eq(fingerprint)
- end
- end
-
- describe "#fingerprint" do
+ describe '#fingerprint_sha256' do
it "generates the key's fingerprint" do
expect(described_class.new(key.split[1]).fingerprint_sha256).to eq(fingerprint_sha256)
end
diff --git a/spec/lib/gitlab/legacy_github_import/project_creator_spec.rb b/spec/lib/gitlab/legacy_github_import/project_creator_spec.rb
index 02cc2eba4da..68f1c214cef 100644
--- a/spec/lib/gitlab/legacy_github_import/project_creator_spec.rb
+++ b/spec/lib/gitlab/legacy_github_import/project_creator_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe Gitlab::LegacyGithubImport::ProjectCreator do
let(:namespace) { create(:group) }
let(:repo) do
- OpenStruct.new(
+ ActiveSupport::InheritableOptions.new(
login: 'vim',
name: 'vim',
full_name: 'asd/vim',
@@ -21,7 +21,7 @@ RSpec.describe Gitlab::LegacyGithubImport::ProjectCreator do
namespace.add_owner(user)
expect_next_instance_of(Project) do |project|
- expect(project).to receive(:add_import_job)
+ allow(project).to receive(:add_import_job)
end
end
diff --git a/spec/lib/gitlab/metrics/rails_slis_spec.rb b/spec/lib/gitlab/metrics/rails_slis_spec.rb
index 0c77dc9f582..2ba06316507 100644
--- a/spec/lib/gitlab/metrics/rails_slis_spec.rb
+++ b/spec/lib/gitlab/metrics/rails_slis_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe Gitlab::Metrics::RailsSlis do
end
allow(Gitlab::RequestEndpoints).to receive(:all_api_endpoints).and_return([api_route])
- allow(Gitlab::RequestEndpoints).to receive(:all_controller_actions).and_return([[ProjectsController, 'show']])
+ allow(Gitlab::RequestEndpoints).to receive(:all_controller_actions).and_return([[ProjectsController, 'index']])
allow(Gitlab::Graphql::KnownOperations).to receive(:default).and_return(Gitlab::Graphql::KnownOperations.new(%w(foo bar)))
end
@@ -22,13 +22,13 @@ RSpec.describe Gitlab::Metrics::RailsSlis do
request_urgency: :default
},
{
- endpoint_id: "ProjectsController#show",
+ endpoint_id: "ProjectsController#index",
feature_category: :projects,
request_urgency: :default
}
]
- possible_graphql_labels = ['graphql:foo', 'graphql:bar', 'graphql:unknown', 'graphql:anonymous'].map do |endpoint_id|
+ possible_graphql_labels = ['graphql:foo', 'graphql:bar', 'graphql:unknown'].map do |endpoint_id|
{
endpoint_id: endpoint_id,
feature_category: nil,
diff --git a/spec/lib/gitlab/omniauth_initializer_spec.rb b/spec/lib/gitlab/omniauth_initializer_spec.rb
index 8b959cf787f..c91b14a33ba 100644
--- a/spec/lib/gitlab/omniauth_initializer_spec.rb
+++ b/spec/lib/gitlab/omniauth_initializer_spec.rb
@@ -309,4 +309,16 @@ RSpec.describe Gitlab::OmniauthInitializer do
subject.execute([conf])
end
end
+
+ describe '.full_host' do
+ subject { described_class.full_host.call({}) }
+
+ let(:base_url) { 'http://localhost/test' }
+
+ before do
+ allow(Settings).to receive(:gitlab).and_return({ 'base_url' => base_url })
+ end
+
+ it { is_expected.to eq(base_url) }
+ end
end
diff --git a/spec/lib/gitlab/pagination/keyset/column_order_definition_spec.rb b/spec/lib/gitlab/pagination/keyset/column_order_definition_spec.rb
index 69384e0c501..778244677ef 100644
--- a/spec/lib/gitlab/pagination/keyset/column_order_definition_spec.rb
+++ b/spec/lib/gitlab/pagination/keyset/column_order_definition_spec.rb
@@ -140,8 +140,8 @@ RSpec.describe Gitlab::Pagination::Keyset::ColumnOrderDefinition do
described_class.new(
attribute_name: :name,
column_expression: Project.arel_table[:name],
- order_expression: Gitlab::Database.nulls_last_order('merge_request_metrics.merged_at', :desc),
- reversed_order_expression: Gitlab::Database.nulls_first_order('merge_request_metrics.merged_at', :asc),
+ order_expression: MergeRequest::Metrics.arel_table[:merged_at].desc.nulls_last,
+ reversed_order_expression: MergeRequest::Metrics.arel_table[:merged_at].asc.nulls_first,
order_direction: :desc,
nullable: :nulls_last, # null values are always last
distinct: false
@@ -161,8 +161,8 @@ RSpec.describe Gitlab::Pagination::Keyset::ColumnOrderDefinition do
described_class.new(
attribute_name: :name,
column_expression: Project.arel_table[:name],
- order_expression: Gitlab::Database.nulls_last_order('merge_request_metrics.merged_at', :desc),
- reversed_order_expression: Gitlab::Database.nulls_first_order('merge_request_metrics.merged_at', :asc),
+ order_expression: MergeRequest::Metrics.arel_table[:merged_at].desc.nulls_last,
+ reversed_order_expression: MergeRequest::Metrics.arel_table[:merged_at].asc.nulls_first,
order_direction: :desc,
nullable: true,
distinct: false
@@ -175,8 +175,8 @@ RSpec.describe Gitlab::Pagination::Keyset::ColumnOrderDefinition do
described_class.new(
attribute_name: :name,
column_expression: Project.arel_table[:name],
- order_expression: Gitlab::Database.nulls_last_order('merge_request_metrics.merged_at', :desc),
- reversed_order_expression: Gitlab::Database.nulls_first_order('merge_request_metrics.merged_at', :asc),
+ order_expression: MergeRequest::Metrics.arel_table[:merged_at].desc.nulls_last,
+ reversed_order_expression: MergeRequest::Metrics.arel_table[:merged_at].asc.nulls_first,
order_direction: :desc,
nullable: :nulls_last,
distinct: true
@@ -191,8 +191,8 @@ RSpec.describe Gitlab::Pagination::Keyset::ColumnOrderDefinition do
described_class.new(
attribute_name: :name,
column_expression: Project.arel_table[:name],
- order_expression: Gitlab::Database.nulls_last_order('merge_request_metrics.merged_at', :desc),
- reversed_order_expression: Gitlab::Database.nulls_first_order('merge_request_metrics.merged_at', :asc),
+ order_expression: MergeRequest::Metrics.arel_table[:merged_at].desc.nulls_last,
+ reversed_order_expression: MergeRequest::Metrics.arel_table[:merged_at].asc.nulls_first,
order_direction: :desc,
nullable: :nulls_last, # null values are always last
distinct: false
diff --git a/spec/lib/gitlab/pagination/keyset/in_operator_optimization/query_builder_spec.rb b/spec/lib/gitlab/pagination/keyset/in_operator_optimization/query_builder_spec.rb
index 58db22e5a9c..9f2ac9a953d 100644
--- a/spec/lib/gitlab/pagination/keyset/in_operator_optimization/query_builder_spec.rb
+++ b/spec/lib/gitlab/pagination/keyset/in_operator_optimization/query_builder_spec.rb
@@ -24,12 +24,12 @@ RSpec.describe Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder
let_it_be(:issues) do
[
create(:issue, project: project_1, created_at: three_weeks_ago, relative_position: 5),
- create(:issue, project: project_1, created_at: two_weeks_ago),
+ create(:issue, project: project_1, created_at: two_weeks_ago, relative_position: nil),
create(:issue, project: project_2, created_at: two_weeks_ago, relative_position: 15),
- create(:issue, project: project_2, created_at: two_weeks_ago),
- create(:issue, project: project_3, created_at: four_weeks_ago),
+ create(:issue, project: project_2, created_at: two_weeks_ago, relative_position: nil),
+ create(:issue, project: project_3, created_at: four_weeks_ago, relative_position: nil),
create(:issue, project: project_4, created_at: five_weeks_ago, relative_position: 10),
- create(:issue, project: project_5, created_at: four_weeks_ago)
+ create(:issue, project: project_5, created_at: four_weeks_ago, relative_position: nil)
]
end
@@ -121,8 +121,8 @@ RSpec.describe Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: :relative_position,
column_expression: Issue.arel_table[:relative_position],
- order_expression: Gitlab::Database.nulls_last_order('relative_position', :desc),
- reversed_order_expression: Gitlab::Database.nulls_first_order('relative_position', :asc),
+ order_expression: Issue.arel_table[:relative_position].desc.nulls_last,
+ reversed_order_expression: Issue.arel_table[:relative_position].asc.nulls_first,
order_direction: :desc,
nullable: :nulls_last,
distinct: false
@@ -155,6 +155,31 @@ RSpec.describe Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder
it_behaves_like 'correct ordering examples'
end
+
+ context 'with condition "relative_position IS NULL"' do
+ let(:base_scope) { Issue.where(relative_position: nil) }
+ let(:scope) { base_scope.order(order) }
+
+ let(:in_operator_optimization_options) do
+ {
+ array_scope: Project.where(namespace_id: top_level_group.self_and_descendants.select(:id)).select(:id),
+ array_mapping_scope: -> (id_expression) { Issue.merge(base_scope.dup).where(Issue.arel_table[:project_id].eq(id_expression)) },
+ finder_query: -> (_relative_position_expression, id_expression) { Issue.where(Issue.arel_table[:id].eq(id_expression)) }
+ }
+ end
+
+ context 'when iterating records one by one' do
+ let(:batch_size) { 1 }
+
+ it_behaves_like 'correct ordering examples'
+ end
+
+ context 'when iterating records with LIMIT 3' do
+ let(:batch_size) { 3 }
+
+ it_behaves_like 'correct ordering examples'
+ end
+ end
end
context 'when ordering by issues.created_at DESC, issues.id ASC' do
@@ -239,7 +264,7 @@ RSpec.describe Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder
end
it 'raises error when unsupported scope is passed' do
- scope = Issue.order(Issue.arel_table[:id].lower.desc)
+ scope = Issue.order(Arel::Nodes::NamedFunction.new('UPPER', [Issue.arel_table[:id]]))
options = {
scope: scope,
diff --git a/spec/lib/gitlab/pagination/keyset/iterator_spec.rb b/spec/lib/gitlab/pagination/keyset/iterator_spec.rb
index 09cbca2c1cb..d62d20d2d2c 100644
--- a/spec/lib/gitlab/pagination/keyset/iterator_spec.rb
+++ b/spec/lib/gitlab/pagination/keyset/iterator_spec.rb
@@ -19,8 +19,8 @@ RSpec.describe Gitlab::Pagination::Keyset::Iterator do
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: column,
column_expression: klass.arel_table[column],
- order_expression: ::Gitlab::Database.nulls_order(column, direction, nulls_position),
- reversed_order_expression: ::Gitlab::Database.nulls_order(column, reverse_direction, reverse_nulls_position),
+ order_expression: klass.arel_table[column].public_send(direction).public_send(nulls_position), # rubocop:disable GitlabSecurity/PublicSend
+ reversed_order_expression: klass.arel_table[column].public_send(reverse_direction).public_send(reverse_nulls_position), # rubocop:disable GitlabSecurity/PublicSend
order_direction: direction,
nullable: nulls_position,
distinct: false
@@ -99,7 +99,7 @@ RSpec.describe Gitlab::Pagination::Keyset::Iterator do
iterator.each_batch(of: 2) { |rel| positions.concat(rel.pluck(:relative_position, :id)) }
- expect(positions).to eq(project.issues.reorder(::Gitlab::Database.nulls_last_order('relative_position', 'ASC')).order(id: :asc).pluck(:relative_position, :id))
+ expect(positions).to eq(project.issues.reorder(Issue.arel_table[:relative_position].asc.nulls_last).order(id: :asc).pluck(:relative_position, :id))
end
end
@@ -111,7 +111,7 @@ RSpec.describe Gitlab::Pagination::Keyset::Iterator do
iterator.each_batch(of: 2) { |rel| positions.concat(rel.pluck(:relative_position, :id)) }
- expect(positions).to eq(project.issues.reorder(::Gitlab::Database.nulls_first_order('relative_position', 'DESC')).order(id: :desc).pluck(:relative_position, :id))
+ expect(positions).to eq(project.issues.reorder(Issue.arel_table[:relative_position].desc.nulls_first).order(id: :desc).pluck(:relative_position, :id))
end
end
@@ -123,7 +123,7 @@ RSpec.describe Gitlab::Pagination::Keyset::Iterator do
iterator.each_batch(of: 2) { |rel| positions.concat(rel.pluck(:relative_position, :id)) }
- expect(positions).to eq(project.issues.reorder(::Gitlab::Database.nulls_first_order('relative_position', 'ASC')).order(id: :asc).pluck(:relative_position, :id))
+ expect(positions).to eq(project.issues.reorder(Issue.arel_table[:relative_position].asc.nulls_first).order(id: :asc).pluck(:relative_position, :id))
end
end
@@ -136,7 +136,7 @@ RSpec.describe Gitlab::Pagination::Keyset::Iterator do
iterator.each_batch(of: 2) { |rel| positions.concat(rel.pluck(:relative_position, :id)) }
- expect(positions).to eq(project.issues.reorder(::Gitlab::Database.nulls_last_order('relative_position', 'DESC')).order(id: :desc).pluck(:relative_position, :id))
+ expect(positions).to eq(project.issues.reorder(Issue.arel_table[:relative_position].desc.nulls_last).order(id: :desc).pluck(:relative_position, :id))
end
end
diff --git a/spec/lib/gitlab/pagination/keyset/order_spec.rb b/spec/lib/gitlab/pagination/keyset/order_spec.rb
index 1bed8e542a2..abbb3a21cd4 100644
--- a/spec/lib/gitlab/pagination/keyset/order_spec.rb
+++ b/spec/lib/gitlab/pagination/keyset/order_spec.rb
@@ -262,8 +262,8 @@ RSpec.describe Gitlab::Pagination::Keyset::Order do
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'year',
column_expression: table['year'],
- order_expression: Gitlab::Database.nulls_last_order('year', :asc),
- reversed_order_expression: Gitlab::Database.nulls_first_order('year', :desc),
+ order_expression: table[:year].asc.nulls_last,
+ reversed_order_expression: table[:year].desc.nulls_first,
order_direction: :asc,
nullable: :nulls_last,
distinct: false
@@ -271,8 +271,8 @@ RSpec.describe Gitlab::Pagination::Keyset::Order do
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'month',
column_expression: table['month'],
- order_expression: Gitlab::Database.nulls_last_order('month', :asc),
- reversed_order_expression: Gitlab::Database.nulls_first_order('month', :desc),
+ order_expression: table[:month].asc.nulls_last,
+ reversed_order_expression: table[:month].desc.nulls_first,
order_direction: :asc,
nullable: :nulls_last,
distinct: false
@@ -328,8 +328,8 @@ RSpec.describe Gitlab::Pagination::Keyset::Order do
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'year',
column_expression: table['year'],
- order_expression: Gitlab::Database.nulls_first_order('year', :asc),
- reversed_order_expression: Gitlab::Database.nulls_last_order('year', :desc),
+ order_expression: table[:year].asc.nulls_first,
+ reversed_order_expression: table[:year].desc.nulls_last,
order_direction: :asc,
nullable: :nulls_first,
distinct: false
@@ -337,9 +337,9 @@ RSpec.describe Gitlab::Pagination::Keyset::Order do
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'month',
column_expression: table['month'],
- order_expression: Gitlab::Database.nulls_first_order('month', :asc),
+ order_expression: table[:month].asc.nulls_first,
order_direction: :asc,
- reversed_order_expression: Gitlab::Database.nulls_last_order('month', :desc),
+ reversed_order_expression: table[:month].desc.nulls_last,
nullable: :nulls_first,
distinct: false
),
@@ -441,6 +441,47 @@ RSpec.describe Gitlab::Pagination::Keyset::Order do
end
end
+ context 'when ordering by the named function LOWER' do
+ let(:order) do
+ Gitlab::Pagination::Keyset::Order.build([
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'title',
+ column_expression: Arel::Nodes::NamedFunction.new("LOWER", [table['title'].desc]),
+ order_expression: table['title'].lower.desc,
+ nullable: :not_nullable,
+ distinct: false
+ ),
+ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
+ attribute_name: 'id',
+ column_expression: table['id'],
+ order_expression: table['id'].desc,
+ nullable: :not_nullable,
+ distinct: true
+ )
+ ])
+ end
+
+ let(:table_data) do
+ <<-SQL
+ VALUES (1, 'A')
+ SQL
+ end
+
+ let(:query) do
+ <<-SQL
+ SELECT id, title
+ FROM (#{table_data}) my_table (id, title)
+ ORDER BY #{order};
+ SQL
+ end
+
+ subject { run_query(query) }
+
+ it "uses downcased value for encoding and decoding a cursor" do
+ expect(order.cursor_attributes_for_node(subject.first)['title']).to eq("a")
+ end
+ end
+
context 'when the passed cursor values do not match with the order definition' do
let(:order) do
Gitlab::Pagination::Keyset::Order.build([
diff --git a/spec/lib/gitlab/pagination/keyset/simple_order_builder_spec.rb b/spec/lib/gitlab/pagination/keyset/simple_order_builder_spec.rb
index 5af86cb2dc0..4f1d380ab0a 100644
--- a/spec/lib/gitlab/pagination/keyset/simple_order_builder_spec.rb
+++ b/spec/lib/gitlab/pagination/keyset/simple_order_builder_spec.rb
@@ -5,6 +5,7 @@ require 'spec_helper'
RSpec.describe Gitlab::Pagination::Keyset::SimpleOrderBuilder do
let(:ordered_scope) { described_class.build(scope).first }
let(:order_object) { Gitlab::Pagination::Keyset::Order.extract_keyset_order_object(ordered_scope) }
+ let(:column_definition) { order_object.column_definitions.first }
subject(:sql_with_order) { ordered_scope.to_sql }
@@ -16,11 +17,25 @@ RSpec.describe Gitlab::Pagination::Keyset::SimpleOrderBuilder do
end
it 'sets the column definition distinct and not nullable' do
- column_definition = order_object.column_definitions.first
-
expect(column_definition).to be_not_nullable
expect(column_definition).to be_distinct
end
+
+ context "when the order scope's model uses default_scope" do
+ let(:scope) do
+ model = Class.new(ApplicationRecord) do
+ self.table_name = 'events'
+
+ default_scope { reorder(nil) } # rubocop:disable Cop/DefaultScope
+ end
+
+ model.reorder(nil)
+ end
+
+ it 'orders by primary key' do
+ expect(sql_with_order).to end_with('ORDER BY "events"."id" DESC')
+ end
+ end
end
context 'when primary key order present' do
@@ -39,8 +54,6 @@ RSpec.describe Gitlab::Pagination::Keyset::SimpleOrderBuilder do
end
it 'sets the column definition for created_at non-distinct and nullable' do
- column_definition = order_object.column_definitions.first
-
expect(column_definition.attribute_name).to eq('created_at')
expect(column_definition.nullable?).to eq(true) # be_nullable calls non_null? method for some reason
expect(column_definition).not_to be_distinct
@@ -59,14 +72,80 @@ RSpec.describe Gitlab::Pagination::Keyset::SimpleOrderBuilder do
let(:scope) { Project.where(id: [1, 2, 3]).order(namespace_id: :asc, id: :asc) }
it 'sets the column definition for namespace_id non-distinct and non-nullable' do
- column_definition = order_object.column_definitions.first
-
expect(column_definition.attribute_name).to eq('namespace_id')
expect(column_definition).to be_not_nullable
expect(column_definition).not_to be_distinct
end
end
+ context 'when ordering by a column with the lower named function' do
+ let(:scope) { Project.where(id: [1, 2, 3]).order(Project.arel_table[:name].lower.desc) }
+
+ it 'sets the column definition for name' do
+ expect(column_definition.attribute_name).to eq('name')
+ expect(column_definition.column_expression.expressions.first.name).to eq('name')
+ expect(column_definition.column_expression.name).to eq('LOWER')
+ end
+
+ it 'adds extra primary key order as tie-breaker' do
+ expect(sql_with_order).to end_with('ORDER BY LOWER("projects"."name") DESC, "projects"."id" DESC')
+ end
+ end
+
+ context "NULLS order given as as an Arel literal" do
+ context 'when NULLS LAST order is given without a tie-breaker' do
+ let(:scope) { Project.order(Project.arel_table[:created_at].asc.nulls_last) }
+
+ it 'sets the column definition for created_at appropriately' do
+ expect(column_definition.attribute_name).to eq('created_at')
+ end
+
+ it 'orders by primary key' do
+ expect(sql_with_order)
+ .to end_with('ORDER BY "projects"."created_at" ASC NULLS LAST, "projects"."id" DESC')
+ end
+ end
+
+ context 'when NULLS FIRST order is given with a tie-breaker' do
+ let(:scope) { Issue.order(Issue.arel_table[:relative_position].desc.nulls_first).order(id: :asc) }
+
+ it 'sets the column definition for created_at appropriately' do
+ expect(column_definition.attribute_name).to eq('relative_position')
+ end
+
+ it 'orders by the given primary key' do
+ expect(sql_with_order)
+ .to end_with('ORDER BY "issues"."relative_position" DESC NULLS FIRST, "issues"."id" ASC')
+ end
+ end
+ end
+
+ context "NULLS order given as as an Arel node" do
+ context 'when NULLS LAST order is given without a tie-breaker' do
+ let(:scope) { Project.order(Project.arel_table[:created_at].asc.nulls_last) }
+
+ it 'sets the column definition for created_at appropriately' do
+ expect(column_definition.attribute_name).to eq('created_at')
+ end
+
+ it 'orders by primary key' do
+ expect(sql_with_order).to end_with('ORDER BY "projects"."created_at" ASC NULLS LAST, "projects"."id" DESC')
+ end
+ end
+
+ context 'when NULLS FIRST order is given with a tie-breaker' do
+ let(:scope) { Issue.order(Issue.arel_table[:relative_position].desc.nulls_first).order(id: :asc) }
+
+ it 'sets the column definition for created_at appropriately' do
+ expect(column_definition.attribute_name).to eq('relative_position')
+ end
+
+ it 'orders by the given primary key' do
+ expect(sql_with_order).to end_with('ORDER BY "issues"."relative_position" DESC NULLS FIRST, "issues"."id" ASC')
+ end
+ end
+ end
+
context 'return :unable_to_order symbol when order cannot be built' do
subject(:success) { described_class.build(scope).last }
@@ -76,10 +155,20 @@ RSpec.describe Gitlab::Pagination::Keyset::SimpleOrderBuilder do
it { is_expected.to eq(false) }
end
- context 'when NULLS LAST order is given' do
- let(:scope) { Project.order(::Gitlab::Database.nulls_last_order('created_at', 'ASC')) }
+ context 'when an invalid NULLS order is given' do
+ using RSpec::Parameterized::TableSyntax
- it { is_expected.to eq(false) }
+ where(:scope) do
+ [
+ lazy { Project.order(Arel.sql('projects.updated_at created_at Asc Nulls Last')) },
+ lazy { Project.order(Arel.sql('projects.created_at ZZZ NULLS FIRST')) },
+ lazy { Project.order(Arel.sql('projects.relative_position ASC NULLS LAST')) }
+ ]
+ end
+
+ with_them do
+ it { is_expected.to eq(false) }
+ end
end
context 'when more than 2 columns are given for the order' do
diff --git a/spec/lib/gitlab/pagination/offset_pagination_spec.rb b/spec/lib/gitlab/pagination/offset_pagination_spec.rb
index f8d50fbc517..ebbd207cc11 100644
--- a/spec/lib/gitlab/pagination/offset_pagination_spec.rb
+++ b/spec/lib/gitlab/pagination/offset_pagination_spec.rb
@@ -66,70 +66,50 @@ RSpec.describe Gitlab::Pagination::OffsetPagination do
let(:query) { base_query.merge(page: 1, per_page: 2) }
- context 'when the api_kaminari_count_with_limit feature flag is unset' do
- it_behaves_like 'paginated response'
- it_behaves_like 'response with pagination headers'
- end
-
- context 'when the api_kaminari_count_with_limit feature flag is disabled' do
+ context 'when resources count is less than MAX_COUNT_LIMIT' do
before do
- stub_feature_flags(api_kaminari_count_with_limit: false)
+ stub_const("::Kaminari::ActiveRecordRelationMethods::MAX_COUNT_LIMIT", 4)
end
it_behaves_like 'paginated response'
it_behaves_like 'response with pagination headers'
end
- context 'when the api_kaminari_count_with_limit feature flag is enabled' do
+ context 'when resources count is more than MAX_COUNT_LIMIT' do
before do
- stub_feature_flags(api_kaminari_count_with_limit: true)
- end
-
- context 'when resources count is less than MAX_COUNT_LIMIT' do
- before do
- stub_const("::Kaminari::ActiveRecordRelationMethods::MAX_COUNT_LIMIT", 4)
- end
-
- it_behaves_like 'paginated response'
- it_behaves_like 'response with pagination headers'
+ stub_const("::Kaminari::ActiveRecordRelationMethods::MAX_COUNT_LIMIT", 2)
end
- context 'when resources count is more than MAX_COUNT_LIMIT' do
- before do
- stub_const("::Kaminari::ActiveRecordRelationMethods::MAX_COUNT_LIMIT", 2)
- end
-
- it_behaves_like 'paginated response'
-
- it 'does not return the X-Total and X-Total-Pages headers' do
- expect_no_header('X-Total')
- expect_no_header('X-Total-Pages')
- expect_header('X-Per-Page', '2')
- expect_header('X-Page', '1')
- expect_header('X-Next-Page', '2')
- expect_header('X-Prev-Page', '')
-
- expect_header('Link', anything) do |_key, val|
- expect(val).to include(%Q(<#{incoming_api_projects_url}?#{query.merge(page: 1).to_query}>; rel="first"))
- expect(val).to include(%Q(<#{incoming_api_projects_url}?#{query.merge(page: 2).to_query}>; rel="next"))
- expect(val).not_to include('rel="last"')
- expect(val).not_to include('rel="prev"')
- end
-
- subject.paginate(resource)
- end
- end
+ it_behaves_like 'paginated response'
- it 'does not return the total headers when excluding them' do
+ it 'does not return the X-Total and X-Total-Pages headers' do
expect_no_header('X-Total')
expect_no_header('X-Total-Pages')
expect_header('X-Per-Page', '2')
expect_header('X-Page', '1')
+ expect_header('X-Next-Page', '2')
+ expect_header('X-Prev-Page', '')
- paginator.paginate(resource, exclude_total_headers: true)
+ expect_header('Link', anything) do |_key, val|
+ expect(val).to include(%Q(<#{incoming_api_projects_url}?#{query.merge(page: 1).to_query}>; rel="first"))
+ expect(val).to include(%Q(<#{incoming_api_projects_url}?#{query.merge(page: 2).to_query}>; rel="next"))
+ expect(val).not_to include('rel="last"')
+ expect(val).not_to include('rel="prev"')
+ end
+
+ subject.paginate(resource)
end
end
+ it 'does not return the total headers when excluding them' do
+ expect_no_header('X-Total')
+ expect_no_header('X-Total-Pages')
+ expect_header('X-Per-Page', '2')
+ expect_header('X-Page', '1')
+
+ paginator.paginate(resource, exclude_total_headers: true)
+ end
+
context 'when resource already paginated' do
let(:resource) { Project.all.page(1).per(1) }
diff --git a/spec/lib/gitlab/patch/legacy_database_config_spec.rb b/spec/lib/gitlab/patch/database_config_spec.rb
index b87e16f31ae..d6f36ab86d5 100644
--- a/spec/lib/gitlab/patch/legacy_database_config_spec.rb
+++ b/spec/lib/gitlab/patch/database_config_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Patch::LegacyDatabaseConfig do
+RSpec.describe Gitlab::Patch::DatabaseConfig do
it 'module is included' do
expect(Rails::Application::Configuration).to include(described_class)
end
diff --git a/spec/lib/gitlab/path_regex_spec.rb b/spec/lib/gitlab/path_regex_spec.rb
index 9876387512b..e5fa7538515 100644
--- a/spec/lib/gitlab/path_regex_spec.rb
+++ b/spec/lib/gitlab/path_regex_spec.rb
@@ -557,7 +557,7 @@ RSpec.describe Gitlab::PathRegex do
end
it 'does not match other non-word characters' do
- expect(subject.match('ruby:2.7.0')[0]).to eq('ruby')
+ expect(subject.match('image:1.0.0')[0]).to eq('image')
end
end
diff --git a/spec/lib/gitlab/project_template_spec.rb b/spec/lib/gitlab/project_template_spec.rb
index 05417e721c7..0ef52b63bc6 100644
--- a/spec/lib/gitlab/project_template_spec.rb
+++ b/spec/lib/gitlab/project_template_spec.rb
@@ -8,7 +8,7 @@ RSpec.describe Gitlab::ProjectTemplate do
expected = %w[
rails spring express iosswift dotnetcore android
gomicro gatsby hugo jekyll plainhtml gitbook
- hexo sse_middleman gitpod_spring_petclinic nfhugo
+ hexo middleman gitpod_spring_petclinic nfhugo
nfjekyll nfplainhtml nfgitbook nfhexo salesforcedx
serverless_framework tencent_serverless_framework
jsonnet cluster_management kotlin_native_linux
diff --git a/spec/lib/gitlab/quick_actions/command_definition_spec.rb b/spec/lib/gitlab/quick_actions/command_definition_spec.rb
index 73629ce3da2..8362c07baca 100644
--- a/spec/lib/gitlab/quick_actions/command_definition_spec.rb
+++ b/spec/lib/gitlab/quick_actions/command_definition_spec.rb
@@ -26,7 +26,7 @@ RSpec.describe Gitlab::QuickActions::CommandDefinition do
describe "#noop?" do
context "when the command has an action block" do
before do
- subject.action_block = proc { }
+ subject.action_block = proc {}
end
it "returns false" do
@@ -42,7 +42,7 @@ RSpec.describe Gitlab::QuickActions::CommandDefinition do
end
describe "#available?" do
- let(:opts) { OpenStruct.new(go: false) }
+ let(:opts) { ActiveSupport::InheritableOptions.new(go: false) }
context "when the command has a condition block" do
before do
@@ -104,7 +104,8 @@ RSpec.describe Gitlab::QuickActions::CommandDefinition do
end
describe "#execute" do
- let(:context) { OpenStruct.new(run: false, commands_executed_count: nil) }
+ let(:fake_context) { Struct.new(:run, :commands_executed_count, :received_arg) }
+ let(:context) { fake_context.new(false, nil, nil) }
context "when the command is a noop" do
it "doesn't execute the command" do
diff --git a/spec/lib/gitlab/search_context/builder_spec.rb b/spec/lib/gitlab/search_context/builder_spec.rb
index 079477115bb..a09115f3f21 100644
--- a/spec/lib/gitlab/search_context/builder_spec.rb
+++ b/spec/lib/gitlab/search_context/builder_spec.rb
@@ -43,7 +43,6 @@ RSpec.describe Gitlab::SearchContext::Builder, type: :controller do
def be_search_context(project: nil, group: nil, snippets: [], ref: nil)
group = project ? project.group : group
snippets.compact!
- ref = ref
have_attributes(
project: project,
diff --git a/spec/lib/gitlab/security/scan_configuration_spec.rb b/spec/lib/gitlab/security/scan_configuration_spec.rb
index 2e8a11dfda3..1760796c5a0 100644
--- a/spec/lib/gitlab/security/scan_configuration_spec.rb
+++ b/spec/lib/gitlab/security/scan_configuration_spec.rb
@@ -47,6 +47,16 @@ RSpec.describe ::Gitlab::Security::ScanConfiguration do
it { is_expected.to be_nil }
end
+ describe '#meta_info_path' do
+ subject { scan.meta_info_path }
+
+ let(:configured) { true }
+ let(:available) { true }
+ let(:type) { :dast }
+
+ it { is_expected.to be_nil }
+ end
+
describe '#can_enable_by_merge_request?' do
subject { scan.can_enable_by_merge_request? }
diff --git a/spec/lib/gitlab/seeder_spec.rb b/spec/lib/gitlab/seeder_spec.rb
index 71d0a41ef98..a22d47cbfb3 100644
--- a/spec/lib/gitlab/seeder_spec.rb
+++ b/spec/lib/gitlab/seeder_spec.rb
@@ -3,6 +3,24 @@
require 'spec_helper'
RSpec.describe Gitlab::Seeder do
+ describe Namespace do
+ subject { described_class }
+
+ it 'has not_mass_generated scope' do
+ expect { Namespace.not_mass_generated }.to raise_error(NoMethodError)
+
+ Gitlab::Seeder.quiet do
+ expect { Namespace.not_mass_generated }.not_to raise_error
+ end
+ end
+
+ it 'includes NamespaceSeed module' do
+ Gitlab::Seeder.quiet do
+ is_expected.to include_module(Gitlab::Seeder::NamespaceSeed)
+ end
+ end
+ end
+
describe '.quiet' do
let(:database_base_models) do
{
@@ -50,4 +68,13 @@ RSpec.describe Gitlab::Seeder do
notification_service.new_note(note)
end
end
+
+ describe '.log_message' do
+ it 'prepends timestamp to the logged message' do
+ freeze_time do
+ message = "some message."
+ expect { described_class.log_message(message) }.to output(/#{Time.current}: #{message}/).to_stdout
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb b/spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb
index 3fbd207c2e1..ffa92126cc9 100644
--- a/spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb
+++ b/spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb
@@ -292,7 +292,7 @@ RSpec.describe Gitlab::SidekiqMiddleware::ServerMetrics do
if category
feature_category category
else
- feature_category_not_owned!
+ feature_category :not_owned
end
def perform
diff --git a/spec/lib/gitlab/sidekiq_middleware/worker_context/client_spec.rb b/spec/lib/gitlab/sidekiq_middleware/worker_context/client_spec.rb
index b9a13fd697e..3baa0c6f967 100644
--- a/spec/lib/gitlab/sidekiq_middleware/worker_context/client_spec.rb
+++ b/spec/lib/gitlab/sidekiq_middleware/worker_context/client_spec.rb
@@ -28,7 +28,7 @@ RSpec.describe Gitlab::SidekiqMiddleware::WorkerContext::Client do
'TestNotOwnedWithContextWorker'
end
- feature_category_not_owned!
+ feature_category :not_owned
end
end
diff --git a/spec/lib/gitlab/sidekiq_middleware/worker_context/server_spec.rb b/spec/lib/gitlab/sidekiq_middleware/worker_context/server_spec.rb
index 377ff6fd166..05b328e55d3 100644
--- a/spec/lib/gitlab/sidekiq_middleware/worker_context/server_spec.rb
+++ b/spec/lib/gitlab/sidekiq_middleware/worker_context/server_spec.rb
@@ -29,7 +29,7 @@ RSpec.describe Gitlab::SidekiqMiddleware::WorkerContext::Server do
"NotOwnedWorker"
end
- feature_category_not_owned!
+ feature_category :not_owned
end
end
diff --git a/spec/lib/gitlab/ssh_public_key_spec.rb b/spec/lib/gitlab/ssh_public_key_spec.rb
index cf5d2c3b455..422b6f925a1 100644
--- a/spec/lib/gitlab/ssh_public_key_spec.rb
+++ b/spec/lib/gitlab/ssh_public_key_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::SSHPublicKey, lib: true do
+RSpec.describe Gitlab::SSHPublicKey, lib: true, fips_mode: false do
let(:key) { attributes_for(:rsa_key_2048)[:key] }
let(:public_key) { described_class.new(key) }
@@ -19,6 +19,17 @@ RSpec.describe Gitlab::SSHPublicKey, lib: true do
it { expect(described_class.technology(name).name).to eq(name) }
it { expect(described_class.technology(name.to_s).name).to eq(name) }
end
+
+ context 'FIPS mode', :fips_mode do
+ where(:name) do
+ [:rsa, :ecdsa, :ed25519, :ecdsa_sk, :ed25519_sk]
+ end
+
+ with_them do
+ it { expect(described_class.technology(name).name).to eq(name) }
+ it { expect(described_class.technology(name.to_s).name).to eq(name) }
+ end
+ end
end
describe '.supported_types' do
@@ -27,6 +38,14 @@ RSpec.describe Gitlab::SSHPublicKey, lib: true do
[:rsa, :dsa, :ecdsa, :ed25519, :ecdsa_sk, :ed25519_sk]
)
end
+
+ context 'FIPS mode', :fips_mode do
+ it 'returns array with the names of supported technologies' do
+ expect(described_class.supported_types).to eq(
+ [:rsa, :dsa, :ecdsa, :ed25519, :ecdsa_sk, :ed25519_sk]
+ )
+ end
+ end
end
describe '.supported_sizes(name)' do
@@ -45,6 +64,24 @@ RSpec.describe Gitlab::SSHPublicKey, lib: true do
it { expect(described_class.supported_sizes(name)).to eq(sizes) }
it { expect(described_class.supported_sizes(name.to_s)).to eq(sizes) }
end
+
+ context 'FIPS mode', :fips_mode do
+ where(:name, :sizes) do
+ [
+ [:rsa, [3072, 4096]],
+ [:dsa, []],
+ [:ecdsa, [256, 384, 521]],
+ [:ed25519, [256]],
+ [:ecdsa_sk, [256]],
+ [:ed25519_sk, [256]]
+ ]
+ end
+
+ with_them do
+ it { expect(described_class.supported_sizes(name)).to eq(sizes) }
+ it { expect(described_class.supported_sizes(name.to_s)).to eq(sizes) }
+ end
+ end
end
describe '.supported_algorithms' do
@@ -60,6 +97,21 @@ RSpec.describe Gitlab::SSHPublicKey, lib: true do
)
)
end
+
+ context 'FIPS mode', :fips_mode do
+ it 'returns all supported algorithms' do
+ expect(described_class.supported_algorithms).to eq(
+ %w(
+ ssh-rsa
+ ssh-dss
+ ecdsa-sha2-nistp256 ecdsa-sha2-nistp384 ecdsa-sha2-nistp521
+ ssh-ed25519
+ sk-ecdsa-sha2-nistp256@openssh.com
+ sk-ssh-ed25519@openssh.com
+ )
+ )
+ end
+ end
end
describe '.supported_algorithms_for_name' do
@@ -80,6 +132,26 @@ RSpec.describe Gitlab::SSHPublicKey, lib: true do
expect(described_class.supported_algorithms_for_name(name.to_s)).to eq(algorithms)
end
end
+
+ context 'FIPS mode', :fips_mode do
+ where(:name, :algorithms) do
+ [
+ [:rsa, %w(ssh-rsa)],
+ [:dsa, %w(ssh-dss)],
+ [:ecdsa, %w(ecdsa-sha2-nistp256 ecdsa-sha2-nistp384 ecdsa-sha2-nistp521)],
+ [:ed25519, %w(ssh-ed25519)],
+ [:ecdsa_sk, %w(sk-ecdsa-sha2-nistp256@openssh.com)],
+ [:ed25519_sk, %w(sk-ssh-ed25519@openssh.com)]
+ ]
+ end
+
+ with_them do
+ it "returns all supported algorithms for #{params[:name]}" do
+ expect(described_class.supported_algorithms_for_name(name)).to eq(algorithms)
+ expect(described_class.supported_algorithms_for_name(name.to_s)).to eq(algorithms)
+ end
+ end
+ end
end
describe '.sanitize(key_content)' do
diff --git a/spec/lib/gitlab/suggestions/commit_message_spec.rb b/spec/lib/gitlab/suggestions/commit_message_spec.rb
index 965960f0c3e..dcadc206715 100644
--- a/spec/lib/gitlab/suggestions/commit_message_spec.rb
+++ b/spec/lib/gitlab/suggestions/commit_message_spec.rb
@@ -3,7 +3,10 @@
require 'spec_helper'
RSpec.describe Gitlab::Suggestions::CommitMessage do
- def create_suggestion(file_path, new_line, to_content)
+ include ProjectForksHelper
+ using RSpec::Parameterized::TableSyntax
+
+ def create_suggestion(merge_request, file_path, new_line, to_content)
position = Gitlab::Diff::Position.new(old_path: file_path,
new_path: file_path,
old_line: nil,
@@ -29,69 +32,111 @@ RSpec.describe Gitlab::Suggestions::CommitMessage do
create(:project, :repository, path: 'project-1', name: 'Project_1')
end
- let_it_be(:merge_request) do
+ let_it_be(:forked_project) { fork_project(project, nil, repository: true) }
+
+ let_it_be(:merge_request_same_project) do
create(:merge_request, source_project: project, target_project: project)
end
- let_it_be(:suggestion_set) do
- suggestion1 = create_suggestion('files/ruby/popen.rb', 9, '*** SUGGESTION 1 ***')
- suggestion2 = create_suggestion('files/ruby/popen.rb', 13, '*** SUGGESTION 2 ***')
- suggestion3 = create_suggestion('files/ruby/regex.rb', 22, '*** SUGGESTION 3 ***')
+ let_it_be(:merge_request_from_fork) do
+ create(:merge_request, source_project: forked_project, target_project: project)
+ end
+
+ let_it_be(:suggestion_set_same_project) do
+ suggestion1 = create_suggestion(merge_request_same_project, 'files/ruby/popen.rb', 9, '*** SUGGESTION 1 ***')
+ suggestion2 = create_suggestion(merge_request_same_project, 'files/ruby/popen.rb', 13, '*** SUGGESTION 2 ***')
+ suggestion3 = create_suggestion(merge_request_same_project, 'files/ruby/regex.rb', 22, '*** SUGGESTION 3 ***')
+
+ Gitlab::Suggestions::SuggestionSet.new([suggestion1, suggestion2, suggestion3])
+ end
+
+ let_it_be(:suggestion_set_forked_project) do
+ suggestion1 = create_suggestion(merge_request_from_fork, 'files/ruby/popen.rb', 9, '*** SUGGESTION 1 ***')
+ suggestion2 = create_suggestion(merge_request_from_fork, 'files/ruby/popen.rb', 13, '*** SUGGESTION 2 ***')
+ suggestion3 = create_suggestion(merge_request_from_fork, 'files/ruby/regex.rb', 22, '*** SUGGESTION 3 ***')
Gitlab::Suggestions::SuggestionSet.new([suggestion1, suggestion2, suggestion3])
end
describe '#message' do
- before do
- # Updating the suggestion_commit_message on a project shared across specs
- # avoids recreating the repository for each spec.
- project.update!(suggestion_commit_message: message)
- end
+ where(:suggestion_set) { [ref(:suggestion_set_same_project), ref(:suggestion_set_forked_project)] }
+
+ with_them do
+ before do
+ # Updating the suggestion_commit_message on a project shared across specs
+ # avoids recreating the repository for each spec.
+ project.update!(suggestion_commit_message: message)
+ forked_project.update!(suggestion_commit_message: fork_message)
+ end
+
+ let(:fork_message) { nil }
- context 'when a custom commit message is not specified' do
- let(:expected_message) { 'Apply 3 suggestion(s) to 2 file(s)' }
+ context 'when a custom commit message is not specified' do
+ let(:expected_message) { 'Apply 3 suggestion(s) to 2 file(s)' }
- context 'and is nil' do
- let(:message) { nil }
+ context 'and is nil' do
+ let(:message) { nil }
- it 'uses the default commit message' do
- expect(described_class
- .new(user, suggestion_set)
- .message).to eq(expected_message)
+ it 'uses the default commit message' do
+ expect(described_class
+ .new(user, suggestion_set)
+ .message).to eq(expected_message)
+ end
end
- end
- context 'and is an empty string' do
- let(:message) { '' }
+ context 'and is an empty string' do
+ let(:message) { '' }
- it 'uses the default commit message' do
- expect(described_class
- .new(user, suggestion_set)
- .message).to eq(expected_message)
+ it 'uses the default commit message' do
+ expect(described_class
+ .new(user, suggestion_set)
+ .message).to eq(expected_message)
+ end
end
- end
- end
- context 'when a custom commit message is specified' do
- let(:message) { "i'm a project message. a user's custom message takes precedence over me :(" }
- let(:custom_message) { "hello there! i'm a cool custom commit message." }
+ context 'when a custom commit message is specified for forked project' do
+ let(:message) { nil }
+ let(:fork_message) { "I'm a sad message that will not be used :(" }
- it 'shows the custom commit message' do
- expect(Gitlab::Suggestions::CommitMessage
- .new(user, suggestion_set, custom_message)
- .message).to eq(custom_message)
+ it 'uses the default commit message' do
+ expect(described_class
+ .new(user, suggestion_set)
+ .message).to eq(expected_message)
+ end
+ end
end
- end
- context 'is specified and includes all placeholders' do
- let(:message) do
- '*** %{branch_name} %{files_count} %{file_paths} %{project_name} %{project_path} %{user_full_name} %{username} %{suggestions_count} ***'
+ context 'when a custom commit message is specified' do
+ let(:message) { "i'm a project message. a user's custom message takes precedence over me :(" }
+ let(:custom_message) { "hello there! i'm a cool custom commit message." }
+
+ it 'shows the custom commit message' do
+ expect(Gitlab::Suggestions::CommitMessage
+ .new(user, suggestion_set, custom_message)
+ .message).to eq(custom_message)
+ end
end
- it 'generates a custom commit message' do
- expect(Gitlab::Suggestions::CommitMessage
- .new(user, suggestion_set)
- .message).to eq('*** master 2 files/ruby/popen.rb, files/ruby/regex.rb Project_1 project-1 Test User test.user 3 ***')
+ context 'is specified and includes all placeholders' do
+ let(:message) do
+ '*** %{branch_name} %{files_count} %{file_paths} %{project_name} %{project_path} %{user_full_name} %{username} %{suggestions_count} ***'
+ end
+
+ it 'generates a custom commit message' do
+ expect(Gitlab::Suggestions::CommitMessage
+ .new(user, suggestion_set)
+ .message).to eq('*** master 2 files/ruby/popen.rb, files/ruby/regex.rb Project_1 project-1 Test User test.user 3 ***')
+ end
+
+ context 'when a custom commit message is specified for forked project' do
+ let(:fork_message) { "I'm a sad message that will not be used :(" }
+
+ it 'uses the target project commit message' do
+ expect(Gitlab::Suggestions::CommitMessage
+ .new(user, suggestion_set)
+ .message).to eq('*** master 2 files/ruby/popen.rb, files/ruby/regex.rb Project_1 project-1 Test User test.user 3 ***')
+ end
+ end
end
end
end
diff --git a/spec/lib/gitlab/suggestions/suggestion_set_spec.rb b/spec/lib/gitlab/suggestions/suggestion_set_spec.rb
index 54d79a9d4ba..469646986e1 100644
--- a/spec/lib/gitlab/suggestions/suggestion_set_spec.rb
+++ b/spec/lib/gitlab/suggestions/suggestion_set_spec.rb
@@ -3,6 +3,9 @@
require 'spec_helper'
RSpec.describe Gitlab::Suggestions::SuggestionSet do
+ include ProjectForksHelper
+ using RSpec::Parameterized::TableSyntax
+
def create_suggestion(file_path, new_line, to_content)
position = Gitlab::Diff::Position.new(old_path: file_path,
new_path: file_path,
@@ -24,86 +27,99 @@ RSpec.describe Gitlab::Suggestions::SuggestionSet do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:forked_project) { fork_project(project, nil, repository: true) }
- let_it_be(:merge_request) do
+ let_it_be(:merge_request_same_project) do
create(:merge_request, source_project: project, target_project: project)
end
- let_it_be(:suggestion) { create(:suggestion)}
-
- let_it_be(:suggestion2) do
- create_suggestion('files/ruby/popen.rb', 13, "*** SUGGESTION 2 ***")
- end
-
- let_it_be(:suggestion3) do
- create_suggestion('files/ruby/regex.rb', 22, "*** SUGGESTION 3 ***")
+ let_it_be(:merge_request_from_fork) do
+ create(:merge_request, source_project: forked_project, target_project: project)
end
- let_it_be(:unappliable_suggestion) { create(:suggestion, :unappliable) }
+ where(:merge_request) { [ref(:merge_request_same_project), ref(:merge_request_from_fork)] }
+ with_them do
+ let(:note) { create(:diff_note_on_merge_request, project: project, noteable: merge_request) }
+ let(:suggestion) { create(:suggestion, note: note) }
- let(:suggestion_set) { described_class.new([suggestion]) }
-
- describe '#project' do
- it 'returns the project associated with the suggestions' do
- expected_project = suggestion.project
+ let(:suggestion2) do
+ create_suggestion('files/ruby/popen.rb', 13, "*** SUGGESTION 2 ***")
+ end
- expect(suggestion_set.project).to be(expected_project)
+ let(:suggestion3) do
+ create_suggestion('files/ruby/regex.rb', 22, "*** SUGGESTION 3 ***")
end
- end
- describe '#branch' do
- it 'returns the branch associated with the suggestions' do
- expected_branch = suggestion.branch
+ let(:unappliable_suggestion) { create(:suggestion, :unappliable) }
+
+ let(:suggestion_set) { described_class.new([suggestion]) }
- expect(suggestion_set.branch).to be(expected_branch)
+ describe '#source_project' do
+ it 'returns the source project associated with the suggestions' do
+ expect(suggestion_set.source_project).to be(merge_request.source_project)
+ end
end
- end
- describe '#valid?' do
- it 'returns true if no errors are found' do
- expect(suggestion_set.valid?).to be(true)
+ describe '#target_project' do
+ it 'returns the target project associated with the suggestions' do
+ expect(suggestion_set.target_project).to be(project)
+ end
end
- it 'returns false if an error is found' do
- suggestion_set = described_class.new([unappliable_suggestion])
+ describe '#branch' do
+ it 'returns the branch associated with the suggestions' do
+ expected_branch = suggestion.branch
- expect(suggestion_set.valid?).to be(false)
+ expect(suggestion_set.branch).to be(expected_branch)
+ end
end
- end
- describe '#error_message' do
- it 'returns an error message if an error is found' do
- suggestion_set = described_class.new([unappliable_suggestion])
+ describe '#valid?' do
+ it 'returns true if no errors are found' do
+ expect(suggestion_set.valid?).to be(true)
+ end
- expect(suggestion_set.error_message).to be_a(String)
+ it 'returns false if an error is found' do
+ suggestion_set = described_class.new([unappliable_suggestion])
+
+ expect(suggestion_set.valid?).to be(false)
+ end
end
- it 'returns nil if no errors are found' do
- expect(suggestion_set.error_message).to be(nil)
+ describe '#error_message' do
+ it 'returns an error message if an error is found' do
+ suggestion_set = described_class.new([unappliable_suggestion])
+
+ expect(suggestion_set.error_message).to be_a(String)
+ end
+
+ it 'returns nil if no errors are found' do
+ expect(suggestion_set.error_message).to be(nil)
+ end
end
- end
- describe '#actions' do
- it 'returns an array of hashes with proper key/value pairs' do
- first_action = suggestion_set.actions.first
+ describe '#actions' do
+ it 'returns an array of hashes with proper key/value pairs' do
+ first_action = suggestion_set.actions.first
- file_suggestion = suggestion_set.send(:suggestions_per_file).first
+ file_suggestion = suggestion_set.send(:suggestions_per_file).first
- expect(first_action[:action]).to be('update')
- expect(first_action[:file_path]).to eq(file_suggestion.file_path)
- expect(first_action[:content]).to eq(file_suggestion.new_content)
+ expect(first_action[:action]).to be('update')
+ expect(first_action[:file_path]).to eq(file_suggestion.file_path)
+ expect(first_action[:content]).to eq(file_suggestion.new_content)
+ end
end
- end
- describe '#file_paths' do
- it 'returns an array of unique file paths associated with the suggestions' do
- suggestion_set = described_class.new([suggestion, suggestion2, suggestion3])
+ describe '#file_paths' do
+ it 'returns an array of unique file paths associated with the suggestions' do
+ suggestion_set = described_class.new([suggestion, suggestion2, suggestion3])
- expected_paths = %w(files/ruby/popen.rb files/ruby/regex.rb)
+ expected_paths = %w(files/ruby/popen.rb files/ruby/regex.rb)
- actual_paths = suggestion_set.file_paths
+ actual_paths = suggestion_set.file_paths
- expect(actual_paths.sort).to eq(expected_paths)
+ expect(actual_paths.sort).to eq(expected_paths)
+ end
end
end
end
diff --git a/spec/lib/gitlab/tracking_spec.rb b/spec/lib/gitlab/tracking_spec.rb
index cd83971aef9..cc973be8be9 100644
--- a/spec/lib/gitlab/tracking_spec.rb
+++ b/spec/lib/gitlab/tracking_spec.rb
@@ -149,4 +149,42 @@ RSpec.describe Gitlab::Tracking do
described_class.event(nil, 'some_action')
end
end
+
+ describe '.definition' do
+ let(:namespace) { create(:namespace) }
+
+ let_it_be(:definition_action) { 'definition_action' }
+ let_it_be(:definition_category) { 'definition_category' }
+ let_it_be(:label_description) { 'definition label description' }
+ let_it_be(:test_definition) {{ 'category': definition_category, 'action': definition_action }}
+
+ before do
+ allow_next_instance_of(described_class) do |instance|
+ allow(instance).to receive(:event)
+ end
+ allow_next_instance_of(Gitlab::Tracking::Destinations::Snowplow) do |instance|
+ allow(instance).to receive(:event)
+ end
+ allow(YAML).to receive(:load_file).with(Rails.root.join('config/events/filename.yml')).and_return(test_definition)
+ end
+
+ it 'dispatchs the data to .event' do
+ project = build_stubbed(:project)
+ user = build_stubbed(:user)
+
+ expect(described_class).to receive(:event) do |category, action, args|
+ expect(category).to eq(definition_category)
+ expect(action).to eq(definition_action)
+ expect(args[:label]).to eq('label')
+ expect(args[:property]).to eq('...')
+ expect(args[:project]).to eq(project)
+ expect(args[:user]).to eq(user)
+ expect(args[:namespace]).to eq(namespace)
+ expect(args[:extra_key_1]).to eq('extra value 1')
+ end
+
+ described_class.definition('filename', category: nil, action: nil, label: 'label', property: '...',
+ project: project, user: user, namespace: namespace, extra_key_1: 'extra value 1')
+ end
+ end
end
diff --git a/spec/lib/gitlab/url_sanitizer_spec.rb b/spec/lib/gitlab/url_sanitizer_spec.rb
index aba4ca109a9..0ffbf5f81e7 100644
--- a/spec/lib/gitlab/url_sanitizer_spec.rb
+++ b/spec/lib/gitlab/url_sanitizer_spec.rb
@@ -139,6 +139,23 @@ RSpec.describe Gitlab::UrlSanitizer do
it { is_expected.to eq(credentials) }
end
end
+
+ context 'with mixed credentials' do
+ where(:url, :credentials, :result) do
+ 'http://a@example.com' | { password: 'd' } | { user: 'a', password: 'd' }
+ 'http://a:b@example.com' | { password: 'd' } | { user: 'a', password: 'd' }
+ 'http://:b@example.com' | { password: 'd' } | { user: nil, password: 'd' }
+ 'http://a@example.com' | { user: 'c' } | { user: 'c', password: nil }
+ 'http://a:b@example.com' | { user: 'c' } | { user: 'c', password: 'b' }
+ 'http://a:b@example.com' | { user: '' } | { user: 'a', password: 'b' }
+ end
+
+ with_them do
+ subject { described_class.new(url, credentials: credentials).credentials }
+
+ it { is_expected.to eq(result) }
+ end
+ end
end
describe '#user' do
diff --git a/spec/lib/gitlab/usage/service_ping_report_spec.rb b/spec/lib/gitlab/usage/service_ping_report_spec.rb
index 1f62ddd0bbb..b6119ab52ec 100644
--- a/spec/lib/gitlab/usage/service_ping_report_spec.rb
+++ b/spec/lib/gitlab/usage/service_ping_report_spec.rb
@@ -7,119 +7,86 @@ RSpec.describe Gitlab::Usage::ServicePingReport, :use_clean_rails_memory_store_c
let(:usage_data) { { uuid: "1111", counts: { issue: 0 } } }
- context 'when feature merge_service_ping_instrumented_metrics enabled' do
- before do
- stub_feature_flags(merge_service_ping_instrumented_metrics: true)
+ before do
+ allow_next_instance_of(Gitlab::Usage::ServicePing::PayloadKeysProcessor) do |instance|
+ allow(instance).to receive(:missing_key_paths).and_return([])
+ end
- allow_next_instance_of(Gitlab::Usage::ServicePing::PayloadKeysProcessor) do |instance|
- allow(instance).to receive(:missing_key_paths).and_return([])
- end
+ allow_next_instance_of(Gitlab::Usage::ServicePing::InstrumentedPayload) do |instance|
+ allow(instance).to receive(:build).and_return({})
+ end
+ end
- allow_next_instance_of(Gitlab::Usage::ServicePing::InstrumentedPayload) do |instance|
- allow(instance).to receive(:build).and_return({})
- end
+ context 'all_metrics_values' do
+ it 'generates the service ping when there are no missing values' do
+ expect(Gitlab::UsageData).to receive(:data).and_return(usage_data)
+ expect(described_class.for(output: :all_metrics_values)).to eq({ uuid: "1111", counts: { issue: 0 } })
end
- context 'all_metrics_values' do
- it 'generates the service ping when there are no missing values' do
- expect(Gitlab::UsageData).to receive(:data).and_return(usage_data)
- expect(described_class.for(output: :all_metrics_values)).to eq({ uuid: "1111", counts: { issue: 0 } })
+ it 'generates the service ping with the missing values' do
+ expect_next_instance_of(Gitlab::Usage::ServicePing::PayloadKeysProcessor, usage_data) do |instance|
+ expect(instance).to receive(:missing_instrumented_metrics_key_paths).and_return(['counts.boards'])
end
- it 'generates the service ping with the missing values' do
- expect_next_instance_of(Gitlab::Usage::ServicePing::PayloadKeysProcessor, usage_data) do |instance|
- expect(instance).to receive(:missing_instrumented_metrics_key_paths).and_return(['counts.boards'])
- end
-
- expect_next_instance_of(Gitlab::Usage::ServicePing::InstrumentedPayload, ['counts.boards'], :with_value) do |instance|
- expect(instance).to receive(:build).and_return({ counts: { boards: 1 } })
- end
-
- expect(Gitlab::UsageData).to receive(:data).and_return(usage_data)
- expect(described_class.for(output: :all_metrics_values)).to eq({ uuid: "1111", counts: { issue: 0, boards: 1 } })
+ expect_next_instance_of(Gitlab::Usage::ServicePing::InstrumentedPayload, ['counts.boards'], :with_value) do |instance|
+ expect(instance).to receive(:build).and_return({ counts: { boards: 1 } })
end
- end
-
- context 'for output: :metrics_queries' do
- it 'generates the service ping' do
- expect(Gitlab::UsageData).to receive(:data).and_return(usage_data)
- described_class.for(output: :metrics_queries)
- end
+ expect(Gitlab::UsageData).to receive(:data).and_return(usage_data)
+ expect(described_class.for(output: :all_metrics_values)).to eq({ uuid: "1111", counts: { issue: 0, boards: 1 } })
end
+ end
- context 'for output: :non_sql_metrics_values' do
- it 'generates the service ping' do
- expect(Gitlab::UsageData).to receive(:data).and_return(usage_data)
+ context 'for output: :metrics_queries' do
+ it 'generates the service ping' do
+ expect(Gitlab::UsageData).to receive(:data).and_return(usage_data)
- described_class.for(output: :non_sql_metrics_values)
- end
+ described_class.for(output: :metrics_queries)
end
+ end
- context 'when using cached' do
- context 'for cached: true' do
- let(:new_usage_data) { { uuid: "1112" } }
-
- it 'caches the values' do
- allow(Gitlab::UsageData).to receive(:data).and_return(usage_data, new_usage_data)
+ context 'for output: :non_sql_metrics_values' do
+ it 'generates the service ping' do
+ expect(Gitlab::UsageData).to receive(:data).and_return(usage_data)
- expect(described_class.for(output: :all_metrics_values)).to eq(usage_data)
- expect(described_class.for(output: :all_metrics_values, cached: true)).to eq(usage_data)
+ described_class.for(output: :non_sql_metrics_values)
+ end
+ end
- expect(Rails.cache.fetch('usage_data')).to eq(usage_data)
- end
+ context 'when using cached' do
+ context 'for cached: true' do
+ let(:new_usage_data) { { uuid: "1112" } }
- it 'writes to cache and returns fresh data' do
- allow(Gitlab::UsageData).to receive(:data).and_return(usage_data, new_usage_data)
+ it 'caches the values' do
+ allow(Gitlab::UsageData).to receive(:data).and_return(usage_data, new_usage_data)
- expect(described_class.for(output: :all_metrics_values)).to eq(usage_data)
- expect(described_class.for(output: :all_metrics_values)).to eq(new_usage_data)
- expect(described_class.for(output: :all_metrics_values, cached: true)).to eq(new_usage_data)
+ expect(described_class.for(output: :all_metrics_values)).to eq(usage_data)
+ expect(described_class.for(output: :all_metrics_values, cached: true)).to eq(usage_data)
- expect(Rails.cache.fetch('usage_data')).to eq(new_usage_data)
- end
+ expect(Rails.cache.fetch('usage_data')).to eq(usage_data)
end
- context 'when no caching' do
- let(:new_usage_data) { { uuid: "1112" } }
-
- it 'returns fresh data' do
- allow(Gitlab::UsageData).to receive(:data).and_return(usage_data, new_usage_data)
-
- expect(described_class.for(output: :all_metrics_values)).to eq(usage_data)
- expect(described_class.for(output: :all_metrics_values)).to eq(new_usage_data)
-
- expect(Rails.cache.fetch('usage_data')).to eq(new_usage_data)
- end
- end
- end
- end
+ it 'writes to cache and returns fresh data' do
+ allow(Gitlab::UsageData).to receive(:data).and_return(usage_data, new_usage_data)
- context 'when feature merge_service_ping_instrumented_metrics disabled' do
- before do
- stub_feature_flags(merge_service_ping_instrumented_metrics: false)
- end
+ expect(described_class.for(output: :all_metrics_values)).to eq(usage_data)
+ expect(described_class.for(output: :all_metrics_values)).to eq(new_usage_data)
+ expect(described_class.for(output: :all_metrics_values, cached: true)).to eq(new_usage_data)
- context 'all_metrics_values' do
- it 'generates the service ping when there are no missing values' do
- expect(Gitlab::UsageData).to receive(:data).and_return(usage_data)
- expect(described_class.for(output: :all_metrics_values)).to eq({ uuid: "1111", counts: { issue: 0 } })
+ expect(Rails.cache.fetch('usage_data')).to eq(new_usage_data)
end
end
- context 'for output: :metrics_queries' do
- it 'generates the service ping' do
- expect(Gitlab::UsageData).to receive(:data).and_return(usage_data)
+ context 'when no caching' do
+ let(:new_usage_data) { { uuid: "1112" } }
- described_class.for(output: :metrics_queries)
- end
- end
+ it 'returns fresh data' do
+ allow(Gitlab::UsageData).to receive(:data).and_return(usage_data, new_usage_data)
- context 'for output: :non_sql_metrics_values' do
- it 'generates the service ping' do
- expect(Gitlab::UsageData).to receive(:data).and_return(usage_data)
+ expect(described_class.for(output: :all_metrics_values)).to eq(usage_data)
+ expect(described_class.for(output: :all_metrics_values)).to eq(new_usage_data)
- described_class.for(output: :non_sql_metrics_values)
+ expect(Rails.cache.fetch('usage_data')).to eq(new_usage_data)
end
end
end
diff --git a/spec/lib/gitlab/usage_data_counters/ci_template_unique_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/ci_template_unique_counter_spec.rb
index 222198a58ac..6a37bfd106d 100644
--- a/spec/lib/gitlab/usage_data_counters/ci_template_unique_counter_spec.rb
+++ b/spec/lib/gitlab/usage_data_counters/ci_template_unique_counter_spec.rb
@@ -5,30 +5,52 @@ require 'spec_helper'
RSpec.describe Gitlab::UsageDataCounters::CiTemplateUniqueCounter do
describe '.track_unique_project_event' do
using RSpec::Parameterized::TableSyntax
+ include SnowplowHelpers
- let(:project_id) { 1 }
+ let(:project) { build(:project) }
+ let(:user) { build(:user) }
shared_examples 'tracks template' do
+ let(:subject) { described_class.track_unique_project_event(project: project, template: template_path, config_source: config_source, user: user) }
+
it "has an event defined for template" do
expect do
- described_class.track_unique_project_event(
- project_id: project_id,
- template: template_path,
- config_source: config_source
- )
+ subject
end.not_to raise_error
end
it "tracks template" do
expanded_template_name = described_class.expand_template_name(template_path)
expected_template_event_name = described_class.ci_template_event_name(expanded_template_name, config_source)
- expect(Gitlab::UsageDataCounters::HLLRedisCounter).to(receive(:track_event)).with(expected_template_event_name, values: project_id)
+ expect(Gitlab::UsageDataCounters::HLLRedisCounter).to(receive(:track_event)).with(expected_template_event_name, values: project.id)
+
+ subject
+ end
+
+ context 'Snowplow' do
+ it 'event is not tracked if FF is disabled' do
+ stub_feature_flags(route_hll_to_snowplow: false)
+
+ subject
- described_class.track_unique_project_event(project_id: project_id, template: template_path, config_source: config_source)
+ expect_no_snowplow_event
+ end
+
+ it 'tracks event' do
+ subject
+
+ expect_snowplow_event(
+ category: described_class.to_s,
+ action: 'ci_templates_unique',
+ namespace: project.namespace,
+ user: user,
+ project: project
+ )
+ end
end
end
- context 'with explicit includes' do
+ context 'with explicit includes', :snowplow do
let(:config_source) { :repository_source }
(described_class.ci_templates - ['Verify/Browser-Performance.latest.gitlab-ci.yml', 'Verify/Browser-Performance.gitlab-ci.yml']).each do |template|
@@ -40,7 +62,7 @@ RSpec.describe Gitlab::UsageDataCounters::CiTemplateUniqueCounter do
end
end
- context 'with implicit includes' do
+ context 'with implicit includes', :snowplow do
let(:config_source) { :auto_devops_source }
[
@@ -60,7 +82,7 @@ RSpec.describe Gitlab::UsageDataCounters::CiTemplateUniqueCounter do
it 'expands short template names' do
expect do
- described_class.track_unique_project_event(project_id: 1, template: 'Dependency-Scanning.gitlab-ci.yml', config_source: :repository_source)
+ described_class.track_unique_project_event(project: project, template: 'Dependency-Scanning.gitlab-ci.yml', config_source: :repository_source, user: user)
end.not_to raise_error
end
end
diff --git a/spec/lib/gitlab/usage_data_counters/gitlab_cli_activity_unique_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/gitlab_cli_activity_unique_counter_spec.rb
new file mode 100644
index 00000000000..d6eb67e5c35
--- /dev/null
+++ b/spec/lib/gitlab/usage_data_counters/gitlab_cli_activity_unique_counter_spec.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::UsageDataCounters::GitLabCliActivityUniqueCounter, :clean_gitlab_redis_shared_state do # rubocop:disable RSpec/FilePath
+ let(:user1) { build(:user, id: 1) }
+ let(:user2) { build(:user, id: 2) }
+ let(:time) { Time.current }
+ let(:action) { described_class::GITLAB_CLI_API_REQUEST_ACTION }
+ let(:user_agent) { { user_agent: 'GLab - GitLab CLI' } }
+
+ context 'when tracking a gitlab cli request' do
+ it_behaves_like 'a request from an extension'
+ end
+end
diff --git a/spec/lib/gitlab/usage_data_queries_spec.rb b/spec/lib/gitlab/usage_data_queries_spec.rb
index c3ac9d7db90..88322e1b971 100644
--- a/spec/lib/gitlab/usage_data_queries_spec.rb
+++ b/spec/lib/gitlab/usage_data_queries_spec.rb
@@ -34,14 +34,14 @@ RSpec.describe Gitlab::UsageDataQueries do
describe '.redis_usage_data' do
subject(:redis_usage_data) { described_class.redis_usage_data { 42 } }
- it 'returns a class for redis_usage_data with a counter call' do
+ it 'returns a stringified class for redis_usage_data with a counter call' do
expect(described_class.redis_usage_data(Gitlab::UsageDataCounters::WikiPageCounter))
- .to eq(redis_usage_data_counter: Gitlab::UsageDataCounters::WikiPageCounter)
+ .to eq(redis_usage_data_counter: "Gitlab::UsageDataCounters::WikiPageCounter")
end
- it 'returns a stringified block for redis_usage_data with a block' do
+ it 'returns a placeholder string for redis_usage_data with a block' do
is_expected.to include(:redis_usage_data_block)
- expect(redis_usage_data[:redis_usage_data_block]).to start_with('#<Proc:')
+ expect(redis_usage_data[:redis_usage_data_block]).to eq('non-SQL usage data block')
end
end
@@ -53,8 +53,8 @@ RSpec.describe Gitlab::UsageDataQueries do
.to eq(alt_usage_data_value: 1)
end
- it 'returns a stringified block for alt_usage_data with a block' do
- expect(alt_usage_data[:alt_usage_data_block]).to start_with('#<Proc:')
+ it 'returns a placeholder string for alt_usage_data with a block' do
+ expect(alt_usage_data[:alt_usage_data_block]).to eq('non-SQL usage data block')
end
end
diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb
index 958df7baf72..8a919a0a72e 100644
--- a/spec/lib/gitlab/usage_data_spec.rb
+++ b/spec/lib/gitlab/usage_data_spec.rb
@@ -166,7 +166,6 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
expect(described_class.usage_activity_by_stage_create({})).to include(
deploy_keys: 2,
keys: 2,
- merge_requests: 2,
projects_with_disable_overriding_approvers_per_merge_request: 2,
projects_without_disable_overriding_approvers_per_merge_request: 6,
remote_mirrors: 2,
@@ -175,7 +174,6 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
expect(described_class.usage_activity_by_stage_create(described_class.monthly_time_range_db_params)).to include(
deploy_keys: 1,
keys: 1,
- merge_requests: 1,
projects_with_disable_overriding_approvers_per_merge_request: 1,
projects_without_disable_overriding_approvers_per_merge_request: 3,
remote_mirrors: 1,
@@ -507,10 +505,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
end
it 'gathers usage counts', :aggregate_failures do
- stub_feature_flags(merge_service_ping_instrumented_metrics: false)
-
count_data = subject[:counts]
- expect(count_data[:boards]).to eq(1)
expect(count_data[:projects]).to eq(4)
expect(count_data.keys).to include(*UsageDataHelpers::COUNTS_KEYS)
expect(UsageDataHelpers::COUNTS_KEYS - count_data.keys).to be_empty
diff --git a/spec/lib/gitlab/utils/delegator_override/validator_spec.rb b/spec/lib/gitlab/utils/delegator_override/validator_spec.rb
index 4cd1b18de82..a58bc65c708 100644
--- a/spec/lib/gitlab/utils/delegator_override/validator_spec.rb
+++ b/spec/lib/gitlab/utils/delegator_override/validator_spec.rb
@@ -47,6 +47,15 @@ RSpec.describe Gitlab::Utils::DelegatorOverride::Validator do
expect(validator.target_classes).to contain_exactly(target_class)
end
+
+ it 'adds all descendants of the target' do
+ child_class1 = Class.new(target_class)
+ child_class2 = Class.new(target_class)
+ grandchild_class = Class.new(child_class2)
+ validator.add_target(target_class)
+
+ expect(validator.target_classes).to contain_exactly(target_class, child_class1, child_class2, grandchild_class)
+ end
end
describe '#expand_on_ancestors' do
diff --git a/spec/lib/gitlab/view/presenter/base_spec.rb b/spec/lib/gitlab/view/presenter/base_spec.rb
index a7083bd2722..afb44c0d298 100644
--- a/spec/lib/gitlab/view/presenter/base_spec.rb
+++ b/spec/lib/gitlab/view/presenter/base_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
RSpec.describe Gitlab::View::Presenter::Base do
let(:project) { double(:project) }
let(:presenter_class) do
- Struct.new(:subject).include(described_class)
+ Struct.new(:__subject__).include(described_class)
end
describe '.presenter?' do
@@ -17,17 +17,24 @@ RSpec.describe Gitlab::View::Presenter::Base do
end
describe '.presents' do
- it 'exposes #subject with the given keyword' do
- presenter_class.presents(Object, as: :foo)
- presenter = presenter_class.new(project)
-
- expect(presenter.foo).to eq(project)
- end
-
it 'raises an error when symbol is passed' do
expect { presenter_class.presents(:foo) }.to raise_error(ArgumentError)
end
+ context 'when the presenter class specifies a custom keyword' do
+ subject(:presenter) { presenter_class.new(project) }
+
+ before do
+ presenter_class.class_eval do
+ presents Object, as: :foo
+ end
+ end
+
+ it 'exposes the subject with the given keyword' do
+ expect(presenter.foo).to be(project)
+ end
+ end
+
context 'when the presenter class inherits Presenter::Delegated' do
let(:presenter_class) do
Class.new(::Gitlab::View::Presenter::Delegated) do
@@ -50,13 +57,22 @@ RSpec.describe Gitlab::View::Presenter::Base do
end
it 'does not set the delegator target' do
- expect(presenter_class).not_to receive(:delegator_target).with(Object)
+ expect(presenter_class).not_to receive(:delegator_target)
presenter_class.presents(Object, as: :foo)
end
end
end
+ describe '#__subject__' do
+ it 'returns the subject' do
+ subject = double
+ presenter = presenter_class.new(subject)
+
+ expect(presenter.__subject__).to be(subject)
+ end
+ end
+
describe '#can?' do
context 'user is not allowed' do
it 'returns false' do
diff --git a/spec/lib/gitlab/web_ide/config/entry/terminal_spec.rb b/spec/lib/gitlab/web_ide/config/entry/terminal_spec.rb
index f8c4a28ed45..7d96adf95e8 100644
--- a/spec/lib/gitlab/web_ide/config/entry/terminal_spec.rb
+++ b/spec/lib/gitlab/web_ide/config/entry/terminal_spec.rb
@@ -132,7 +132,7 @@ RSpec.describe Gitlab::WebIde::Config::Entry::Terminal do
{ before_script: %w[ls pwd],
script: 'sleep 100',
tags: ['webide'],
- image: 'ruby:3.0',
+ image: 'image:1.0',
services: ['mysql'],
variables: { KEY: 'value' } }
end
@@ -143,7 +143,7 @@ RSpec.describe Gitlab::WebIde::Config::Entry::Terminal do
tag_list: ['webide'],
job_variables: [{ key: 'KEY', value: 'value', public: true }],
options: {
- image: { name: "ruby:3.0" },
+ image: { name: "image:1.0" },
services: [{ name: "mysql" }],
before_script: %w[ls pwd],
script: ['sleep 100']
diff --git a/spec/lib/gitlab/web_ide/config_spec.rb b/spec/lib/gitlab/web_ide/config_spec.rb
index 7ee9d40410c..11ea6150719 100644
--- a/spec/lib/gitlab/web_ide/config_spec.rb
+++ b/spec/lib/gitlab/web_ide/config_spec.rb
@@ -11,7 +11,7 @@ RSpec.describe Gitlab::WebIde::Config do
let(:yml) do
<<-EOS
terminal:
- image: ruby:2.7
+ image: image:1.0
before_script:
- gem install rspec
EOS
@@ -21,7 +21,7 @@ RSpec.describe Gitlab::WebIde::Config do
it 'returns hash created from string' do
hash = {
terminal: {
- image: 'ruby:2.7',
+ image: 'image:1.0',
before_script: ['gem install rspec']
}
}
diff --git a/spec/lib/gitlab/workhorse_spec.rb b/spec/lib/gitlab/workhorse_spec.rb
index 3bab9aec454..91ab0a53c6c 100644
--- a/spec/lib/gitlab/workhorse_spec.rb
+++ b/spec/lib/gitlab/workhorse_spec.rb
@@ -448,6 +448,14 @@ RSpec.describe Gitlab::Workhorse do
end
end
+ describe '.detect_content_type' do
+ subject { described_class.detect_content_type }
+
+ it 'returns array setting detect content type in workhorse' do
+ expect(subject).to eq(%w[Gitlab-Workhorse-Detect-Content-Type true])
+ end
+ end
+
describe '.send_git_blob' do
include FakeBlobHelpers
diff --git a/spec/lib/mattermost/session_spec.rb b/spec/lib/mattermost/session_spec.rb
index e2e1b4c28c7..2158076e4b5 100644
--- a/spec/lib/mattermost/session_spec.rb
+++ b/spec/lib/mattermost/session_spec.rb
@@ -35,6 +35,12 @@ RSpec.describe Mattermost::Session, type: :request do
it 'makes a request to the oauth uri' do
expect { subject.with_session }.to raise_error(::Mattermost::NoSessionError)
end
+
+ it 'returns nill on calling a non exisitng method on request' do
+ return_value = subject.request.method_missing("non_existing_method", "something") do
+ end
+ expect(return_value).to be(nil)
+ end
end
context 'with oauth_uri' do
diff --git a/spec/lib/prometheus/cleanup_multiproc_dir_service_spec.rb b/spec/lib/prometheus/cleanup_multiproc_dir_service_spec.rb
index db77a5d42d8..bdf9673a53f 100644
--- a/spec/lib/prometheus/cleanup_multiproc_dir_service_spec.rb
+++ b/spec/lib/prometheus/cleanup_multiproc_dir_service_spec.rb
@@ -1,32 +1,27 @@
# frozen_string_literal: true
-require 'spec_helper'
+require 'fast_spec_helper'
RSpec.describe Prometheus::CleanupMultiprocDirService do
- describe '.call' do
- subject { described_class.new.execute }
-
+ describe '#execute' do
let(:metrics_multiproc_dir) { Dir.mktmpdir }
let(:metrics_file_path) { File.join(metrics_multiproc_dir, 'counter_puma_master-0.db') }
+ subject(:service) { described_class.new(metrics_dir_arg).execute }
+
before do
FileUtils.touch(metrics_file_path)
end
after do
- FileUtils.rm_r(metrics_multiproc_dir)
+ FileUtils.rm_rf(metrics_multiproc_dir)
end
context 'when `multiprocess_files_dir` is defined' do
- before do
- expect(Prometheus::Client.configuration)
- .to receive(:multiprocess_files_dir)
- .and_return(metrics_multiproc_dir)
- .at_least(:once)
- end
+ let(:metrics_dir_arg) { metrics_multiproc_dir }
it 'removes old metrics' do
- expect { subject }
+ expect { service }
.to change { File.exist?(metrics_file_path) }
.from(true)
.to(false)
@@ -34,15 +29,10 @@ RSpec.describe Prometheus::CleanupMultiprocDirService do
end
context 'when `multiprocess_files_dir` is not defined' do
- before do
- expect(Prometheus::Client.configuration)
- .to receive(:multiprocess_files_dir)
- .and_return(nil)
- .at_least(:once)
- end
+ let(:metrics_dir_arg) { nil }
it 'does not remove any files' do
- expect { subject }
+ expect { service }
.not_to change { File.exist?(metrics_file_path) }
.from(true)
end
diff --git a/spec/lib/sidebars/groups/menus/group_information_menu_spec.rb b/spec/lib/sidebars/groups/menus/group_information_menu_spec.rb
index b68af6fb8ab..5f67ee11970 100644
--- a/spec/lib/sidebars/groups/menus/group_information_menu_spec.rb
+++ b/spec/lib/sidebars/groups/menus/group_information_menu_spec.rb
@@ -28,6 +28,20 @@ RSpec.describe Sidebars::Groups::Menus::GroupInformationMenu do
end
end
+ describe '#sprite_icon' do
+ subject { described_class.new(context).sprite_icon }
+
+ context 'when group is a root group' do
+ specify { is_expected.to eq 'group'}
+ end
+
+ context 'when group is a child group' do
+ let(:group) { build(:group, parent: root_group) }
+
+ specify { is_expected.to eq 'subgroup'}
+ end
+ end
+
describe 'Menu Items' do
subject { described_class.new(context).renderable_items.index { |e| e.item_id == item_id } }
diff --git a/spec/lib/sidebars/projects/menus/infrastructure_menu_spec.rb b/spec/lib/sidebars/projects/menus/infrastructure_menu_spec.rb
index 8a6b0e4e95d..81114f5a0b3 100644
--- a/spec/lib/sidebars/projects/menus/infrastructure_menu_spec.rb
+++ b/spec/lib/sidebars/projects/menus/infrastructure_menu_spec.rb
@@ -112,6 +112,38 @@ RSpec.describe Sidebars::Projects::Menus::InfrastructureMenu do
let(:item_id) { :google_cloud }
it_behaves_like 'access rights checks'
+
+ context 'when feature flag is turned off globally' do
+ before do
+ stub_feature_flags(incubation_5mp_google_cloud: false)
+ end
+
+ it { is_expected.to be_nil }
+
+ context 'when feature flag is enabled for specific project' do
+ before do
+ stub_feature_flags(incubation_5mp_google_cloud: project)
+ end
+
+ it_behaves_like 'access rights checks'
+ end
+
+ context 'when feature flag is enabled for specific group' do
+ before do
+ stub_feature_flags(incubation_5mp_google_cloud: project.group)
+ end
+
+ it_behaves_like 'access rights checks'
+ end
+
+ context 'when feature flag is enabled for specific project' do
+ before do
+ stub_feature_flags(incubation_5mp_google_cloud: user)
+ end
+
+ it_behaves_like 'access rights checks'
+ end
+ end
end
end
end
diff --git a/spec/lib/sidebars/projects/panel_spec.rb b/spec/lib/sidebars/projects/panel_spec.rb
index 7e69a2dfe52..ff253eedd08 100644
--- a/spec/lib/sidebars/projects/panel_spec.rb
+++ b/spec/lib/sidebars/projects/panel_spec.rb
@@ -17,16 +17,40 @@ RSpec.describe Sidebars::Projects::Panel do
subject { described_class.new(context).instance_variable_get(:@menus) }
context 'when integration is present and active' do
- let_it_be(:confluence) { create(:confluence_integration, active: true) }
+ context 'confluence only' do
+ let_it_be(:confluence) { create(:confluence_integration, active: true) }
- let(:project) { confluence.project }
+ let(:project) { confluence.project }
- it 'contains Confluence menu item' do
- expect(subject.index { |i| i.is_a?(Sidebars::Projects::Menus::ConfluenceMenu) }).not_to be_nil
+ it 'contains Confluence menu item' do
+ expect(subject.index { |i| i.is_a?(Sidebars::Projects::Menus::ConfluenceMenu) }).not_to be_nil
+ end
+
+ it 'does not contain Wiki menu item' do
+ expect(subject.index { |i| i.is_a?(Sidebars::Projects::Menus::WikiMenu) }).to be_nil
+ end
end
- it 'does not contain Wiki menu item' do
- expect(subject.index { |i| i.is_a?(Sidebars::Projects::Menus::WikiMenu) }).to be_nil
+ context 'shimo only' do
+ let_it_be(:shimo) { create(:shimo_integration, active: true) }
+
+ let(:project) { shimo.project }
+
+ it 'contains Shimo menu item' do
+ expect(subject.index { |i| i.is_a?(Sidebars::Projects::Menus::ShimoMenu) }).not_to be_nil
+ end
+ end
+
+ context 'confluence & shimo' do
+ let_it_be(:confluence) { create(:confluence_integration, active: true) }
+ let_it_be(:shimo) { create(:shimo_integration, active: true) }
+
+ let(:project) { confluence.project }
+
+ it 'contains Confluence menu item, not Shimo' do
+ expect(subject.index { |i| i.is_a?(Sidebars::Projects::Menus::ConfluenceMenu) }).not_to be_nil
+ expect(subject.index { |i| i.is_a?(Sidebars::Projects::Menus::ShimoMenu) }).to be_nil
+ end
end
end
diff --git a/spec/lib/system_check/app/git_user_default_ssh_config_check_spec.rb b/spec/lib/system_check/app/git_user_default_ssh_config_check_spec.rb
index 2c996635c36..7c9fbe152cc 100644
--- a/spec/lib/system_check/app/git_user_default_ssh_config_check_spec.rb
+++ b/spec/lib/system_check/app/git_user_default_ssh_config_check_spec.rb
@@ -71,7 +71,31 @@ RSpec.describe SystemCheck::App::GitUserDefaultSSHConfigCheck do
end
end
+ describe '#show_error' do
+ subject(:show_error) { described_class.new.show_error }
+
+ before do
+ stub_user
+ stub_home_dir
+ stub_ssh_file(forbidden_file)
+ end
+
+ it 'outputs error information' do
+ expected = %r{
+ Try\ fixing\ it:\s+
+ mkdir\ ~/gitlab-check-backup-(.+)\s+
+ sudo\ mv\ (.+)\s+
+ For\ more\ information\ see:\s+
+ doc/user/ssh\.md\#overriding-ssh-settings-on-the-gitlab-server\s+
+ Please\ fix\ the\ error\ above\ and\ rerun\ the\ checks
+ }x
+
+ expect { show_error }.to output(expected).to_stdout
+ end
+ end
+
def stub_user
+ allow(File).to receive(:expand_path).and_call_original
allow(File).to receive(:expand_path).with("~#{username}").and_return(home_dir)
end
diff --git a/spec/mailers/emails/in_product_marketing_spec.rb b/spec/mailers/emails/in_product_marketing_spec.rb
index 720e6f101a8..e62719f4283 100644
--- a/spec/mailers/emails/in_product_marketing_spec.rb
+++ b/spec/mailers/emails/in_product_marketing_spec.rb
@@ -69,7 +69,6 @@ RSpec.describe Emails::InProductMarketing do
:team_short | 0
:trial_short | 0
:admin_verify | 0
- :invite_team | 0
end
with_them do
@@ -99,11 +98,7 @@ RSpec.describe Emails::InProductMarketing do
is_expected.not_to have_body_text(CGI.unescapeHTML(message.invite_link))
end
- if track == :invite_team
- is_expected.not_to have_body_text(/This is email \d of \d/)
- else
- is_expected.to have_body_text(message.progress)
- end
+ is_expected.to have_body_text(message.progress)
end
end
end
diff --git a/spec/mailers/emails/profile_spec.rb b/spec/mailers/emails/profile_spec.rb
index 87776457473..f4483f7e8f5 100644
--- a/spec/mailers/emails/profile_spec.rb
+++ b/spec/mailers/emails/profile_spec.rb
@@ -416,4 +416,27 @@ RSpec.describe Emails::Profile do
is_expected.to have_body_text /#{profile_two_factor_auth_path}/
end
end
+
+ describe 'added a new email address' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:email) { create(:email, user: user) }
+
+ subject { Notify.new_email_address_added_email(user, email) }
+
+ it_behaves_like 'an email sent from GitLab'
+ it_behaves_like 'it should not have Gmail Actions links'
+ it_behaves_like 'a user cannot unsubscribe through footer link'
+
+ it 'is sent to the user' do
+ is_expected.to deliver_to user.email
+ end
+
+ it 'has the correct subject' do
+ is_expected.to have_subject /^New email address added$/i
+ end
+
+ it 'includes a link to the email address page' do
+ is_expected.to have_body_text /#{profile_emails_path}/
+ end
+ end
end
diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb
index 978118ed1b1..b6ad66f41b5 100644
--- a/spec/mailers/notify_spec.rb
+++ b/spec/mailers/notify_spec.rb
@@ -317,6 +317,9 @@ RSpec.describe Notify do
end
context 'for merge requests' do
+ let(:push_user) { create(:user) }
+ let(:commit_limit) { NotificationService::NEW_COMMIT_EMAIL_DISPLAY_LIMIT }
+
describe 'that are new' do
subject { described_class.new_merge_request_email(merge_request.assignee_ids.first, merge_request.id) }
@@ -457,12 +460,6 @@ RSpec.describe Notify do
end
shared_examples 'a push to an existing merge request' do
- let(:push_user) { create(:user) }
-
- subject do
- described_class.push_to_merge_request_email(recipient.id, merge_request.id, push_user.id, new_commits: merge_request.commits, existing_commits: existing_commits)
- end
-
it_behaves_like 'a multiple recipients email'
it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
let(:model) { merge_request }
@@ -487,16 +484,136 @@ RSpec.describe Notify do
end
end
- describe 'that have new commits' do
- let(:existing_commits) { [] }
+ shared_examples 'shows the compare url between first and last commits' do |count|
+ it 'shows the compare url between first and last commits' do
+ commit_id_1 = existing_commits.first[:short_id]
+ commit_id_2 = existing_commits.last[:short_id]
+
+ is_expected.to have_link("#{commit_id_1}...#{commit_id_2}", href: project_compare_url(project, from: commit_id_1, to: commit_id_2))
+ is_expected.to have_body_text("#{count} commits from branch `#{merge_request.target_branch}`")
+ end
+ end
+
+ shared_examples 'shows new commit urls' do |count|
+ it 'shows new commit urls' do
+ displayed_new_commits.each do |commit|
+ is_expected.to have_link(commit[:short_id], href: project_commit_url(project, commit[:short_id]))
+ is_expected.to have_body_text(commit[:title])
+ end
+ end
+
+ it 'does not show hidden new commit urls' do
+ hidden_new_commits.each do |commit|
+ is_expected.not_to have_link(commit[:short_id], href: project_commit_url(project, commit[:short_id]))
+ is_expected.not_to have_body_text(commit[:title])
+ end
+ end
+ end
+
+ describe 'that have no new commits' do
+ subject do
+ described_class.push_to_merge_request_email(recipient.id, merge_request.id, push_user.id, new_commits: [], total_new_commits_count: 0, existing_commits: [], total_existing_commits_count: 0)
+ end
it_behaves_like 'a push to an existing merge request'
end
+ describe 'that have fewer than the commit truncation limit' do
+ let(:new_commits) { merge_request.commits }
+ let(:displayed_new_commits) { new_commits }
+ let(:hidden_new_commits) { [] }
+
+ subject do
+ described_class.push_to_merge_request_email(
+ recipient.id, merge_request.id, push_user.id,
+ new_commits: new_commits, total_new_commits_count: new_commits.length,
+ existing_commits: [], total_existing_commits_count: 0
+ )
+ end
+
+ it_behaves_like 'a push to an existing merge request'
+ it_behaves_like 'shows new commit urls'
+ end
+
+ describe 'that have more than the commit truncation limit' do
+ let(:new_commits) do
+ Array.new(commit_limit + 10) do |i|
+ {
+ short_id: SecureRandom.hex(4),
+ title: "This is commit #{i}"
+ }
+ end
+ end
+
+ let(:displayed_new_commits) { new_commits.first(commit_limit) }
+ let(:hidden_new_commits) { new_commits.last(10) }
+
+ subject do
+ described_class.push_to_merge_request_email(
+ recipient.id, merge_request.id, push_user.id,
+ new_commits: displayed_new_commits, total_new_commits_count: commit_limit + 10,
+ existing_commits: [], total_existing_commits_count: 0
+ )
+ end
+
+ it_behaves_like 'a push to an existing merge request'
+ it_behaves_like 'shows new commit urls'
+
+ it 'shows "and more" message' do
+ is_expected.to have_body_text("And 10 more")
+ end
+ end
+
describe 'that have new commits on top of an existing one' do
let(:existing_commits) { [merge_request.commits.first] }
+ subject do
+ described_class.push_to_merge_request_email(
+ recipient.id, merge_request.id, push_user.id,
+ new_commits: merge_request.commits, total_new_commits_count: merge_request.commits.length,
+ existing_commits: existing_commits, total_existing_commits_count: existing_commits.length
+ )
+ end
+
+ it_behaves_like 'a push to an existing merge request'
+
+ it 'shows the existing commit' do
+ commit_id = existing_commits.first.short_id
+ is_expected.to have_link(commit_id, href: project_commit_url(project, commit_id))
+ is_expected.to have_body_text("1 commit from branch `#{merge_request.target_branch}`")
+ end
+ end
+
+ describe 'that have new commits on top of two existing ones' do
+ let(:existing_commits) { [merge_request.commits.first, merge_request.commits.second] }
+
+ subject do
+ described_class.push_to_merge_request_email(
+ recipient.id, merge_request.id, push_user.id,
+ new_commits: merge_request.commits, total_new_commits_count: merge_request.commits.length,
+ existing_commits: existing_commits, total_existing_commits_count: existing_commits.length
+ )
+ end
+
+ it_behaves_like 'a push to an existing merge request'
+ it_behaves_like 'shows the compare url between first and last commits', 2
+ end
+
+ describe 'that have new commits on top of more than two existing ones' do
+ let(:existing_commits) do
+ [merge_request.commits.first] + [double(:commit)] * 3 + [merge_request.commits.second]
+ end
+
+ subject do
+ described_class.push_to_merge_request_email(
+ recipient.id, merge_request.id, push_user.id,
+ new_commits: merge_request.commits, total_new_commits_count: merge_request.commits.length,
+ existing_commits: existing_commits, total_existing_commits_count: existing_commits.length
+ )
+ end
+
it_behaves_like 'a push to an existing merge request'
+ it_behaves_like 'shows the compare url between first and last commits', 5
end
end
@@ -2064,14 +2181,46 @@ RSpec.describe Notify do
context 'when diff note' do
let!(:notes) { create_list(:diff_note_on_merge_request, 3, review: review, project: project, author: review.author, noteable: merge_request) }
- it 'links to notes' do
+ it 'links to notes and discussions', :aggregate_failures do
+ reply_note = create(:diff_note_on_merge_request, review: review, project: project, author: review.author, noteable: merge_request, in_reply_to: notes.first)
+
review.notes.each do |note|
# Text part
expect(subject.text_part.body.raw_source).to include(
project_merge_request_url(project, merge_request, anchor: "note_#{note.id}")
)
+
+ if note == reply_note
+ expect(subject.text_part.body.raw_source).to include("commented on a discussion on #{note.discussion.file_path}")
+ else
+ expect(subject.text_part.body.raw_source).to include("started a new discussion on #{note.discussion.file_path}")
+ end
end
end
+
+ it 'includes only one link to the highlighted_diff_email' do
+ expect(subject.html_part.body.raw_source).to include('assets/mailers/highlighted_diff_email').once
+ end
+
+ it 'avoids N+1 cached queries when rendering html', :use_sql_query_cache, :request_store do
+ control_count = ActiveRecord::QueryRecorder.new(query_recorder_debug: true, skip_cached: false) do
+ subject.html_part
+ end
+
+ create_list(:diff_note_on_merge_request, 3, review: review, project: project, author: review.author, noteable: merge_request)
+
+ expect { described_class.new_review_email(recipient.id, review.id).html_part }.not_to exceed_all_query_limit(control_count)
+ end
+
+ it 'avoids N+1 cached queries when rendering text', :use_sql_query_cache, :request_store do
+ control_count = ActiveRecord::QueryRecorder.new(query_recorder_debug: true, skip_cached: false) do
+ subject.text_part
+ end
+
+ create_list(:diff_note_on_merge_request, 3, review: review, project: project, author: review.author, noteable: merge_request)
+
+ expect { described_class.new_review_email(recipient.id, review.id).text_part }.not_to exceed_all_query_limit(control_count)
+ end
end
it 'contains review author name' do
diff --git a/spec/migrations/20210430134202_copy_adoption_snapshot_namespace_spec.rb b/spec/migrations/20210430134202_copy_adoption_snapshot_namespace_spec.rb
index 598da495195..ed18820ec8d 100644
--- a/spec/migrations/20210430134202_copy_adoption_snapshot_namespace_spec.rb
+++ b/spec/migrations/20210430134202_copy_adoption_snapshot_namespace_spec.rb
@@ -36,7 +36,6 @@ RSpec.describe CopyAdoptionSnapshotNamespace, :migration, schema: 20210430124630
runner_configured: true,
pipeline_succeeded: true,
deploy_succeeded: true,
- security_scan_succeeded: true,
end_time: Time.zone.now.end_of_month
}
diff --git a/spec/migrations/20211214012507_backfill_incident_issue_escalation_statuses_spec.rb b/spec/migrations/20211214012507_backfill_incident_issue_escalation_statuses_spec.rb
index a17fee6bab2..791c0595f0e 100644
--- a/spec/migrations/20211214012507_backfill_incident_issue_escalation_statuses_spec.rb
+++ b/spec/migrations/20211214012507_backfill_incident_issue_escalation_statuses_spec.rb
@@ -10,27 +10,10 @@ RSpec.describe BackfillIncidentIssueEscalationStatuses do
let(:namespace) { namespaces.create!(name: 'foo', path: 'foo') }
let(:project) { projects.create!(namespace_id: namespace.id) }
- before do
- stub_const("#{described_class.name}::BATCH_SIZE", 1)
- end
-
- it 'schedules jobs for incident issues' do
- issue_1 = issues.create!(project_id: project.id) # non-incident issue
- incident_1 = issues.create!(project_id: project.id, issue_type: 1)
- incident_2 = issues.create!(project_id: project.id, issue_type: 1)
-
- Sidekiq::Testing.fake! do
- freeze_time do
- migrate!
+ # Backfill removed - see db/migrate/20220321234317_remove_all_issuable_escalation_statuses.rb.
+ it 'does nothing' do
+ issues.create!(project_id: project.id, issue_type: 1)
- expect(described_class::MIGRATION).to be_scheduled_delayed_migration(
- 2.minutes, issue_1.id, issue_1.id)
- expect(described_class::MIGRATION).to be_scheduled_delayed_migration(
- 4.minutes, incident_1.id, incident_1.id)
- expect(described_class::MIGRATION).to be_scheduled_delayed_migration(
- 6.minutes, incident_2.id, incident_2.id)
- expect(BackgroundMigrationWorker.jobs.size).to eq(3)
- end
- end
+ expect { migrate! }.not_to change { BackgroundMigrationWorker.jobs.size }
end
end
diff --git a/spec/migrations/20220204095121_backfill_namespace_statistics_with_dependency_proxy_size_spec.rb b/spec/migrations/20220204095121_backfill_namespace_statistics_with_dependency_proxy_size_spec.rb
new file mode 100644
index 00000000000..39398fa058d
--- /dev/null
+++ b/spec/migrations/20220204095121_backfill_namespace_statistics_with_dependency_proxy_size_spec.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe BackfillNamespaceStatisticsWithDependencyProxySize do
+ let_it_be(:groups) { table(:namespaces) }
+ let_it_be(:group1) { groups.create!(id: 10, name: 'test1', path: 'test1', type: 'Group') }
+ let_it_be(:group2) { groups.create!(id: 20, name: 'test2', path: 'test2', type: 'Group') }
+ let_it_be(:group3) { groups.create!(id: 30, name: 'test3', path: 'test3', type: 'Group') }
+ let_it_be(:group4) { groups.create!(id: 40, name: 'test4', path: 'test4', type: 'Group') }
+
+ let_it_be(:dependency_proxy_blobs) { table(:dependency_proxy_blobs) }
+ let_it_be(:dependency_proxy_manifests) { table(:dependency_proxy_manifests) }
+
+ let_it_be(:group1_manifest) { create_manifest(10, 10) }
+ let_it_be(:group2_manifest) { create_manifest(20, 20) }
+ let_it_be(:group3_manifest) { create_manifest(30, 30) }
+
+ let_it_be(:group1_blob) { create_blob(10, 10) }
+ let_it_be(:group2_blob) { create_blob(20, 20) }
+ let_it_be(:group3_blob) { create_blob(30, 30) }
+
+ describe '#up' do
+ it 'correctly schedules background migrations' do
+ stub_const("#{described_class}::BATCH_SIZE", 2)
+
+ Sidekiq::Testing.fake! do
+ freeze_time do
+ migrate!
+
+ aggregate_failures do
+ expect(described_class::MIGRATION)
+ .to be_scheduled_migration([10, 30], ['dependency_proxy_size'])
+
+ expect(described_class::MIGRATION)
+ .to be_scheduled_delayed_migration(2.minutes, [20], ['dependency_proxy_size'])
+
+ expect(BackgroundMigrationWorker.jobs.size).to eq(2)
+ end
+ end
+ end
+ end
+ end
+
+ def create_manifest(group_id, size)
+ dependency_proxy_manifests.create!(
+ group_id: group_id,
+ size: size,
+ file_name: 'test-file',
+ file: 'test',
+ digest: 'abc123'
+ )
+ end
+
+ def create_blob(group_id, size)
+ dependency_proxy_blobs.create!(
+ group_id: group_id,
+ size: size,
+ file_name: 'test-file',
+ file: 'test'
+ )
+ end
+end
diff --git a/spec/migrations/20220223124428_schedule_merge_topics_with_same_name_spec.rb b/spec/migrations/20220223124428_schedule_merge_topics_with_same_name_spec.rb
new file mode 100644
index 00000000000..d9f6729475c
--- /dev/null
+++ b/spec/migrations/20220223124428_schedule_merge_topics_with_same_name_spec.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe ScheduleMergeTopicsWithSameName do
+ let(:topics) { table(:topics) }
+
+ describe '#up' do
+ before do
+ stub_const("#{described_class}::BATCH_SIZE", 2)
+
+ topics.create!(name: 'topic1')
+ topics.create!(name: 'Topic2')
+ topics.create!(name: 'Topic3')
+ topics.create!(name: 'Topic4')
+ topics.create!(name: 'topic2')
+ topics.create!(name: 'topic3')
+ topics.create!(name: 'topic4')
+ topics.create!(name: 'TOPIC2')
+ topics.create!(name: 'topic5')
+ end
+
+ it 'schedules MergeTopicsWithSameName background jobs', :aggregate_failures do
+ Sidekiq::Testing.fake! do
+ freeze_time do
+ migrate!
+
+ expect(described_class::MIGRATION).to be_scheduled_delayed_migration(2.minutes, %w[topic2 topic3])
+ expect(described_class::MIGRATION).to be_scheduled_delayed_migration(4.minutes, %w[topic4])
+ expect(BackgroundMigrationWorker.jobs.size).to eq(2)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/migrations/20220315171129_cleanup_draft_data_from_faulty_regex_spec.rb b/spec/migrations/20220315171129_cleanup_draft_data_from_faulty_regex_spec.rb
new file mode 100644
index 00000000000..925f1e573be
--- /dev/null
+++ b/spec/migrations/20220315171129_cleanup_draft_data_from_faulty_regex_spec.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe CleanupDraftDataFromFaultyRegex do
+ let(:merge_requests) { table(:merge_requests) }
+
+ let!(:namespace) { table(:namespaces).create!(name: 'namespace', path: 'namespace') }
+ let!(:project) { table(:projects).create!(namespace_id: namespace.id) }
+
+ let(:default_mr_values) do
+ {
+ target_project_id: project.id,
+ draft: true,
+ source_branch: 'master',
+ target_branch: 'feature'
+ }
+ end
+
+ let!(:known_good_1) { merge_requests.create!(default_mr_values.merge(title: "Draft: Test Title")) }
+ let!(:known_good_2) { merge_requests.create!(default_mr_values.merge(title: "WIP: Test Title")) }
+ let!(:known_bad_1) { merge_requests.create!(default_mr_values.merge(title: "Known bad title drafts")) }
+ let!(:known_bad_2) { merge_requests.create!(default_mr_values.merge(title: "Known bad title wip")) }
+
+ describe '#up' do
+ it 'schedules CleanupDraftDataFromFaultyRegex background jobs filtering for eligble MRs' do
+ stub_const("#{described_class}::BATCH_SIZE", 2)
+ allow(Gitlab).to receive(:com?).and_return(true)
+
+ freeze_time do
+ migrate!
+
+ expect(described_class::MIGRATION).to be_scheduled_delayed_migration(5.minutes, known_bad_1.id, known_bad_2.id)
+
+ expect(BackgroundMigrationWorker.jobs.size).to eq(1)
+ end
+ end
+ end
+end
diff --git a/spec/migrations/20220316202640_populate_container_repositories_migration_plan_spec.rb b/spec/migrations/20220316202640_populate_container_repositories_migration_plan_spec.rb
new file mode 100644
index 00000000000..7b5c8254163
--- /dev/null
+++ b/spec/migrations/20220316202640_populate_container_repositories_migration_plan_spec.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe PopulateContainerRepositoriesMigrationPlan, :aggregate_failures do
+ let_it_be(:namespaces) { table(:namespaces) }
+ let_it_be(:projects) { table(:projects) }
+ let_it_be(:container_repositories) { table(:container_repositories) }
+
+ let!(:namespace) { namespaces.create!(id: 1, name: 'namespace', path: 'namespace') }
+ let!(:project) { projects.create!(id: 1, name: 'project', path: 'project', namespace_id: 1) }
+ let!(:container_repository1) { container_repositories.create!(name: 'container_repository1', project_id: 1) }
+ let!(:container_repository2) { container_repositories.create!(name: 'container_repository2', project_id: 1) }
+ let!(:container_repository3) { container_repositories.create!(name: 'container_repository3', project_id: 1) }
+
+ before do
+ stub_const("#{described_class.name}::BATCH_SIZE", 2)
+ end
+
+ it 'schedules jobs for container_repositories to populate migration_state' do
+ Sidekiq::Testing.fake! do
+ freeze_time do
+ migrate!
+
+ expect(described_class::MIGRATION).to be_scheduled_delayed_migration(
+ 2.minutes, container_repository1.id, container_repository2.id)
+ expect(described_class::MIGRATION).to be_scheduled_delayed_migration(
+ 4.minutes, container_repository3.id, container_repository3.id)
+ expect(BackgroundMigrationWorker.jobs.size).to eq(2)
+ end
+ end
+ end
+end
diff --git a/spec/migrations/20220321234317_remove_all_issuable_escalation_statuses_spec.rb b/spec/migrations/20220321234317_remove_all_issuable_escalation_statuses_spec.rb
new file mode 100644
index 00000000000..44e20df1130
--- /dev/null
+++ b/spec/migrations/20220321234317_remove_all_issuable_escalation_statuses_spec.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe RemoveAllIssuableEscalationStatuses do
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+ let(:issues) { table(:issues) }
+ let(:statuses) { table(:incident_management_issuable_escalation_statuses) }
+ let(:namespace) { namespaces.create!(name: 'foo', path: 'foo') }
+ let(:project) { projects.create!(namespace_id: namespace.id) }
+
+ it 'removes all escalation status records' do
+ issue = issues.create!(project_id: project.id, issue_type: 1)
+ statuses.create!(issue_id: issue.id)
+
+ expect { migrate! }.to change(statuses, :count).from(1).to(0)
+ end
+end
diff --git a/spec/migrations/20220322132242_update_pages_onboarding_state_spec.rb b/spec/migrations/20220322132242_update_pages_onboarding_state_spec.rb
new file mode 100644
index 00000000000..fbd5fe546fa
--- /dev/null
+++ b/spec/migrations/20220322132242_update_pages_onboarding_state_spec.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+require 'spec_helper'
+require_migration!
+
+RSpec.describe UpdatePagesOnboardingState do
+ let(:migration) { described_class.new }
+ let!(:namespaces) { table(:namespaces) }
+ let!(:projects) { table(:projects) }
+ let!(:project_pages_metadata) { table(:project_pages_metadata) }
+
+ let!(:namespace1) { namespaces.create!(name: 'foo', path: 'foo') }
+ let!(:namespace2) { namespaces.create!(name: 'bar', path: 'bar') }
+ let!(:project1) { projects.create!(namespace_id: namespace1.id) }
+ let!(:project2) { projects.create!(namespace_id: namespace2.id) }
+ let!(:pages_metadata1) do
+ project_pages_metadata.create!(
+ project_id: project1.id,
+ deployed: true,
+ onboarding_complete: false
+ )
+ end
+
+ let!(:pages_metadata2) do
+ project_pages_metadata.create!(
+ project_id: project2.id,
+ deployed: false,
+ onboarding_complete: false
+ )
+ end
+
+ describe '#up' do
+ before do
+ migration.up
+ end
+
+ it 'sets the onboarding_complete attribute to the value of deployed' do
+ expect(pages_metadata1.reload.onboarding_complete).to eq(true)
+ expect(pages_metadata2.reload.onboarding_complete).to eq(false)
+ end
+ end
+
+ describe '#down' do
+ before do
+ migration.up
+ migration.down
+ end
+
+ it 'sets all onboarding_complete attributes to false' do
+ expect(pages_metadata1.reload.onboarding_complete).to eq(false)
+ expect(pages_metadata2.reload.onboarding_complete).to eq(false)
+ end
+ end
+end
diff --git a/spec/migrations/20220324032250_migrate_shimo_confluence_service_category_spec.rb b/spec/migrations/20220324032250_migrate_shimo_confluence_service_category_spec.rb
new file mode 100644
index 00000000000..38db6d51e7e
--- /dev/null
+++ b/spec/migrations/20220324032250_migrate_shimo_confluence_service_category_spec.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe MigrateShimoConfluenceServiceCategory, :migration do
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+ let(:integrations) { table(:integrations) }
+
+ before do
+ namespace = namespaces.create!(name: 'test', path: 'test')
+ projects.create!(id: 1, namespace_id: namespace.id, name: 'gitlab', path: 'gitlab')
+ integrations.create!(id: 1, active: true, type_new: "Integrations::SlackSlashCommands",
+ category: 'chat', project_id: 1)
+ integrations.create!(id: 3, active: true, type_new: "Integrations::Confluence", category: 'common', project_id: 1)
+ integrations.create!(id: 5, active: true, type_new: "Integrations::Shimo", category: 'common', project_id: 1)
+ end
+
+ describe '#up' do
+ it 'correctly schedules background migrations', :aggregate_failures do
+ stub_const("#{described_class.name}::BATCH_SIZE", 2)
+
+ Sidekiq::Testing.fake! do
+ freeze_time do
+ migrate!
+
+ expect(described_class::MIGRATION).to be_scheduled_migration(3, 5)
+ expect(BackgroundMigrationWorker.jobs.size).to eq(1)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/migrations/20220329175119_remove_leftover_ci_job_artifact_deletions_spec.rb b/spec/migrations/20220329175119_remove_leftover_ci_job_artifact_deletions_spec.rb
new file mode 100644
index 00000000000..13884007af2
--- /dev/null
+++ b/spec/migrations/20220329175119_remove_leftover_ci_job_artifact_deletions_spec.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+require_migration!
+
+RSpec.describe RemoveLeftoverCiJobArtifactDeletions do
+ let(:deleted_records) { table(:loose_foreign_keys_deleted_records) }
+
+ target_table_name = Ci::JobArtifact.table_name
+
+ let(:pending_record1) do
+ deleted_records.create!(
+ id: 1,
+ fully_qualified_table_name: "public.#{target_table_name}",
+ primary_key_value: 1,
+ status: 1
+ )
+ end
+
+ let(:pending_record2) do
+ deleted_records.create!(
+ id: 2,
+ fully_qualified_table_name: "public.#{target_table_name}",
+ primary_key_value: 2,
+ status: 1
+ )
+ end
+
+ let(:other_pending_record1) do
+ deleted_records.create!(
+ id: 3,
+ fully_qualified_table_name: 'public.projects',
+ primary_key_value: 1,
+ status: 1
+ )
+ end
+
+ let(:other_pending_record2) do
+ deleted_records.create!(
+ id: 4,
+ fully_qualified_table_name: 'public.ci_builds',
+ primary_key_value: 1,
+ status: 1
+ )
+ end
+
+ let(:processed_record1) do
+ deleted_records.create!(
+ id: 5,
+ fully_qualified_table_name: 'public.external_pull_requests',
+ primary_key_value: 3,
+ status: 2
+ )
+ end
+
+ let(:other_processed_record1) do
+ deleted_records.create!(
+ id: 6,
+ fully_qualified_table_name: 'public.ci_builds',
+ primary_key_value: 2,
+ status: 2
+ )
+ end
+
+ let!(:persisted_ids_before) do
+ [
+ pending_record1,
+ pending_record2,
+ other_pending_record1,
+ other_pending_record2,
+ processed_record1,
+ other_processed_record1
+ ].map(&:id).sort
+ end
+
+ let!(:persisted_ids_after) do
+ [
+ other_pending_record1,
+ other_pending_record2,
+ processed_record1,
+ other_processed_record1
+ ].map(&:id).sort
+ end
+
+ def all_ids
+ deleted_records.all.map(&:id).sort
+ end
+
+ it 'deletes pending external_pull_requests records' do
+ expect { migrate! }.to change { all_ids }.from(persisted_ids_before).to(persisted_ids_after)
+ end
+end
diff --git a/spec/migrations/20220412143552_consume_remaining_encrypt_integration_property_jobs_spec.rb b/spec/migrations/20220412143552_consume_remaining_encrypt_integration_property_jobs_spec.rb
new file mode 100644
index 00000000000..4a1b68a5a85
--- /dev/null
+++ b/spec/migrations/20220412143552_consume_remaining_encrypt_integration_property_jobs_spec.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require_migration!
+
+RSpec.describe ConsumeRemainingEncryptIntegrationPropertyJobs, :migration do
+ subject(:migration) { described_class.new }
+
+ let(:integrations) { table(:integrations) }
+ let(:bg_migration_class) { ::Gitlab::BackgroundMigration::EncryptIntegrationProperties }
+ let(:bg_migration) { instance_double(bg_migration_class) }
+
+ before do
+ stub_const("#{described_class.name}::BATCH_SIZE", 2)
+ end
+
+ it 'performs remaining background migrations', :aggregate_failures do
+ # Already migrated
+ integrations.create!(properties: some_props, encrypted_properties: 'abc')
+ integrations.create!(properties: some_props, encrypted_properties: 'def')
+ integrations.create!(properties: some_props, encrypted_properties: 'xyz')
+ # update required
+ record1 = integrations.create!(properties: some_props)
+ record2 = integrations.create!(properties: some_props)
+ record3 = integrations.create!(properties: some_props)
+ # No update required
+ integrations.create!(properties: nil)
+ integrations.create!(properties: nil)
+
+ expect(Gitlab::BackgroundMigration).to receive(:steal).with(bg_migration_class.name.demodulize)
+ expect(bg_migration_class).to receive(:new).twice.and_return(bg_migration)
+ expect(bg_migration).to receive(:perform).with(record1.id, record2.id)
+ expect(bg_migration).to receive(:perform).with(record3.id, record3.id)
+
+ migrate!
+ end
+
+ def some_props
+ { iid: generate(:iid), url: generate(:url), username: generate(:username) }.to_json
+ end
+end
diff --git a/spec/migrations/add_epics_relative_position_spec.rb b/spec/migrations/add_epics_relative_position_spec.rb
new file mode 100644
index 00000000000..f3b7dd1727b
--- /dev/null
+++ b/spec/migrations/add_epics_relative_position_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require_migration!
+
+RSpec.describe AddEpicsRelativePosition, :migration do
+ let(:groups) { table(:namespaces) }
+ let(:epics) { table(:epics) }
+ let(:users) { table(:users) }
+ let(:user) { users.create!(name: 'user', email: 'email@example.org', projects_limit: 100) }
+ let(:group) { groups.create!(name: 'gitlab', path: 'gitlab-org', type: 'Group') }
+
+ let!(:epic1) { epics.create!(title: 'epic 1', title_html: 'epic 1', author_id: user.id, group_id: group.id, iid: 1) }
+ let!(:epic2) { epics.create!(title: 'epic 2', title_html: 'epic 2', author_id: user.id, group_id: group.id, iid: 2) }
+ let!(:epic3) { epics.create!(title: 'epic 3', title_html: 'epic 3', author_id: user.id, group_id: group.id, iid: 3) }
+
+ it 'does nothing if epics table contains relative_position' do
+ expect { migrate! }.not_to change { epics.pluck(:relative_position) }
+ end
+
+ it 'adds relative_position if missing and backfills it with ID value', :aggregate_failures do
+ ActiveRecord::Base.connection.execute('ALTER TABLE epics DROP relative_position')
+
+ migrate!
+
+ expect(epics.pluck(:relative_position)).to match_array([epic1.id * 500, epic2.id * 500, epic3.id * 500])
+ end
+end
diff --git a/spec/migrations/backfill_group_features_spec.rb b/spec/migrations/backfill_group_features_spec.rb
new file mode 100644
index 00000000000..922d54f43be
--- /dev/null
+++ b/spec/migrations/backfill_group_features_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe BackfillGroupFeatures, :migration do
+ let(:migration) { described_class::MIGRATION }
+
+ describe '#up' do
+ it 'schedules background jobs for each batch of namespaces' do
+ migrate!
+
+ expect(migration).to have_scheduled_batched_migration(
+ table_name: :namespaces,
+ column_name: :id,
+ job_arguments: [described_class::BATCH_SIZE],
+ interval: described_class::INTERVAL,
+ batch_size: described_class::BATCH_SIZE
+ )
+ end
+ end
+
+ describe '#down' do
+ it 'deletes all batched migration records' do
+ migrate!
+ schema_migrate_down!
+
+ expect(migration).not_to have_scheduled_batched_migration
+ end
+ end
+end
diff --git a/spec/migrations/backfill_namespace_id_for_project_routes_spec.rb b/spec/migrations/backfill_namespace_id_for_project_routes_spec.rb
new file mode 100644
index 00000000000..28edd17731f
--- /dev/null
+++ b/spec/migrations/backfill_namespace_id_for_project_routes_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe BackfillNamespaceIdForProjectRoutes, :migration do
+ let(:migration) { described_class::MIGRATION }
+
+ describe '#up' do
+ it 'schedules background jobs for each batch of group members' do
+ migrate!
+
+ expect(migration).to have_scheduled_batched_migration(
+ table_name: :routes,
+ column_name: :id,
+ interval: described_class::INTERVAL
+ )
+ end
+ end
+
+ describe '#down' do
+ it 'deletes all batched migration records' do
+ migrate!
+ schema_migrate_down!
+
+ expect(migration).not_to have_scheduled_batched_migration
+ end
+ end
+end
diff --git a/spec/migrations/backfill_work_item_type_id_on_issues_spec.rb b/spec/migrations/backfill_work_item_type_id_on_issues_spec.rb
new file mode 100644
index 00000000000..6798b0cc7e8
--- /dev/null
+++ b/spec/migrations/backfill_work_item_type_id_on_issues_spec.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe BackfillWorkItemTypeIdOnIssues, :migration do
+ let_it_be(:migration) { described_class::MIGRATION }
+ let_it_be(:interval) { 2.minutes }
+ let_it_be(:issue_type_enum) { { issue: 0, incident: 1, test_case: 2, requirement: 3, task: 4 } }
+ let_it_be(:base_work_item_type_ids) do
+ table(:work_item_types).where(namespace_id: nil).order(:base_type).each_with_object({}) do |type, hash|
+ hash[type.base_type] = type.id
+ end
+ end
+
+ describe '#up' do
+ it 'correctly schedules background migrations' do
+ Sidekiq::Testing.fake! do
+ freeze_time do
+ migrate!
+
+ scheduled_migrations = Gitlab::Database::BackgroundMigration::BatchedMigration.where(job_class_name: migration)
+ work_item_types = table(:work_item_types).where(namespace_id: nil)
+
+ expect(scheduled_migrations.count).to eq(work_item_types.count)
+
+ [:issue, :incident, :test_case, :requirement, :task].each do |issue_type|
+ expect(migration).to have_scheduled_batched_migration(
+ table_name: :issues,
+ column_name: :id,
+ job_arguments: [issue_type_enum[issue_type], base_work_item_type_ids[issue_type_enum[issue_type]]],
+ interval: interval,
+ batch_size: described_class::BATCH_SIZE,
+ max_batch_size: described_class::MAX_BATCH_SIZE,
+ sub_batch_size: described_class::SUB_BATCH_SIZE,
+ batch_class_name: described_class::BATCH_CLASS_NAME
+ )
+ end
+ end
+ end
+ end
+ end
+
+ describe '#down' do
+ it 'deletes all batched migration records' do
+ migrate!
+ schema_migrate_down!
+
+ expect(migration).not_to have_scheduled_batched_migration
+ end
+ end
+end
diff --git a/spec/migrations/cleanup_after_fixing_issue_when_admin_changed_primary_email_spec.rb b/spec/migrations/cleanup_after_fixing_issue_when_admin_changed_primary_email_spec.rb
new file mode 100644
index 00000000000..eda57545c7a
--- /dev/null
+++ b/spec/migrations/cleanup_after_fixing_issue_when_admin_changed_primary_email_spec.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe CleanupAfterFixingIssueWhenAdminChangedPrimaryEmail, :sidekiq do
+ let(:migration) { described_class.new }
+ let(:users) { table(:users) }
+ let(:emails) { table(:emails) }
+
+ let!(:user_1) { users.create!(name: 'confirmed-user-1', email: 'confirmed-1@example.com', confirmed_at: 3.days.ago, projects_limit: 100) }
+ let!(:user_2) { users.create!(name: 'confirmed-user-2', email: 'confirmed-2@example.com', confirmed_at: 1.day.ago, projects_limit: 100) }
+ let!(:user_3) { users.create!(name: 'confirmed-user-3', email: 'confirmed-3@example.com', confirmed_at: 1.day.ago, projects_limit: 100) }
+ let!(:user_4) { users.create!(name: 'unconfirmed-user', email: 'unconfirmed@example.com', confirmed_at: nil, projects_limit: 100) }
+
+ let!(:email_1) { emails.create!(email: 'confirmed-1@example.com', user_id: user_1.id, confirmed_at: 1.day.ago) }
+ let!(:email_2) { emails.create!(email: 'other_2@example.com', user_id: user_2.id, confirmed_at: 1.day.ago) }
+
+ before do
+ stub_const("#{described_class.name}::BATCH_SIZE", 2)
+ end
+
+ it 'adds the primary email to emails for leftover confirmed users that do not have their primary email in the emails table', :aggregate_failures do
+ original_email_1_confirmed_at = email_1.reload.confirmed_at
+
+ expect { migration.up }.to change { emails.count }.by(2)
+
+ expect(emails.find_by(user_id: user_2.id, email: 'confirmed-2@example.com').confirmed_at).to eq(user_2.reload.confirmed_at)
+ expect(emails.find_by(user_id: user_3.id, email: 'confirmed-3@example.com').confirmed_at).to eq(user_3.reload.confirmed_at)
+ expect(email_1.reload.confirmed_at).to eq(original_email_1_confirmed_at)
+
+ expect(emails.exists?(user_id: user_4.id)).to be(false)
+ end
+
+ it 'continues in case of errors with one email' do
+ allow(Email).to receive(:create) { raise 'boom!' }
+
+ expect { migration.up }.not_to raise_error
+ end
+end
diff --git a/spec/migrations/finalize_project_namespaces_backfill_spec.rb b/spec/migrations/finalize_project_namespaces_backfill_spec.rb
new file mode 100644
index 00000000000..3d0b0ec13fe
--- /dev/null
+++ b/spec/migrations/finalize_project_namespaces_backfill_spec.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe FinalizeProjectNamespacesBackfill, :migration do
+ let(:batched_migrations) { table(:batched_background_migrations) }
+
+ let_it_be(:migration) { described_class::MIGRATION }
+
+ describe '#up' do
+ shared_examples 'raises migration not finished exception' do
+ it 'raises exception' do
+ expect { migrate! }.to raise_error(/Expected batched background migration for the given configuration to be marked as 'finished'/)
+ end
+ end
+
+ context 'when project namespace backfilling 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 backfilling migration present' do
+ let!(:project_namespace_backfill) do
+ batched_migrations.create!(
+ job_class_name: 'ProjectNamespaces::BackfillProjectNamespaces',
+ table_name: :projects,
+ column_name: :id,
+ job_arguments: [nil, 'up'],
+ interval: 2.minutes,
+ min_value: 1,
+ max_value: 2,
+ batch_size: 1000,
+ sub_batch_size: 200,
+ status: 3 # finished
+ )
+ end
+
+ context 'when project namespace backfilling migration finished successfully' do
+ it 'does not raise exception' do
+ expect { migrate! }.not_to raise_error(/Expected batched background migration for the given configuration to be marked as 'finished'/)
+ end
+ end
+
+ context 'when project namespace backfilling migration is paused' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:status, :description) do
+ 0 | 'paused'
+ 1 | 'active'
+ 4 | 'failed'
+ 5 | 'finalizing'
+ end
+
+ with_them do
+ before do
+ project_namespace_backfill.update!(status: status)
+ end
+
+ it_behaves_like 'raises migration not finished exception'
+ end
+ end
+ end
+ end
+end
diff --git a/spec/migrations/finalize_traversal_ids_background_migrations_spec.rb b/spec/migrations/finalize_traversal_ids_background_migrations_spec.rb
new file mode 100644
index 00000000000..74d6447e6a7
--- /dev/null
+++ b/spec/migrations/finalize_traversal_ids_background_migrations_spec.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!('finalize_traversal_ids_background_migrations')
+
+RSpec.describe FinalizeTraversalIdsBackgroundMigrations, :migration do
+ shared_context 'incomplete background migration' do
+ before do
+ # Jobs enqueued in Sidekiq.
+ Sidekiq::Testing.disable! do
+ BackgroundMigrationWorker.perform_in(10, job_class_name, [1, 2, 100])
+ BackgroundMigrationWorker.perform_in(20, job_class_name, [3, 4, 100])
+ end
+
+ # Jobs tracked in the database.
+ # table(:background_migration_jobs).create!(
+ Gitlab::Database::BackgroundMigrationJob.create!(
+ class_name: job_class_name,
+ arguments: [5, 6, 100],
+ status: Gitlab::Database::BackgroundMigrationJob.statuses['pending']
+ )
+ # table(:background_migration_jobs).create!(
+ Gitlab::Database::BackgroundMigrationJob.create!(
+ class_name: job_class_name,
+ arguments: [7, 8, 100],
+ status: Gitlab::Database::BackgroundMigrationJob.statuses['succeeded']
+ )
+ end
+ end
+
+ context 'BackfillNamespaceTraversalIdsRoots background migration' do
+ let(:job_class_name) { 'BackfillNamespaceTraversalIdsRoots' }
+
+ include_context 'incomplete background migration'
+
+ before do
+ migrate!
+ end
+
+ it_behaves_like(
+ 'finalized tracked background migration',
+ Gitlab::BackgroundMigration::BackfillNamespaceTraversalIdsRoots
+ )
+ end
+
+ context 'BackfillNamespaceTraversalIdsChildren background migration' do
+ let(:job_class_name) { 'BackfillNamespaceTraversalIdsChildren' }
+
+ include_context 'incomplete background migration'
+
+ before do
+ migrate!
+ end
+
+ it_behaves_like(
+ 'finalized tracked background migration',
+ Gitlab::BackgroundMigration::BackfillNamespaceTraversalIdsChildren
+ )
+ end
+end
diff --git a/spec/migrations/fix_and_backfill_project_namespaces_for_projects_with_duplicate_name_spec.rb b/spec/migrations/fix_and_backfill_project_namespaces_for_projects_with_duplicate_name_spec.rb
new file mode 100644
index 00000000000..44a2220b2ad
--- /dev/null
+++ b/spec/migrations/fix_and_backfill_project_namespaces_for_projects_with_duplicate_name_spec.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe FixAndBackfillProjectNamespacesForProjectsWithDuplicateName, :migration do
+ let(:projects) { table(:projects) }
+ let(:namespaces) { table(:namespaces) }
+
+ let!(:group) { namespaces.create!(name: 'group1', path: 'group1', type: 'Group') }
+ let!(:project_namespace) { namespaces.create!(name: 'project2', path: 'project2', type: 'Project') }
+ let!(:project1) { projects.create!(name: 'project1', path: 'project1', project_namespace_id: nil, namespace_id: group.id, visibility_level: 20) }
+ let!(:project2) { projects.create!(name: 'project2', path: 'project2', project_namespace_id: project_namespace.id, namespace_id: group.id, visibility_level: 20) }
+ let!(:project3) { projects.create!(name: 'project3', path: 'project3', project_namespace_id: nil, namespace_id: group.id, visibility_level: 20) }
+ let!(:project4) { projects.create!(name: 'project4', path: 'project4', project_namespace_id: nil, namespace_id: group.id, visibility_level: 20) }
+
+ describe '#up' do
+ it 'schedules background migrations' do
+ Sidekiq::Testing.fake! do
+ freeze_time do
+ described_class.new.up
+
+ migration = described_class::MIGRATION
+
+ expect(migration).to be_scheduled_delayed_migration(2.minutes, project1.id, project4.id)
+ expect(BackgroundMigrationWorker.jobs.size).to eq 1
+ end
+ end
+ end
+
+ context 'in batches' do
+ before do
+ stub_const('FixAndBackfillProjectNamespacesForProjectsWithDuplicateName::BATCH_SIZE', 2)
+ end
+
+ it 'schedules background migrations' do
+ Sidekiq::Testing.fake! do
+ freeze_time do
+ described_class.new.up
+
+ migration = described_class::MIGRATION
+
+ expect(migration).to be_scheduled_delayed_migration(2.minutes, project1.id, project3.id)
+ expect(migration).to be_scheduled_delayed_migration(4.minutes, project4.id, project4.id)
+ expect(BackgroundMigrationWorker.jobs.size).to eq 2
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/migrations/remove_wiki_notes_spec.rb b/spec/migrations/remove_wiki_notes_spec.rb
new file mode 100644
index 00000000000..2ffebdee106
--- /dev/null
+++ b/spec/migrations/remove_wiki_notes_spec.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe RemoveWikiNotes, :migration do
+ let(:notes) { table(:notes) }
+
+ it 'removes all wiki notes' do
+ notes.create!(id: 97, note: 'Wiki note', noteable_type: 'Wiki')
+ notes.create!(id: 98, note: 'Commit note', noteable_type: 'Commit')
+ notes.create!(id: 110, note: 'Issue note', noteable_type: 'Issue')
+ notes.create!(id: 242, note: 'MergeRequest note', noteable_type: 'MergeRequest')
+
+ expect(notes.where(noteable_type: 'Wiki').size).to eq(1)
+
+ expect { migrate! }.to change { notes.count }.by(-1)
+
+ expect(notes.where(noteable_type: 'Wiki').size).to eq(0)
+ end
+
+ context 'when not staging nor com' do
+ it 'does not remove notes' do
+ allow(::Gitlab).to receive(:com?).and_return(false)
+ allow(::Gitlab).to receive(:dev_or_test_env?).and_return(false)
+ allow(::Gitlab).to receive(:staging?).and_return(false)
+
+ notes.create!(id: 97, note: 'Wiki note', noteable_type: 'Wiki')
+
+ expect { migrate! }.not_to change { notes.count }
+ end
+ end
+end
diff --git a/spec/migrations/replace_work_item_type_backfill_next_batch_strategy_spec.rb b/spec/migrations/replace_work_item_type_backfill_next_batch_strategy_spec.rb
new file mode 100644
index 00000000000..5e22fc06973
--- /dev/null
+++ b/spec/migrations/replace_work_item_type_backfill_next_batch_strategy_spec.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe ReplaceWorkItemTypeBackfillNextBatchStrategy, :migration do
+ describe '#up' do
+ it 'sets the new strategy for existing migrations' do
+ migrations = create_migrations(described_class::OLD_STRATEGY_CLASS, 2)
+
+ expect do
+ migrate!
+
+ migrations.each(&:reload)
+ end.to change { migrations.pluck(:batch_class_name).uniq }.from([described_class::OLD_STRATEGY_CLASS])
+ .to([described_class::NEW_STRATEGY_CLASS])
+ end
+ end
+
+ describe '#down' do
+ it 'sets the old strategy for existing migrations' do
+ migrations = create_migrations(described_class::NEW_STRATEGY_CLASS, 2)
+
+ expect do
+ migrate!
+ schema_migrate_down!
+
+ migrations.each(&:reload)
+ end.to change { migrations.pluck(:batch_class_name).uniq }.from([described_class::NEW_STRATEGY_CLASS])
+ .to([described_class::OLD_STRATEGY_CLASS])
+ end
+ end
+
+ def create_migrations(batch_class_name, count)
+ Array.new(2) { |index| create_background_migration(batch_class_name, [index]) }
+ end
+
+ def create_background_migration(batch_class_name, job_arguments)
+ migrations_table = table(:batched_background_migrations)
+
+ migrations_table.create!(
+ batch_class_name: batch_class_name,
+ job_class_name: described_class::JOB_CLASS_NAME,
+ max_value: 10,
+ batch_size: 5,
+ sub_batch_size: 1,
+ interval: 2.minutes,
+ table_name: :issues,
+ column_name: :id,
+ total_tuple_count: 10_000,
+ pause_ms: 100,
+ job_arguments: job_arguments
+ )
+ end
+end
diff --git a/spec/models/alert_management/metric_image_spec.rb b/spec/models/alert_management/metric_image_spec.rb
new file mode 100644
index 00000000000..dedbd6e501e
--- /dev/null
+++ b/spec/models/alert_management/metric_image_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe AlertManagement::MetricImage do
+ subject { build(:alert_metric_image) }
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:alert) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to be_valid }
+ it { is_expected.to validate_presence_of(:file) }
+ it { is_expected.to validate_length_of(:url).is_at_most(255) }
+ it { is_expected.to validate_length_of(:url_text).is_at_most(128) }
+ end
+
+ describe '.available_for?' do
+ subject { described_class.available_for?(issue.project) }
+
+ let_it_be_with_refind(:issue) { create(:issue) }
+
+ it { is_expected.to eq(true) }
+ end
+end
diff --git a/spec/models/analytics/cycle_analytics/aggregation_spec.rb b/spec/models/analytics/cycle_analytics/aggregation_spec.rb
index 4bf737df56a..6071e4b3d21 100644
--- a/spec/models/analytics/cycle_analytics/aggregation_spec.rb
+++ b/spec/models/analytics/cycle_analytics/aggregation_spec.rb
@@ -10,7 +10,7 @@ RSpec.describe Analytics::CycleAnalytics::Aggregation, type: :model do
it { is_expected.not_to validate_presence_of(:group) }
it { is_expected.not_to validate_presence_of(:enabled) }
- %i[incremental_runtimes_in_seconds incremental_processed_records last_full_run_runtimes_in_seconds last_full_run_processed_records].each do |column|
+ %i[incremental_runtimes_in_seconds incremental_processed_records full_runtimes_in_seconds full_processed_records].each do |column|
it "validates the array length of #{column}" do
record = described_class.new(column => [1] * 11)
@@ -20,6 +20,81 @@ RSpec.describe Analytics::CycleAnalytics::Aggregation, type: :model do
end
end
+ describe 'attribute updater methods' do
+ subject(:aggregation) { build(:cycle_analytics_aggregation) }
+
+ describe '#cursor_for' do
+ it 'returns empty cursors' do
+ aggregation.last_full_issues_id = nil
+ aggregation.last_full_issues_updated_at = nil
+
+ expect(aggregation.cursor_for(:full, Issue)).to eq({})
+ end
+
+ context 'when cursor is not empty' do
+ it 'returns the cursor values' do
+ current_time = Time.current
+
+ aggregation.last_full_issues_id = 1111
+ aggregation.last_full_issues_updated_at = current_time
+
+ expect(aggregation.cursor_for(:full, Issue)).to eq({ id: 1111, updated_at: current_time })
+ end
+ end
+ end
+
+ describe '#refresh_last_run' do
+ it 'updates the run_at column' do
+ freeze_time do
+ aggregation.refresh_last_run(:incremental)
+
+ expect(aggregation.last_incremental_run_at).to eq(Time.current)
+ end
+ end
+ end
+
+ describe '#reset_full_run_cursors' do
+ it 'resets all full run cursors to nil' do
+ aggregation.last_full_issues_id = 111
+ aggregation.last_full_issues_updated_at = Time.current
+ aggregation.last_full_merge_requests_id = 111
+ aggregation.last_full_merge_requests_updated_at = Time.current
+
+ aggregation.reset_full_run_cursors
+
+ expect(aggregation).to have_attributes(
+ last_full_issues_id: nil,
+ last_full_issues_updated_at: nil,
+ last_full_merge_requests_id: nil,
+ last_full_merge_requests_updated_at: nil
+ )
+ end
+ end
+
+ describe '#set_cursor' do
+ it 'sets the cursor values for the given mode' do
+ aggregation.set_cursor(:full, Issue, { id: 2222, updated_at: nil })
+
+ expect(aggregation).to have_attributes(
+ last_full_issues_id: 2222,
+ last_full_issues_updated_at: nil
+ )
+ end
+ end
+
+ describe '#set_stats' do
+ it 'appends stats to the runtime and processed_records attributes' do
+ aggregation.set_stats(:full, 10, 20)
+ aggregation.set_stats(:full, 20, 30)
+
+ expect(aggregation).to have_attributes(
+ full_runtimes_in_seconds: [10, 20],
+ full_processed_records: [20, 30]
+ )
+ end
+ end
+ end
+
describe '#safe_create_for_group' do
let_it_be(:group) { create(:group) }
let_it_be(:subgroup) { create(:group, parent: group) }
diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb
index 70331e8d78a..541fa1ac77a 100644
--- a/spec/models/application_setting_spec.rb
+++ b/spec/models/application_setting_spec.rb
@@ -512,12 +512,8 @@ RSpec.describe ApplicationSetting do
end
context 'key restrictions' do
- it 'supports all key types' do
- expect(described_class::SUPPORTED_KEY_TYPES).to eq(Gitlab::SSHPublicKey.supported_types)
- end
-
it 'does not allow all key types to be disabled' do
- described_class::SUPPORTED_KEY_TYPES.each do |type|
+ Gitlab::SSHPublicKey.supported_types.each do |type|
setting["#{type}_key_restriction"] = described_class::FORBIDDEN_KEY_VALUE
end
@@ -526,15 +522,23 @@ RSpec.describe ApplicationSetting do
end
where(:type) do
- described_class::SUPPORTED_KEY_TYPES
+ Gitlab::SSHPublicKey.supported_types
end
with_them do
let(:field) { :"#{type}_key_restriction" }
- it { is_expected.to validate_presence_of(field) }
- it { is_expected.to allow_value(*KeyRestrictionValidator.supported_key_restrictions(type)).for(field) }
- it { is_expected.not_to allow_value(128).for(field) }
+ shared_examples 'key validations' do
+ it { is_expected.to validate_presence_of(field) }
+ it { is_expected.to allow_value(*KeyRestrictionValidator.supported_key_restrictions(type)).for(field) }
+ it { is_expected.not_to allow_value(128).for(field) }
+ end
+
+ it_behaves_like 'key validations'
+
+ context 'FIPS mode', :fips_mode do
+ it_behaves_like 'key validations'
+ end
end
end
@@ -1306,4 +1310,31 @@ RSpec.describe ApplicationSetting do
end
end
end
+
+ describe '#database_grafana_api_key' do
+ it 'is encrypted' do
+ subject.database_grafana_api_key = 'somesecret'
+
+ aggregate_failures do
+ expect(subject.encrypted_database_grafana_api_key).to be_present
+ expect(subject.encrypted_database_grafana_api_key_iv).to be_present
+ expect(subject.encrypted_database_grafana_api_key).not_to eq(subject.database_grafana_api_key)
+ end
+ end
+ end
+
+ context "inactive project deletion" do
+ it "validates that inactive_projects_send_warning_email_after_months is less than inactive_projects_delete_after_months" do
+ subject[:inactive_projects_delete_after_months] = 3
+ subject[:inactive_projects_send_warning_email_after_months] = 6
+
+ expect(subject).to be_invalid
+ end
+
+ it { is_expected.to validate_numericality_of(:inactive_projects_send_warning_email_after_months).is_greater_than(0) }
+
+ it { is_expected.to validate_numericality_of(:inactive_projects_delete_after_months).is_greater_than(0) }
+
+ it { is_expected.to validate_numericality_of(:inactive_projects_min_size_mb).is_greater_than_or_equal_to(0) }
+ end
end
diff --git a/spec/models/award_emoji_spec.rb b/spec/models/award_emoji_spec.rb
index ebd1441f901..4da19267b1c 100644
--- a/spec/models/award_emoji_spec.rb
+++ b/spec/models/award_emoji_spec.rb
@@ -58,6 +58,43 @@ RSpec.describe AwardEmoji do
end
end
end
+
+ context 'custom emoji' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:emoji) { create(:custom_emoji, name: 'partyparrot', namespace: group) }
+
+ before do
+ group.add_maintainer(user)
+ end
+
+ %i[issue merge_request note_on_issue snippet].each do |awardable_type|
+ let_it_be(:project) { create(:project, namespace: group) }
+ let(:awardable) { create(awardable_type, project: project) }
+
+ it "is accepted on #{awardable_type}" do
+ new_award = build(:award_emoji, user: user, awardable: awardable, name: emoji.name)
+
+ expect(new_award).to be_valid
+ end
+ end
+
+ it 'is accepted on subgroup issue' do
+ subgroup = create(:group, parent: group)
+ project = create(:project, namespace: subgroup)
+ issue = create(:issue, project: project)
+ new_award = build(:award_emoji, user: user, awardable: issue, name: emoji.name)
+
+ expect(new_award).to be_valid
+ end
+
+ it 'is not supported on personal snippet (yet)' do
+ snippet = create(:personal_snippet)
+ new_award = build(:award_emoji, user: snippet.author, awardable: snippet, name: 'null')
+
+ expect(new_award).not_to be_valid
+ end
+ end
end
describe 'scopes' do
@@ -210,4 +247,47 @@ RSpec.describe AwardEmoji do
end
end
end
+
+ describe '#url' do
+ let_it_be(:custom_emoji) { create(:custom_emoji) }
+ let_it_be(:project) { create(:project, namespace: custom_emoji.group) }
+ let_it_be(:issue) { create(:issue, project: project) }
+
+ def build_award(name)
+ build(:award_emoji, awardable: issue, name: name)
+ end
+
+ it 'is nil for built-in emoji' do
+ new_award = build_award('tada')
+
+ count = ActiveRecord::QueryRecorder.new do
+ expect(new_award.url).to be_nil
+ end.count
+ expect(count).to be_zero
+ end
+
+ it 'is nil for unrecognized emoji' do
+ new_award = build_award('null')
+
+ expect(new_award.url).to be_nil
+ end
+
+ it 'is set for custom emoji' do
+ new_award = build_award(custom_emoji.name)
+
+ expect(new_award.url).to eq(custom_emoji.url)
+ end
+
+ context 'feature flag disabled' do
+ before do
+ stub_feature_flags(custom_emoji: false)
+ end
+
+ it 'does not query' do
+ new_award = build_award(custom_emoji.name)
+
+ expect(ActiveRecord::QueryRecorder.new { new_award.url }.count).to be_zero
+ end
+ end
+ end
end
diff --git a/spec/models/blob_spec.rb b/spec/models/blob_spec.rb
index 6eba9ca63b0..9c153f36d8b 100644
--- a/spec/models/blob_spec.rb
+++ b/spec/models/blob_spec.rb
@@ -229,6 +229,20 @@ RSpec.describe Blob do
end
end
+ describe '#executable?' do
+ it 'is true for executables' do
+ executable_blob = fake_blob(path: 'file', mode: '100755')
+
+ expect(executable_blob.executable?).to eq true
+ end
+
+ it 'is false for non-executables' do
+ non_executable_blob = fake_blob(path: 'file', mode: '100655')
+
+ expect(non_executable_blob.executable?).to eq false
+ end
+ end
+
describe '#extension' do
it 'returns the extension' do
blob = fake_blob(path: 'file.md')
diff --git a/spec/models/board_spec.rb b/spec/models/board_spec.rb
index 90a06b80f9c..775cccd2aec 100644
--- a/spec/models/board_spec.rb
+++ b/spec/models/board_spec.rb
@@ -21,10 +21,12 @@ RSpec.describe Board do
end
describe '#order_by_name_asc' do
+ # rubocop:disable RSpec/VariableName
let!(:board_B) { create(:board, project: project, name: 'B') }
let!(:board_C) { create(:board, project: project, name: 'C') }
let!(:board_a) { create(:board, project: project, name: 'a') }
let!(:board_A) { create(:board, project: project, name: 'A') }
+ # rubocop:enable RSpec/VariableName
it 'returns in case-insensitive alphabetical order and then by ascending id' do
expect(project.boards.order_by_name_asc).to eq [board_a, board_A, board_B, board_C]
@@ -32,10 +34,12 @@ RSpec.describe Board do
end
describe '#first_board' do
+ # rubocop:disable RSpec/VariableName
let!(:board_B) { create(:board, project: project, name: 'B') }
let!(:board_C) { create(:board, project: project, name: 'C') }
let!(:board_a) { create(:board, project: project, name: 'a') }
let!(:board_A) { create(:board, project: project, name: 'A') }
+ # rubocop:enable RSpec/VariableName
it 'return the first case-insensitive alphabetical board as a relation' do
expect(project.boards.first_board).to eq [board_a]
diff --git a/spec/models/bulk_import_spec.rb b/spec/models/bulk_import_spec.rb
index ea002a7b174..3430da43f62 100644
--- a/spec/models/bulk_import_spec.rb
+++ b/spec/models/bulk_import_spec.rb
@@ -3,6 +3,13 @@
require 'spec_helper'
RSpec.describe BulkImport, type: :model do
+ let_it_be(:created_bulk_import) { create(:bulk_import, :created) }
+ let_it_be(:started_bulk_import) { create(:bulk_import, :started) }
+ let_it_be(:finished_bulk_import) { create(:bulk_import, :finished) }
+ let_it_be(:failed_bulk_import) { create(:bulk_import, :failed) }
+ let_it_be(:stale_created_bulk_import) { create(:bulk_import, :created, created_at: 3.days.ago) }
+ let_it_be(:stale_started_bulk_import) { create(:bulk_import, :started, created_at: 3.days.ago) }
+
describe 'associations' do
it { is_expected.to belong_to(:user).required }
it { is_expected.to have_one(:configuration) }
@@ -16,9 +23,15 @@ RSpec.describe BulkImport, type: :model do
it { is_expected.to define_enum_for(:source_type).with_values(%i[gitlab]) }
end
+ describe '.stale scope' do
+ subject { described_class.stale }
+
+ it { is_expected.to contain_exactly(stale_created_bulk_import, stale_started_bulk_import) }
+ end
+
describe '.all_human_statuses' do
it 'returns all human readable entity statuses' do
- expect(described_class.all_human_statuses).to contain_exactly('created', 'started', 'finished', 'failed')
+ expect(described_class.all_human_statuses).to contain_exactly('created', 'started', 'finished', 'failed', 'timeout')
end
end
diff --git a/spec/models/bulk_imports/entity_spec.rb b/spec/models/bulk_imports/entity_spec.rb
index e5bbac62dcc..6f6a7c9bcd8 100644
--- a/spec/models/bulk_imports/entity_spec.rb
+++ b/spec/models/bulk_imports/entity_spec.rb
@@ -151,7 +151,7 @@ RSpec.describe BulkImports::Entity, type: :model do
describe '.all_human_statuses' do
it 'returns all human readable entity statuses' do
- expect(described_class.all_human_statuses).to contain_exactly('created', 'started', 'finished', 'failed')
+ expect(described_class.all_human_statuses).to contain_exactly('created', 'started', 'finished', 'failed', 'timeout')
end
end
@@ -179,7 +179,7 @@ RSpec.describe BulkImports::Entity, type: :model do
entity = create(:bulk_import_entity, :group_entity)
entity.create_pipeline_trackers!
- expect(entity.trackers.count).to eq(BulkImports::Groups::Stage.new(entity.bulk_import).pipelines.count)
+ expect(entity.trackers.count).to eq(BulkImports::Groups::Stage.new(entity).pipelines.count)
expect(entity.trackers.map(&:pipeline_name)).to include(BulkImports::Groups::Pipelines::GroupPipeline.to_s)
end
end
@@ -189,7 +189,7 @@ RSpec.describe BulkImports::Entity, type: :model do
entity = create(:bulk_import_entity, :project_entity)
entity.create_pipeline_trackers!
- expect(entity.trackers.count).to eq(BulkImports::Projects::Stage.new(entity.bulk_import).pipelines.count)
+ expect(entity.trackers.count).to eq(BulkImports::Projects::Stage.new(entity).pipelines.count)
expect(entity.trackers.map(&:pipeline_name)).to include(BulkImports::Projects::Pipelines::ProjectPipeline.to_s)
end
end
diff --git a/spec/models/bulk_imports/export_status_spec.rb b/spec/models/bulk_imports/export_status_spec.rb
index f945ad12244..79ed6b39358 100644
--- a/spec/models/bulk_imports/export_status_spec.rb
+++ b/spec/models/bulk_imports/export_status_spec.rb
@@ -13,6 +13,10 @@ RSpec.describe BulkImports::ExportStatus do
double(parsed_response: [{ 'relation' => 'labels', 'status' => status, 'error' => 'error!' }])
end
+ let(:invalid_response_double) do
+ double(parsed_response: [{ 'relation' => 'not_a_real_relation', 'status' => status, 'error' => 'error!' }])
+ end
+
subject { described_class.new(tracker, relation) }
before do
@@ -36,6 +40,18 @@ RSpec.describe BulkImports::ExportStatus do
it 'returns false' do
expect(subject.started?).to eq(false)
end
+
+ context 'when returned relation is invalid' do
+ before do
+ allow_next_instance_of(BulkImports::Clients::HTTP) do |client|
+ allow(client).to receive(:get).and_return(invalid_response_double)
+ end
+ end
+
+ it 'returns false' do
+ expect(subject.started?).to eq(false)
+ end
+ end
end
end
@@ -63,7 +79,7 @@ RSpec.describe BulkImports::ExportStatus do
it 'returns true' do
expect(subject.failed?).to eq(true)
- expect(subject.error).to eq('Empty export status response')
+ expect(subject.error).to eq('Empty relation export status')
end
end
end
diff --git a/spec/models/bulk_imports/tracker_spec.rb b/spec/models/bulk_imports/tracker_spec.rb
index a72b628e329..0b6f692a477 100644
--- a/spec/models/bulk_imports/tracker_spec.rb
+++ b/spec/models/bulk_imports/tracker_spec.rb
@@ -66,8 +66,8 @@ RSpec.describe BulkImports::Tracker, type: :model do
describe '#pipeline_class' do
it 'returns the pipeline class' do
- bulk_import = create(:bulk_import)
- pipeline_class = BulkImports::Groups::Stage.new(bulk_import).pipelines.first[1]
+ entity = create(:bulk_import_entity)
+ pipeline_class = BulkImports::Groups::Stage.new(entity).pipelines.first[1]
tracker = create(:bulk_import_tracker, pipeline_name: pipeline_class)
expect(tracker.pipeline_class).to eq(pipeline_class)
diff --git a/spec/models/ci/bridge_spec.rb b/spec/models/ci/bridge_spec.rb
index 7c3c02a5ab7..5ee560c4925 100644
--- a/spec/models/ci/bridge_spec.rb
+++ b/spec/models/ci/bridge_spec.rb
@@ -30,6 +30,12 @@ RSpec.describe Ci::Bridge do
expect(bridge).to have_one(:downstream_pipeline)
end
+ describe '#retryable?' do
+ it 'returns false' do
+ expect(bridge.retryable?).to eq(false)
+ end
+ end
+
describe '#tags' do
it 'only has a bridge tag' do
expect(bridge.tags).to eq [:bridge]
@@ -282,6 +288,26 @@ RSpec.describe Ci::Bridge do
)
end
end
+
+ context 'when the pipeline runs from a pipeline schedule' do
+ let(:pipeline_schedule) { create(:ci_pipeline_schedule, :nightly, project: project ) }
+ let(:pipeline) { create(:ci_pipeline, pipeline_schedule: pipeline_schedule) }
+
+ let(:options) do
+ { trigger: { project: 'my/project', forward: { pipeline_variables: true } } }
+ end
+
+ before do
+ pipeline_schedule.variables.create!(key: 'schedule_var_key', value: 'schedule var value')
+ end
+
+ it 'adds the schedule variable' do
+ expect(bridge.downstream_variables).to contain_exactly(
+ { key: 'BRIDGE', value: 'cross' },
+ { key: 'schedule_var_key', value: 'schedule var value' }
+ )
+ end
+ end
end
end
diff --git a/spec/models/ci/build_dependencies_spec.rb b/spec/models/ci/build_dependencies_spec.rb
index cd330324840..91048cae064 100644
--- a/spec/models/ci/build_dependencies_spec.rb
+++ b/spec/models/ci/build_dependencies_spec.rb
@@ -14,7 +14,7 @@ RSpec.describe Ci::BuildDependencies do
end
let!(:build) { create(:ci_build, pipeline: pipeline, name: 'build', stage_idx: 0, stage: 'build') }
- let!(:rspec_test) { create(:ci_build, pipeline: pipeline, name: 'rspec', stage_idx: 1, stage: 'test') }
+ let!(:rspec_test) { create(:ci_build, :success, pipeline: pipeline, name: 'rspec', stage_idx: 1, stage: 'test') }
let!(:rubocop_test) { create(:ci_build, pipeline: pipeline, name: 'rubocop', stage_idx: 1, stage: 'test') }
let!(:staging) { create(:ci_build, pipeline: pipeline, name: 'staging', stage_idx: 2, stage: 'deploy') }
@@ -48,7 +48,7 @@ RSpec.describe Ci::BuildDependencies do
project.add_developer(user)
end
- let!(:retried_job) { Ci::Build.retry(rspec_test, user) }
+ let!(:retried_job) { Ci::RetryJobService.new(rspec_test.project, user).execute(rspec_test)[:job] }
it 'contains the retried job instead of the original one' do
is_expected.to contain_exactly(build, retried_job, rubocop_test)
diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb
index 240b932638a..fd87a388442 100644
--- a/spec/models/ci/build_spec.rb
+++ b/spec/models/ci/build_spec.rb
@@ -1585,6 +1585,31 @@ RSpec.describe Ci::Build do
it { is_expected.to eq('review/x') }
end
+
+ context 'when environment name uses a nested variable' do
+ let(:yaml_variables) do
+ [
+ { key: 'ENVIRONMENT_NAME', value: '${CI_COMMIT_REF_NAME}' }
+ ]
+ end
+
+ let(:build) do
+ create(:ci_build,
+ ref: 'master',
+ yaml_variables: yaml_variables,
+ environment: 'review/$ENVIRONMENT_NAME')
+ end
+
+ it { is_expected.to eq('review/master') }
+
+ context 'when the FF ci_expand_environment_name_and_url is disabled' do
+ before do
+ stub_feature_flags(ci_expand_environment_name_and_url: false)
+ end
+
+ it { is_expected.to eq('review/${CI_COMMIT_REF_NAME}') }
+ end
+ end
end
describe '#expanded_kubernetes_namespace' do
@@ -1951,90 +1976,6 @@ RSpec.describe Ci::Build do
end
end
- describe '#retryable?' do
- subject { build }
-
- context 'when build is retryable' do
- context 'when build is successful' do
- before do
- build.success!
- end
-
- it { is_expected.to be_retryable }
- end
-
- context 'when build is failed' do
- before do
- build.drop!
- end
-
- it { is_expected.to be_retryable }
- end
-
- context 'when build is canceled' do
- before do
- build.cancel!
- end
-
- it { is_expected.to be_retryable }
- end
- end
-
- context 'when build is not retryable' do
- context 'when build is running' do
- before do
- build.run!
- end
-
- it { is_expected.not_to be_retryable }
- end
-
- context 'when build is skipped' do
- before do
- build.skip!
- end
-
- it { is_expected.not_to be_retryable }
- end
-
- context 'when build is degenerated' do
- before do
- build.degenerate!
- end
-
- it { is_expected.not_to be_retryable }
- end
-
- context 'when a canceled build has been retried already' do
- before do
- project.add_developer(user)
- build.cancel!
- described_class.retry(build, user)
- end
-
- it { is_expected.not_to be_retryable }
- end
-
- context 'when deployment is rejected' do
- before do
- build.drop!(:deployment_rejected)
- end
-
- it { is_expected.not_to be_retryable }
- end
-
- context 'when build is waiting for deployment approval' do
- subject { build_stubbed(:ci_build, :manual, environment: 'production') }
-
- before do
- create(:deployment, :blocked, deployable: subject)
- end
-
- it { is_expected.not_to be_retryable }
- end
- end
- end
-
describe '#action?' do
before do
build.update!(when: value)
@@ -2308,7 +2249,7 @@ RSpec.describe Ci::Build do
describe '#options' do
let(:options) do
{
- image: "ruby:2.7",
+ image: "image:1.0",
services: ["postgres"],
script: ["ls -a"]
}
@@ -2319,7 +2260,7 @@ RSpec.describe Ci::Build do
end
it 'allows to access with symbolized keys' do
- expect(build.options[:image]).to eq('ruby:2.7')
+ expect(build.options[:image]).to eq('image:1.0')
end
it 'rejects access with string keys' do
@@ -2358,24 +2299,12 @@ RSpec.describe Ci::Build do
end
context 'when build is retried' do
- let!(:new_build) { described_class.retry(build, user) }
+ let!(:new_build) { Ci::RetryJobService.new(project, user).execute(build)[:job] }
it 'does not return any of them' do
is_expected.not_to include(build, new_build)
end
end
-
- context 'when other build is retried' do
- let!(:retried_build) { described_class.retry(other_build, user) }
-
- before do
- retried_build.success
- end
-
- it 'returns a retried build' do
- is_expected.to contain_exactly(retried_build)
- end
- end
end
describe '#other_scheduled_actions' do
@@ -3962,8 +3891,9 @@ RSpec.describe Ci::Build do
subject { create(:ci_build, :running, options: { script: ["ls -al"], retry: 3 }, project: project, user: user) }
it 'retries build and assigns the same user to it' do
- expect(described_class).to receive(:retry)
- .with(subject, user)
+ expect_next_instance_of(::Ci::RetryJobService) do |service|
+ expect(service).to receive(:execute).with(subject)
+ end
subject.drop!
end
@@ -3977,10 +3907,10 @@ RSpec.describe Ci::Build do
end
context 'when retry service raises Gitlab::Access::AccessDeniedError exception' do
- let(:retry_service) { Ci::RetryBuildService.new(subject.project, subject.user) }
+ let(:retry_service) { Ci::RetryJobService.new(subject.project, subject.user) }
before do
- allow_any_instance_of(Ci::RetryBuildService)
+ allow_any_instance_of(Ci::RetryJobService)
.to receive(:execute)
.with(subject)
.and_raise(Gitlab::Access::AccessDeniedError)
diff --git a/spec/models/ci/job_artifact_spec.rb b/spec/models/ci/job_artifact_spec.rb
index bd0397e0396..24c318d0218 100644
--- a/spec/models/ci/job_artifact_spec.rb
+++ b/spec/models/ci/job_artifact_spec.rb
@@ -279,6 +279,15 @@ RSpec.describe Ci::JobArtifact do
end
end
+ describe '.order_expired_asc' do
+ let_it_be(:first_artifact) { create(:ci_job_artifact, expire_at: 2.days.ago) }
+ let_it_be(:second_artifact) { create(:ci_job_artifact, expire_at: 1.day.ago) }
+
+ it 'returns ordered artifacts' do
+ expect(described_class.order_expired_asc).to eq([first_artifact, second_artifact])
+ end
+ end
+
describe '.for_project' do
it 'returns artifacts only for given project(s)', :aggregate_failures do
artifact1 = create(:ci_job_artifact)
@@ -700,10 +709,6 @@ RSpec.describe Ci::JobArtifact do
MSG
end
- it_behaves_like 'it has loose foreign keys' do
- let(:factory_name) { :ci_job_artifact }
- end
-
context 'loose foreign key on ci_job_artifacts.project_id' do
it_behaves_like 'cleanup by a loose foreign key' do
let!(:parent) { create(:project) }
diff --git a/spec/models/ci/namespace_mirror_spec.rb b/spec/models/ci/namespace_mirror_spec.rb
index 38471f15849..9b4e86916b8 100644
--- a/spec/models/ci/namespace_mirror_spec.rb
+++ b/spec/models/ci/namespace_mirror_spec.rb
@@ -44,6 +44,53 @@ RSpec.describe Ci::NamespaceMirror do
end
end
+ describe '.contains_traversal_ids' do
+ let!(:other_group1) { create(:group) }
+ let!(:other_group2) { create(:group, parent: other_group1) }
+ let!(:other_group3) { create(:group, parent: other_group2) }
+ let!(:other_group4) { create(:group) }
+
+ subject(:result) { described_class.contains_traversal_ids(all_traversal_ids) }
+
+ context 'when passing a top-level group' do
+ let(:all_traversal_ids) do
+ [
+ [other_group1.id]
+ ]
+ end
+
+ it 'returns only itself and children of that group' do
+ expect(result.map(&:namespace)).to contain_exactly(other_group1, other_group2, other_group3)
+ end
+ end
+
+ context 'when passing many levels of groups' do
+ let(:all_traversal_ids) do
+ [
+ [other_group2.parent_id, other_group2.id],
+ [other_group3.parent_id, other_group3.id],
+ [other_group4.id]
+ ]
+ end
+
+ it 'returns only the asked group' do
+ expect(result.map(&:namespace)).to contain_exactly(other_group2, other_group3, other_group4)
+ end
+ end
+
+ context 'when passing invalid data ' do
+ let(:all_traversal_ids) do
+ [
+ ["; UPDATE"]
+ ]
+ end
+
+ it 'data is properly sanitised' do
+ expect(result.to_sql).to include "((traversal_ids[1])) IN (('; UPDATE'))"
+ end
+ end
+ end
+
describe '.by_namespace_id' do
subject(:result) { described_class.by_namespace_id(group2.id) }
diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb
index 294ec07ee3e..45b51d5bf44 100644
--- a/spec/models/ci/pipeline_spec.rb
+++ b/spec/models/ci/pipeline_spec.rb
@@ -1146,6 +1146,50 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
end
end
+
+ describe 'variable CI_GITLAB_FIPS_MODE' do
+ context 'when FIPS flag is enabled' do
+ before do
+ allow(Gitlab::FIPS).to receive(:enabled?).and_return(true)
+ end
+
+ it "is included with value 'true'" do
+ expect(subject.to_hash).to include('CI_GITLAB_FIPS_MODE' => 'true')
+ end
+ end
+
+ context 'when FIPS flag is disabled' do
+ before do
+ allow(Gitlab::FIPS).to receive(:enabled?).and_return(false)
+ end
+
+ it 'is not included' do
+ expect(subject.to_hash).not_to have_key('CI_GITLAB_FIPS_MODE')
+ end
+ end
+ end
+
+ context 'without a commit' do
+ let(:pipeline) { build(:ci_empty_pipeline, :created, sha: nil) }
+
+ it 'does not expose commit variables' do
+ expect(subject.to_hash.keys)
+ .not_to include(
+ 'CI_COMMIT_SHA',
+ 'CI_COMMIT_SHORT_SHA',
+ 'CI_COMMIT_BEFORE_SHA',
+ 'CI_COMMIT_REF_NAME',
+ 'CI_COMMIT_REF_SLUG',
+ 'CI_COMMIT_BRANCH',
+ 'CI_COMMIT_TAG',
+ 'CI_COMMIT_MESSAGE',
+ 'CI_COMMIT_TITLE',
+ 'CI_COMMIT_DESCRIPTION',
+ 'CI_COMMIT_REF_PROTECTED',
+ 'CI_COMMIT_TIMESTAMP',
+ 'CI_COMMIT_AUTHOR')
+ end
+ end
end
describe '#protected_ref?' do
@@ -1663,7 +1707,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
expect(upstream_pipeline.reload).to be_failed
Sidekiq::Testing.inline! do
- new_job = Ci::Build.retry(job, project.users.first)
+ new_job = Ci::RetryJobService.new(project, project.users.first).execute(job)[:job]
expect(downstream_pipeline.reload).to be_running
expect(upstream_pipeline.reload).to be_running
@@ -1684,7 +1728,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
expect(upstream_pipeline.reload).to be_success
Sidekiq::Testing.inline! do
- new_job = Ci::Build.retry(job, project.users.first)
+ new_job = Ci::RetryJobService.new(project, project.users.first).execute(job)[:job]
expect(downstream_pipeline.reload).to be_running
expect(upstream_pipeline.reload).to be_running
@@ -1715,7 +1759,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
expect(upstream_of_upstream_pipeline.reload).to be_failed
Sidekiq::Testing.inline! do
- new_job = Ci::Build.retry(job, project.users.first)
+ new_job = Ci::RetryJobService.new(project, project.users.first).execute(job)[:job]
expect(downstream_pipeline.reload).to be_running
expect(upstream_pipeline.reload).to be_running
@@ -2583,8 +2627,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
build.drop
project.add_developer(user)
-
- Ci::Build.retry(build, user)
+ ::Ci::RetryJobService.new(project, user).execute(build)[:job]
end
# We are changing a state: created > failed > running
@@ -4688,7 +4731,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
project.add_developer(user)
retried_build.cancel!
- ::Ci::Build.retry(retried_build, user)
+ Ci::RetryJobService.new(project, user).execute(retried_build)[:job]
end
it 'does not include retried builds' do
@@ -4714,6 +4757,24 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
end
+ describe '#has_expired_test_reports?' do
+ subject { pipeline_with_test_report.has_expired_test_reports? }
+
+ let(:pipeline_with_test_report) { create(:ci_pipeline, :with_test_reports) }
+
+ context 'when artifacts are not expired' do
+ it { is_expected.to be_falsey }
+ end
+
+ context 'when artifacts are expired' do
+ before do
+ pipeline_with_test_report.job_artifacts.first.update!(expire_at: Date.yesterday)
+ end
+
+ it { is_expected.to be_truthy }
+ end
+ end
+
it_behaves_like 'it has loose foreign keys' do
let(:factory_name) { :ci_pipeline }
end
diff --git a/spec/models/ci/processable_spec.rb b/spec/models/ci/processable_spec.rb
index ac1a8247aaa..71fef3c1b5b 100644
--- a/spec/models/ci/processable_spec.rb
+++ b/spec/models/ci/processable_spec.rb
@@ -14,6 +14,100 @@ RSpec.describe Ci::Processable do
it { is_expected.to delegate_method(:legacy_detached_merge_request_pipeline?).to(:pipeline) }
end
+ describe '#retryable' do
+ shared_examples_for 'retryable processable' do
+ context 'when processable is successful' do
+ before do
+ processable.success!
+ end
+
+ it { is_expected.to be_retryable }
+ end
+
+ context 'when processable is failed' do
+ before do
+ processable.drop!
+ end
+
+ it { is_expected.to be_retryable }
+ end
+
+ context 'when processable is canceled' do
+ before do
+ processable.cancel!
+ end
+
+ it { is_expected.to be_retryable }
+ end
+ end
+
+ shared_examples_for 'non-retryable processable' do
+ context 'when processable is skipped' do
+ before do
+ processable.skip!
+ end
+
+ it { is_expected.not_to be_retryable }
+ end
+
+ context 'when processable is degenerated' do
+ before do
+ processable.degenerate!
+ end
+
+ it { is_expected.not_to be_retryable }
+ end
+
+ context 'when a canceled processable has been retried already' do
+ before do
+ project.add_developer(create(:user))
+ processable.cancel!
+ processable.update!(retried: true)
+ end
+
+ it { is_expected.not_to be_retryable }
+ end
+ end
+
+ context 'when the processable is a build' do
+ subject(:processable) { create(:ci_build, pipeline: pipeline) }
+
+ context 'when the processable is retryable' do
+ it_behaves_like 'retryable processable'
+
+ context 'when deployment is rejected' do
+ before do
+ processable.drop!(:deployment_rejected)
+ end
+
+ it { is_expected.not_to be_retryable }
+ end
+
+ context 'when build is waiting for deployment approval' do
+ subject { build_stubbed(:ci_build, :manual, environment: 'production') }
+
+ before do
+ create(:deployment, :blocked, deployable: subject)
+ end
+
+ it { is_expected.not_to be_retryable }
+ end
+ end
+
+ context 'when the processable is non-retryable' do
+ it_behaves_like 'non-retryable processable'
+
+ context 'when processable is running' do
+ before do
+ processable.run!
+ end
+
+ it { is_expected.not_to be_retryable }
+ end
+ end
+ end
+ end
+
describe '#aggregated_needs_names' do
let(:with_aggregated_needs) { pipeline.processables.select_with_aggregated_needs(project) }
diff --git a/spec/models/ci/runner_spec.rb b/spec/models/ci/runner_spec.rb
index 42187c3ef99..05b7bc39a74 100644
--- a/spec/models/ci/runner_spec.rb
+++ b/spec/models/ci/runner_spec.rb
@@ -134,28 +134,28 @@ RSpec.describe Ci::Runner do
end
context 'cost factors validations' do
- it 'dissalows :private_projects_minutes_cost_factor being nil' do
+ it 'disallows :private_projects_minutes_cost_factor being nil' do
runner = build(:ci_runner, private_projects_minutes_cost_factor: nil)
expect(runner).to be_invalid
expect(runner.errors.full_messages).to include('Private projects minutes cost factor needs to be non-negative')
end
- it 'dissalows :public_projects_minutes_cost_factor being nil' do
+ it 'disallows :public_projects_minutes_cost_factor being nil' do
runner = build(:ci_runner, public_projects_minutes_cost_factor: nil)
expect(runner).to be_invalid
expect(runner.errors.full_messages).to include('Public projects minutes cost factor needs to be non-negative')
end
- it 'dissalows :private_projects_minutes_cost_factor being negative' do
+ it 'disallows :private_projects_minutes_cost_factor being negative' do
runner = build(:ci_runner, private_projects_minutes_cost_factor: -1.1)
expect(runner).to be_invalid
expect(runner.errors.full_messages).to include('Private projects minutes cost factor needs to be non-negative')
end
- it 'dissalows :public_projects_minutes_cost_factor being negative' do
+ it 'disallows :public_projects_minutes_cost_factor being negative' do
runner = build(:ci_runner, public_projects_minutes_cost_factor: -2.2)
expect(runner).to be_invalid
diff --git a/spec/models/ci/secure_file_spec.rb b/spec/models/ci/secure_file_spec.rb
index 4382385aaf5..f92db3fe8db 100644
--- a/spec/models/ci/secure_file_spec.rb
+++ b/spec/models/ci/secure_file_spec.rb
@@ -3,14 +3,14 @@
require 'spec_helper'
RSpec.describe Ci::SecureFile do
- let(:sample_file) { fixture_file('ci_secure_files/upload-keystore.jks') }
-
- subject { create(:ci_secure_file) }
-
before do
stub_ci_secure_file_object_storage
end
+ let(:sample_file) { fixture_file('ci_secure_files/upload-keystore.jks') }
+
+ subject { create(:ci_secure_file, file: CarrierWaveStringFile.new(sample_file)) }
+
it { is_expected.to be_a FileStoreMounter }
it { is_expected.to belong_to(:project).required }
@@ -27,6 +27,26 @@ RSpec.describe Ci::SecureFile do
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_presence_of(:permissions) }
it { is_expected.to validate_presence_of(:project_id) }
+ context 'unique filename' do
+ let_it_be(:project1) { create(:project) }
+
+ it 'ensures the file name is unique within a given project' do
+ file1 = create(:ci_secure_file, project: project1, name: 'file1')
+ expect do
+ create(:ci_secure_file, project: project1, name: 'file1')
+ end.to raise_error(ActiveRecord::RecordInvalid, 'Validation failed: Name has already been taken')
+
+ expect(project1.secure_files.where(name: 'file1').count).to be 1
+ expect(project1.secure_files.find_by(name: 'file1').id).to eq(file1.id)
+ end
+
+ it 'allows duplicate file names in different projects' do
+ create(:ci_secure_file, project: project1)
+ expect do
+ create(:ci_secure_file, project: create(:project))
+ end.not_to raise_error
+ end
+ end
end
describe '#permissions' do
@@ -37,8 +57,6 @@ RSpec.describe Ci::SecureFile do
describe '#checksum' do
it 'computes SHA256 checksum on the file before encrypted' do
- subject.file = CarrierWaveStringFile.new(sample_file)
- subject.save!
expect(subject.checksum).to eq(Digest::SHA256.hexdigest(sample_file))
end
end
@@ -51,8 +69,6 @@ RSpec.describe Ci::SecureFile do
describe '#file' do
it 'returns the saved file' do
- subject.file = CarrierWaveStringFile.new(sample_file)
- subject.save!
expect(Base64.encode64(subject.file.read)).to eq(Base64.encode64(sample_file))
end
end
diff --git a/spec/models/clusters/agent_spec.rb b/spec/models/clusters/agent_spec.rb
index f279e779de5..f10e0cc8fa7 100644
--- a/spec/models/clusters/agent_spec.rb
+++ b/spec/models/clusters/agent_spec.rb
@@ -117,6 +117,23 @@ RSpec.describe Clusters::Agent do
end
end
+ describe '#last_used_agent_tokens' do
+ let_it_be(:agent) { create(:cluster_agent) }
+
+ subject { agent.last_used_agent_tokens }
+
+ context 'agent has no tokens' do
+ it { is_expected.to be_empty }
+ end
+
+ context 'agent has active and inactive tokens' do
+ let!(:active_token) { create(:cluster_agent_token, agent: agent, last_used_at: 1.minute.ago) }
+ let!(:inactive_token) { create(:cluster_agent_token, agent: agent, last_used_at: 2.hours.ago) }
+
+ it { is_expected.to contain_exactly(active_token, inactive_token) }
+ end
+ end
+
describe '#activity_event_deletion_cutoff' do
let_it_be(:agent) { create(:cluster_agent) }
let_it_be(:event1) { create(:agent_activity_event, agent: agent, recorded_at: 1.hour.ago) }
diff --git a/spec/models/commit_status_spec.rb b/spec/models/commit_status_spec.rb
index 86ee159b97e..155e0fbb0e9 100644
--- a/spec/models/commit_status_spec.rb
+++ b/spec/models/commit_status_spec.rb
@@ -994,4 +994,11 @@ RSpec.describe CommitStatus do
let!(:model) { create(:ci_build, project: parent) }
end
end
+
+ context 'loose foreign key on ci_builds.runner_id' do
+ it_behaves_like 'cleanup by a loose foreign key' do
+ let!(:parent) { create(:ci_runner) }
+ let!(:model) { create(:ci_build, runner: parent) }
+ end
+ end
end
diff --git a/spec/models/concerns/approvable_base_spec.rb b/spec/models/concerns/approvable_base_spec.rb
index 79053e98db7..2bf6a98a64d 100644
--- a/spec/models/concerns/approvable_base_spec.rb
+++ b/spec/models/concerns/approvable_base_spec.rb
@@ -36,7 +36,7 @@ RSpec.describe ApprovableBase do
subject { merge_request.can_be_approved_by?(user) }
before do
- merge_request.project.add_developer(user)
+ merge_request.project.add_developer(user) if user
end
it 'returns true' do
@@ -64,7 +64,7 @@ RSpec.describe ApprovableBase do
subject { merge_request.can_be_unapproved_by?(user) }
before do
- merge_request.project.add_developer(user)
+ merge_request.project.add_developer(user) if user
end
it 'returns false' do
diff --git a/spec/models/concerns/batch_nullify_dependent_associations_spec.rb b/spec/models/concerns/batch_nullify_dependent_associations_spec.rb
new file mode 100644
index 00000000000..933464f301a
--- /dev/null
+++ b/spec/models/concerns/batch_nullify_dependent_associations_spec.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BatchNullifyDependentAssociations do
+ before do
+ test_user = Class.new(ActiveRecord::Base) do
+ self.table_name = 'users'
+
+ has_many :closed_issues, foreign_key: :closed_by_id, class_name: 'Issue', dependent: :nullify
+ has_many :issues, foreign_key: :author_id, class_name: 'Issue', dependent: :nullify
+ has_many :updated_issues, foreign_key: :updated_by_id, class_name: 'Issue'
+
+ include BatchNullifyDependentAssociations
+ end
+
+ stub_const('TestUser', test_user)
+ end
+
+ describe '.dependent_associations_to_nullify' do
+ it 'returns only associations with `dependent: :nullify` associations' do
+ expect(TestUser.dependent_associations_to_nullify.map(&:name)).to match_array([:closed_issues, :issues])
+ end
+ end
+
+ describe '#nullify_dependent_associations_in_batches' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:updated_by_issue) { create(:issue, updated_by: user) }
+
+ before do
+ create(:issue, closed_by: user)
+ create(:issue, closed_by: user)
+ end
+
+ it 'nullifies multiple settings' do
+ expect do
+ test_user = TestUser.find(user.id)
+ test_user.nullify_dependent_associations_in_batches
+ end.to change { Issue.where(closed_by_id: user.id).count }.by(-2)
+ end
+
+ it 'excludes associations' do
+ expect do
+ test_user = TestUser.find(user.id)
+ test_user.nullify_dependent_associations_in_batches(exclude: [:closed_issues])
+ end.not_to change { Issue.where(closed_by_id: user.id).count }
+ end
+ end
+end
diff --git a/spec/models/concerns/featurable_spec.rb b/spec/models/concerns/featurable_spec.rb
index 453b6f7f29a..bf104fe1b30 100644
--- a/spec/models/concerns/featurable_spec.rb
+++ b/spec/models/concerns/featurable_spec.rb
@@ -3,171 +3,101 @@
require 'spec_helper'
RSpec.describe Featurable do
- let_it_be(:user) { create(:user) }
+ let!(:klass) do
+ Class.new(ApplicationRecord) do
+ include Featurable
- let(:project) { create(:project) }
- let(:feature_class) { subject.class }
- let(:features) { feature_class::FEATURES }
+ self.table_name = 'project_features'
- subject { project.project_feature }
+ set_available_features %i(feature1 feature2 feature3)
- describe '.quoted_access_level_column' do
- it 'returns the table name and quoted column name for a feature' do
- expected = '"project_features"."issues_access_level"'
-
- expect(feature_class.quoted_access_level_column(:issues)).to eq(expected)
- end
- end
+ def feature1_access_level
+ Featurable::DISABLED
+ end
- describe '.access_level_attribute' do
- it { expect(feature_class.access_level_attribute(:wiki)).to eq :wiki_access_level }
+ def feature2_access_level
+ Featurable::ENABLED
+ end
- it 'raises error for unspecified feature' do
- expect { feature_class.access_level_attribute(:unknown) }
- .to raise_error(ArgumentError, /invalid feature: unknown/)
+ def feature3_access_level
+ Featurable::PRIVATE
+ end
end
end
- describe '.set_available_features' do
- let!(:klass) do
- Class.new(ApplicationRecord) do
- include Featurable
+ subject { klass.new }
- self.table_name = 'project_features'
-
- set_available_features %i(feature1 feature2)
+ describe '.set_available_features' do
+ it { expect(klass.available_features).to match_array [:feature1, :feature2, :feature3] }
+ end
- def feature1_access_level
- Featurable::DISABLED
- end
+ describe '#*_enabled?' do
+ it { expect(subject.feature1_enabled?).to be_falsey }
+ it { expect(subject.feature2_enabled?).to be_truthy }
+ end
- def feature2_access_level
- Featurable::ENABLED
- end
- end
+ describe '.quoted_access_level_column' do
+ it 'returns the table name and quoted column name for a feature' do
+ expect(klass.quoted_access_level_column(:feature1)).to eq('"project_features"."feature1_access_level"')
end
-
- let!(:instance) { klass.new }
-
- it { expect(klass.available_features).to eq [:feature1, :feature2] }
- it { expect(instance.feature1_enabled?).to be_falsey }
- it { expect(instance.feature2_enabled?).to be_truthy }
end
- describe '.available_features' do
- it { expect(feature_class.available_features).to include(*features) }
+ describe '.access_level_attribute' do
+ it { expect(klass.access_level_attribute(:feature1)).to eq :feature1_access_level }
+
+ it 'raises error for unspecified feature' do
+ expect { klass.access_level_attribute(:unknown) }
+ .to raise_error(ArgumentError, /invalid feature: unknown/)
+ end
end
describe '#access_level' do
it 'returns access level' do
- expect(subject.access_level(:wiki)).to eq(subject.wiki_access_level)
+ expect(subject.access_level(:feature1)).to eq(subject.feature1_access_level)
end
end
describe '#feature_available?' do
- let(:features) { %w(issues wiki builds merge_requests snippets repository pages metrics_dashboard) }
-
context 'when features are disabled' do
- it "returns false" do
- update_all_project_features(project, features, ProjectFeature::DISABLED)
-
- features.each do |feature|
- expect(project.feature_available?(feature.to_sym, user)).to eq(false), "#{feature} failed"
- end
+ it 'returns false' do
+ expect(subject.feature_available?(:feature1)).to eq(false)
end
end
context 'when features are enabled only for team members' do
- it "returns false when user is not a team member" do
- update_all_project_features(project, features, ProjectFeature::PRIVATE)
+ let_it_be(:user) { create(:user) }
- features.each do |feature|
- expect(project.feature_available?(feature.to_sym, user)).to eq(false), "#{feature} failed"
- end
+ before do
+ expect(subject).to receive(:member?).and_call_original
end
- it "returns true when user is a team member" do
- project.add_developer(user)
-
- update_all_project_features(project, features, ProjectFeature::PRIVATE)
-
- features.each do |feature|
- expect(project.feature_available?(feature.to_sym, user)).to eq(true), "#{feature} failed"
+ context 'when user is not present' do
+ it 'returns false' do
+ expect(subject.feature_available?(:feature3)).to eq(false)
end
end
- it "returns true when user is a member of project group" do
- group = create(:group)
- project = create(:project, namespace: group)
- group.add_developer(user)
+ context 'when user can read all resources' do
+ it 'returns true' do
+ allow(user).to receive(:can_read_all_resources?).and_return(true)
- update_all_project_features(project, features, ProjectFeature::PRIVATE)
-
- features.each do |feature|
- expect(project.feature_available?(feature.to_sym, user)).to eq(true), "#{feature} failed"
+ expect(subject.feature_available?(:feature3, user)).to eq(true)
end
end
- context 'when admin mode is enabled', :enable_admin_mode do
- it "returns true if user is an admin" do
- user.update_attribute(:admin, true)
-
- update_all_project_features(project, features, ProjectFeature::PRIVATE)
+ context 'when user cannot read all resources' do
+ it 'raises NotImplementedError exception' do
+ expect(subject).to receive(:resource_member?).and_call_original
- features.each do |feature|
- expect(project.feature_available?(feature.to_sym, user)).to eq(true), "#{feature} failed"
- end
- end
- end
-
- context 'when admin mode is disabled' do
- it "returns false when user is an admin" do
- user.update_attribute(:admin, true)
-
- update_all_project_features(project, features, ProjectFeature::PRIVATE)
-
- features.each do |feature|
- expect(project.feature_available?(feature.to_sym, user)).to eq(false), "#{feature} failed"
- end
+ expect { subject.feature_available?(:feature3, user) }.to raise_error(NotImplementedError)
end
end
end
context 'when feature is enabled for everyone' do
- it "returns true" do
- expect(project.feature_available?(:issues, user)).to eq(true)
+ it 'returns true' do
+ expect(subject.feature_available?(:feature2)).to eq(true)
end
end
end
-
- describe '#*_enabled?' do
- let(:features) { %w(wiki builds merge_requests) }
-
- it "returns false when feature is disabled" do
- update_all_project_features(project, features, ProjectFeature::DISABLED)
-
- features.each do |feature|
- expect(project.public_send("#{feature}_enabled?")).to eq(false), "#{feature} failed"
- end
- end
-
- it "returns true when feature is enabled only for team members" do
- update_all_project_features(project, features, ProjectFeature::PRIVATE)
-
- features.each do |feature|
- expect(project.public_send("#{feature}_enabled?")).to eq(true), "#{feature} failed"
- end
- end
-
- it "returns true when feature is enabled for everyone" do
- features.each do |feature|
- expect(project.public_send("#{feature}_enabled?")).to eq(true), "#{feature} failed"
- end
- end
- end
-
- def update_all_project_features(project, features, value)
- project_feature_attributes = features.to_h { |f| ["#{f}_access_level", value] }
- project.project_feature.update!(project_feature_attributes)
- end
end
diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb
index e3c0e3a7a2b..b38135fc0b2 100644
--- a/spec/models/concerns/issuable_spec.rb
+++ b/spec/models/concerns/issuable_spec.rb
@@ -625,6 +625,16 @@ RSpec.describe Issuable do
end
end
+ describe "#labels_hook_attrs" do
+ let(:project) { create(:project) }
+ let(:label) { create(:label) }
+ let(:issue) { create(:labeled_issue, project: project, labels: [label]) }
+
+ it "returns a list of label hook attributes" do
+ expect(issue.labels_hook_attrs).to match_array([label.hook_attrs])
+ end
+ end
+
describe '.labels_hash' do
let(:feature_label) { create(:label, title: 'Feature') }
let(:second_label) { create(:label, title: 'Second Label') }
diff --git a/spec/models/concerns/sensitive_serializable_hash_spec.rb b/spec/models/concerns/sensitive_serializable_hash_spec.rb
index 923f9e80c1f..c864ecb4eec 100644
--- a/spec/models/concerns/sensitive_serializable_hash_spec.rb
+++ b/spec/models/concerns/sensitive_serializable_hash_spec.rb
@@ -30,16 +30,6 @@ RSpec.describe SensitiveSerializableHash do
expect(model.serializable_hash(unsafe_serialization_hash: true)).to include('super_secret')
end
end
-
- context 'when prevent_sensitive_fields_from_serializable_hash feature flag is disabled' do
- before do
- stub_feature_flags(prevent_sensitive_fields_from_serializable_hash: false)
- end
-
- it 'includes the field in serializable_hash' do
- expect(model.serializable_hash).to include('super_secret')
- end
- end
end
describe '#serializable_hash' do
diff --git a/spec/models/concerns/taskable_spec.rb b/spec/models/concerns/taskable_spec.rb
index 6b41174a046..140f6cda51c 100644
--- a/spec/models/concerns/taskable_spec.rb
+++ b/spec/models/concerns/taskable_spec.rb
@@ -13,6 +13,10 @@ RSpec.describe Taskable do
- [x] Second item
* [x] First item
* [ ] Second item
+ + [ ] No-break space (U+00A0)
+ + [ ] Figure space (U+2007)
+ + [ ] Narrow no-break space (U+202F)
+ + [ ] Thin space (U+2009)
MARKDOWN
end
@@ -21,7 +25,11 @@ RSpec.describe Taskable do
TaskList::Item.new('- [ ]', 'First item'),
TaskList::Item.new('- [x]', 'Second item'),
TaskList::Item.new('* [x]', 'First item'),
- TaskList::Item.new('* [ ]', 'Second item')
+ TaskList::Item.new('* [ ]', 'Second item'),
+ TaskList::Item.new('+ [ ]', 'No-break space (U+00A0)'),
+ TaskList::Item.new('+ [ ]', 'Figure space (U+2007)'),
+ TaskList::Item.new('+ [ ]', 'Narrow no-break space (U+202F)'),
+ TaskList::Item.new('+ [ ]', 'Thin space (U+2009)')
]
end
diff --git a/spec/models/container_repository_spec.rb b/spec/models/container_repository_spec.rb
index c8d86edc55f..2ea042fb767 100644
--- a/spec/models/container_repository_spec.rb
+++ b/spec/models/container_repository_spec.rb
@@ -122,6 +122,27 @@ RSpec.describe ContainerRepository, :aggregate_failures do
expect(repository).to be_import_aborted
end
end
+
+ context 'already imported' do
+ it 'finishes the import' do
+ expect(repository).to receive(:migration_pre_import).and_return(:already_imported)
+
+ expect { subject }
+ .to change { repository.reload.migration_state }.to('import_done')
+ .and change { repository.reload.migration_skipped_reason }.to('native_import')
+ end
+ end
+
+ context 'non-existing repository' do
+ it 'finishes the import' do
+ expect(repository).to receive(:migration_pre_import).and_return(:not_found)
+
+ expect { subject }
+ .to change { repository.reload.migration_state }.to('import_done')
+ .and change { repository.migration_skipped_reason }.to('not_found')
+ .and change { repository.migration_import_done_at }.from(nil)
+ end
+ end
end
shared_examples 'transitioning to importing', skip_import_success: true do
@@ -151,6 +172,16 @@ RSpec.describe ContainerRepository, :aggregate_failures do
expect(repository).to be_import_aborted
end
end
+
+ context 'already imported' do
+ it 'finishes the import' do
+ expect(repository).to receive(:migration_import).and_return(:already_imported)
+
+ expect { subject }
+ .to change { repository.reload.migration_state }.to('import_done')
+ .and change { repository.reload.migration_skipped_reason }.to('native_import')
+ end
+ end
end
shared_examples 'transitioning out of import_aborted' do
@@ -193,7 +224,7 @@ RSpec.describe ContainerRepository, :aggregate_failures do
end
end
- it_behaves_like 'transitioning from allowed states', %w[default]
+ it_behaves_like 'transitioning from allowed states', %w[default pre_importing importing import_aborted]
it_behaves_like 'transitioning to pre_importing'
end
@@ -208,7 +239,7 @@ RSpec.describe ContainerRepository, :aggregate_failures do
end
end
- it_behaves_like 'transitioning from allowed states', %w[import_aborted]
+ it_behaves_like 'transitioning from allowed states', %w[pre_importing importing import_aborted]
it_behaves_like 'transitioning to pre_importing'
it_behaves_like 'transitioning out of import_aborted'
end
@@ -218,7 +249,7 @@ RSpec.describe ContainerRepository, :aggregate_failures do
subject { repository.finish_pre_import }
- it_behaves_like 'transitioning from allowed states', %w[pre_importing import_aborted]
+ it_behaves_like 'transitioning from allowed states', %w[pre_importing importing import_aborted]
it 'sets migration_pre_import_done_at' do
expect { subject }.to change { repository.reload.migration_pre_import_done_at }
@@ -238,7 +269,7 @@ RSpec.describe ContainerRepository, :aggregate_failures do
end
end
- it_behaves_like 'transitioning from allowed states', %w[pre_import_done]
+ it_behaves_like 'transitioning from allowed states', %w[pre_import_done pre_importing importing import_aborted]
it_behaves_like 'transitioning to importing'
end
@@ -253,7 +284,7 @@ RSpec.describe ContainerRepository, :aggregate_failures do
end
end
- it_behaves_like 'transitioning from allowed states', %w[import_aborted]
+ it_behaves_like 'transitioning from allowed states', %w[pre_importing importing import_aborted]
it_behaves_like 'transitioning to importing'
it_behaves_like 'no action when feature flag is disabled'
end
@@ -263,7 +294,7 @@ RSpec.describe ContainerRepository, :aggregate_failures do
subject { repository.finish_import }
- it_behaves_like 'transitioning from allowed states', %w[importing import_aborted]
+ it_behaves_like 'transitioning from allowed states', %w[default pre_importing importing import_aborted]
it_behaves_like 'queueing the next import'
it 'sets migration_import_done_at and queues the next import' do
@@ -302,6 +333,19 @@ RSpec.describe ContainerRepository, :aggregate_failures do
expect(repository.migration_aborted_in_state).to eq('importing')
expect(repository).to be_import_aborted
end
+
+ context 'above the max retry limit' do
+ before do
+ stub_application_setting(container_registry_import_max_retries: 1)
+ end
+
+ it 'skips the migration' do
+ expect { subject }.to change { repository.migration_skipped_at }
+
+ expect(repository.reload).to be_import_skipped
+ expect(repository.migration_skipped_reason).to eq('too_many_retries')
+ end
+ end
end
describe '#skip_import' do
@@ -309,7 +353,7 @@ RSpec.describe ContainerRepository, :aggregate_failures do
subject { repository.skip_import(reason: :too_many_retries) }
- it_behaves_like 'transitioning from allowed states', ContainerRepository::ABORTABLE_MIGRATION_STATES
+ it_behaves_like 'transitioning from allowed states', ContainerRepository::SKIPPABLE_MIGRATION_STATES
it 'sets migration_skipped_at and migration_skipped_reason' do
expect { subject }.to change { repository.reload.migration_skipped_at }
@@ -334,7 +378,7 @@ RSpec.describe ContainerRepository, :aggregate_failures do
end
end
- it_behaves_like 'transitioning from allowed states', %w[pre_importing import_aborted]
+ it_behaves_like 'transitioning from allowed states', %w[pre_importing importing import_aborted]
it_behaves_like 'transitioning to importing'
end
end
@@ -391,7 +435,7 @@ RSpec.describe ContainerRepository, :aggregate_failures do
describe '#retry_aborted_migration' do
subject { repository.retry_aborted_migration }
- shared_examples 'no action' do
+ context 'when migration_state is not aborted' do
it 'does nothing' do
expect { subject }.not_to change { repository.reload.migration_state }
@@ -399,104 +443,45 @@ RSpec.describe ContainerRepository, :aggregate_failures do
end
end
- shared_examples 'retrying the pre_import' do
- it 'retries the pre_import' do
- expect(repository).to receive(:migration_pre_import).and_return(:ok)
-
- expect { subject }.to change { repository.reload.migration_state }.to('pre_importing')
- end
- end
-
- shared_examples 'retrying the import' do
- it 'retries the import' do
- expect(repository).to receive(:migration_import).and_return(:ok)
-
- expect { subject }.to change { repository.reload.migration_state }.to('importing')
- end
- end
-
- context 'when migration_state is not aborted' do
- it_behaves_like 'no action'
- end
-
context 'when migration_state is aborted' do
before do
repository.abort_import
allow(repository.gitlab_api_client)
- .to receive(:import_status).with(repository.path).and_return(client_response)
+ .to receive(:import_status).with(repository.path).and_return(status)
end
- context 'native response' do
- let(:client_response) { 'native' }
-
- it 'raises an error' do
- expect { subject }.to raise_error(described_class::NativeImportError)
- end
- end
+ it_behaves_like 'reconciling migration_state' do
+ context 'error response' do
+ let(:status) { 'error' }
- context 'import_in_progress response' do
- let(:client_response) { 'import_in_progress' }
-
- it_behaves_like 'no action'
- end
-
- context 'import_complete response' do
- let(:client_response) { 'import_complete' }
-
- it 'finishes the import' do
- expect { subject }.to change { repository.reload.migration_state }.to('import_done')
- end
- end
-
- context 'import_failed response' do
- let(:client_response) { 'import_failed' }
-
- it_behaves_like 'retrying the import'
- end
-
- context 'pre_import_in_progress response' do
- let(:client_response) { 'pre_import_in_progress' }
-
- it_behaves_like 'no action'
- end
+ context 'migration_pre_import_done_at is NULL' do
+ it_behaves_like 'retrying the pre_import'
+ end
- context 'pre_import_complete response' do
- let(:client_response) { 'pre_import_complete' }
+ context 'migration_pre_import_done_at is not NULL' do
+ before do
+ repository.update_columns(
+ migration_pre_import_started_at: 5.minutes.ago,
+ migration_pre_import_done_at: Time.zone.now
+ )
+ end
- it 'finishes the pre_import and starts the import' do
- expect(repository).to receive(:finish_pre_import).and_call_original
- expect(repository).to receive(:migration_import).and_return(:ok)
-
- expect { subject }.to change { repository.reload.migration_state }.to('importing')
+ it_behaves_like 'retrying the import'
+ end
end
end
+ end
+ end
- context 'pre_import_failed response' do
- let(:client_response) { 'pre_import_failed' }
-
- it_behaves_like 'retrying the pre_import'
- end
-
- context 'error response' do
- let(:client_response) { 'error' }
-
- context 'migration_pre_import_done_at is NULL' do
- it_behaves_like 'retrying the pre_import'
- end
-
- context 'migration_pre_import_done_at is not NULL' do
- before do
- repository.update_columns(
- migration_pre_import_started_at: 5.minutes.ago,
- migration_pre_import_done_at: Time.zone.now
- )
- end
+ describe '#reconcile_import_status' do
+ subject { repository.reconcile_import_status(status) }
- it_behaves_like 'retrying the import'
- end
- end
+ before do
+ repository.abort_import
end
+
+ it_behaves_like 'reconciling migration_state'
end
describe '#tag' do
@@ -667,7 +652,7 @@ RSpec.describe ContainerRepository, :aggregate_failures do
context 'supports gitlab api on .com with a recent repository' do
before do
expect(repository.gitlab_api_client).to receive(:supports_gitlab_api?).and_return(true)
- expect(repository.gitlab_api_client).to receive(:repository_details).with(repository.path, with_size: true).and_return(response)
+ expect(repository.gitlab_api_client).to receive(:repository_details).with(repository.path, sizing: :self).and_return(response)
end
context 'with a size_bytes field' do
@@ -722,12 +707,12 @@ RSpec.describe ContainerRepository, :aggregate_failures do
end
context 'registry migration' do
- shared_examples 'handling the migration step' do |step|
- let(:client_response) { :foobar }
+ before do
+ allow(repository.gitlab_api_client).to receive(:supports_gitlab_api?).and_return(true)
+ end
- before do
- allow(repository.gitlab_api_client).to receive(:supports_gitlab_api?).and_return(true)
- end
+ shared_examples 'gitlab migration client request' do |step|
+ let(:client_response) { :foobar }
it 'returns the same response as the client' do
expect(repository.gitlab_api_client)
@@ -746,6 +731,10 @@ RSpec.describe ContainerRepository, :aggregate_failures do
expect(subject).to eq(:error)
end
end
+ end
+
+ shared_examples 'handling the migration step' do |step|
+ it_behaves_like 'gitlab migration client request', step
context 'too many imports' do
it 'raises an error when it receives too_many_imports as a response' do
@@ -767,6 +756,67 @@ RSpec.describe ContainerRepository, :aggregate_failures do
it_behaves_like 'handling the migration step', :import_repository
end
+
+ describe '#migration_cancel' do
+ subject { repository.migration_cancel }
+
+ it_behaves_like 'gitlab migration client request', :cancel_repository_import
+ end
+
+ describe '#force_migration_cancel' do
+ subject { repository.force_migration_cancel }
+
+ shared_examples 'returning the same response as the client' do
+ it 'returns the same response' do
+ expect(repository.gitlab_api_client)
+ .to receive(:cancel_repository_import).with(repository.path, force: true).and_return(client_response)
+
+ expect(subject).to eq(client_response)
+ end
+ end
+
+ context 'successful cancellation' do
+ let(:client_response) { { status: :ok } }
+
+ it_behaves_like 'returning the same response as the client'
+
+ it 'skips the migration' do
+ expect(repository.gitlab_api_client)
+ .to receive(:cancel_repository_import).with(repository.path, force: true).and_return(client_response)
+
+ expect { subject }.to change { repository.reload.migration_state }.to('import_skipped')
+ .and change { repository.migration_skipped_reason }.to('migration_forced_canceled')
+ .and change { repository.migration_skipped_at }
+ end
+ end
+
+ context 'failed cancellation' do
+ let(:client_response) { { status: :error } }
+
+ it_behaves_like 'returning the same response as the client'
+
+ it 'does not skip the migration' do
+ expect(repository.gitlab_api_client)
+ .to receive(:cancel_repository_import).with(repository.path, force: true).and_return(client_response)
+
+ expect { subject }.to not_change { repository.reload.migration_state }
+ .and not_change { repository.migration_skipped_reason }
+ .and not_change { repository.migration_skipped_at }
+ end
+ end
+
+ context 'when the gitlab_api feature is not supported' do
+ before do
+ allow(repository.gitlab_api_client).to receive(:supports_gitlab_api?).and_return(false)
+ end
+
+ it 'returns :error' do
+ expect(repository.gitlab_api_client).not_to receive(:cancel_repository_import)
+
+ expect(subject).to eq(:error)
+ end
+ end
+ end
end
describe '.build_from_path' do
@@ -1081,6 +1131,43 @@ RSpec.describe ContainerRepository, :aggregate_failures do
end
end
+ describe '.all_migrated?' do
+ let_it_be(:project) { create(:project) }
+
+ subject { project.container_repositories.all_migrated? }
+
+ context 'with no repositories' do
+ it { is_expected.to be_truthy }
+ end
+
+ context 'with only recent repositories' do
+ let_it_be(:container_repository1) { create(:container_repository, project: project) }
+ let_it_be_with_reload(:container_repository2) { create(:container_repository, project: project) }
+
+ it { is_expected.to be_truthy }
+
+ context 'with one old non migrated repository' do
+ before do
+ container_repository2.update!(created_at: described_class::MIGRATION_PHASE_1_ENDED_AT - 3.months)
+ end
+
+ it { is_expected.to be_falsey }
+ end
+
+ context 'with one old migrated repository' do
+ before do
+ container_repository2.update!(
+ created_at: described_class::MIGRATION_PHASE_1_ENDED_AT - 3.months,
+ migration_state: 'import_done',
+ migration_import_done_at: Time.zone.now
+ )
+ end
+
+ it { is_expected.to be_truthy }
+ end
+ end
+ end
+
describe '.with_enabled_policy' do
let_it_be(:repository) { create(:container_repository) }
let_it_be(:repository2) { create(:container_repository) }
@@ -1168,6 +1255,17 @@ RSpec.describe ContainerRepository, :aggregate_failures do
end
end
+ context 'not found response' do
+ let(:response) { :not_found }
+
+ it 'completes the migration' do
+ expect(subject).to eq(false)
+
+ expect(container_repository).to be_import_done
+ expect(container_repository.reload.migration_skipped_reason).to eq('not_found')
+ end
+ end
+
context 'other response' do
let(:response) { :error }
@@ -1185,6 +1283,30 @@ RSpec.describe ContainerRepository, :aggregate_failures do
end
end
+ describe '#retried_too_many_times?' do
+ subject { repository.retried_too_many_times? }
+
+ before do
+ stub_application_setting(container_registry_import_max_retries: 3)
+ end
+
+ context 'migration_retries_count is equal or greater than max_retries' do
+ before do
+ repository.update_column(:migration_retries_count, 3)
+ end
+
+ it { is_expected.to eq(true) }
+ end
+
+ context 'migration_retries_count is lower than max_retries' do
+ before do
+ repository.update_column(:migration_retries_count, 2)
+ end
+
+ it { is_expected.to eq(false) }
+ end
+ end
+
context 'with repositories' do
let_it_be_with_reload(:repository) { create(:container_repository, :cleanup_unscheduled) }
let_it_be(:other_repository) { create(:container_repository, :cleanup_unscheduled) }
@@ -1241,11 +1363,12 @@ RSpec.describe ContainerRepository, :aggregate_failures do
let_it_be(:import_done_repository) { create(:container_repository, :import_done, migration_pre_import_done_at: 3.days.ago, migration_import_done_at: 2.days.ago) }
let_it_be(:import_aborted_repository) { create(:container_repository, :import_aborted, migration_pre_import_done_at: 5.days.ago, migration_aborted_at: 1.day.ago) }
let_it_be(:pre_import_done_repository) { create(:container_repository, :pre_import_done, migration_pre_import_done_at: 1.hour.ago) }
+ let_it_be(:import_skipped_repository) { create(:container_repository, :import_skipped, migration_skipped_at: 90.minutes.ago) }
subject { described_class.recently_done_migration_step }
it 'returns completed imports by done_at date' do
- expect(subject.to_a).to eq([pre_import_done_repository, import_aborted_repository, import_done_repository])
+ expect(subject.to_a).to eq([pre_import_done_repository, import_skipped_repository, import_aborted_repository, import_done_repository])
end
end
@@ -1255,7 +1378,7 @@ RSpec.describe ContainerRepository, :aggregate_failures do
subject { described_class.ready_for_import }
before do
- stub_application_setting(container_registry_import_target_plan: root_group.actual_plan_name)
+ stub_application_setting(container_registry_import_target_plan: valid_container_repository.migration_plan)
end
it 'works' do
@@ -1266,13 +1389,15 @@ RSpec.describe ContainerRepository, :aggregate_failures do
describe '#last_import_step_done_at' do
let_it_be(:aborted_at) { Time.zone.now - 1.hour }
let_it_be(:pre_import_done_at) { Time.zone.now - 2.hours }
+ let_it_be(:skipped_at) { Time.zone.now - 3.hours }
subject { repository.last_import_step_done_at }
before do
repository.update_columns(
migration_pre_import_done_at: pre_import_done_at,
- migration_aborted_at: aborted_at
+ migration_aborted_at: aborted_at,
+ migration_skipped_at: skipped_at
)
end
diff --git a/spec/models/custom_emoji_spec.rb b/spec/models/custom_emoji_spec.rb
index 01252a58681..15655d08556 100644
--- a/spec/models/custom_emoji_spec.rb
+++ b/spec/models/custom_emoji_spec.rb
@@ -38,7 +38,7 @@ RSpec.describe CustomEmoji do
new_emoji = build(:custom_emoji, name: old_emoji.name, namespace: old_emoji.namespace, group: group)
expect(new_emoji).not_to be_valid
- expect(new_emoji.errors.messages).to eq(creator: ["can't be blank"], name: ["has already been taken"])
+ expect(new_emoji.errors.messages).to eq(name: ["has already been taken"])
end
it 'disallows non http and https file value' do
diff --git a/spec/models/customer_relations/contact_spec.rb b/spec/models/customer_relations/contact_spec.rb
index 18896962261..86f868b269e 100644
--- a/spec/models/customer_relations/contact_spec.rb
+++ b/spec/models/customer_relations/contact_spec.rb
@@ -25,7 +25,7 @@ RSpec.describe CustomerRelations::Contact, type: :model do
it { is_expected.to validate_length_of(:email).is_at_most(255) }
it { is_expected.to validate_length_of(:description).is_at_most(1024) }
- it { is_expected.to validate_uniqueness_of(:email).scoped_to(:group_id) }
+ it { is_expected.to validate_uniqueness_of(:email).case_insensitive.scoped_to(:group_id) }
it_behaves_like 'an object with RFC3696 compliant email-formatted attributes', :email
end
@@ -87,6 +87,15 @@ RSpec.describe CustomerRelations::Contact, type: :model do
too_many_emails = described_class::MAX_PLUCK + 1
expect { described_class.find_ids_by_emails(group, Array(0..too_many_emails)) }.to raise_error(ArgumentError)
end
+
+ it 'finds contacts regardless of email casing' do
+ new_contact = create(:contact, group: group, email: "UPPERCASE@example.com")
+ emails = [group_contacts[0].email.downcase, group_contacts[1].email.upcase, new_contact.email]
+
+ contact_ids = described_class.find_ids_by_emails(group, emails)
+
+ expect(contact_ids).to contain_exactly(group_contacts[0].id, group_contacts[1].id, new_contact.id)
+ end
end
describe '#self.exists_for_group?' do
@@ -104,4 +113,33 @@ RSpec.describe CustomerRelations::Contact, type: :model do
end
end
end
+
+ describe '#self.move_to_root_group' do
+ let!(:old_root_group) { create(:group) }
+ let!(:contacts) { create_list(:contact, 4, group: old_root_group) }
+ let!(:project) { create(:project, group: old_root_group) }
+ let!(:issue) { create(:issue, project: project) }
+ let!(:issue_contact1) { create(:issue_customer_relations_contact, issue: issue, contact: contacts[0]) }
+ let!(:issue_contact2) { create(:issue_customer_relations_contact, issue: issue, contact: contacts[1]) }
+ let!(:new_root_group) { create(:group) }
+ let!(:dupe_contact1) { create(:contact, group: new_root_group, email: contacts[1].email) }
+ let!(:dupe_contact2) { create(:contact, group: new_root_group, email: contacts[3].email.upcase) }
+
+ before do
+ old_root_group.update!(parent: new_root_group)
+ CustomerRelations::Contact.move_to_root_group(old_root_group)
+ end
+
+ it 'moves contacts with unique emails and deletes the rest' do
+ expect(contacts[0].reload.group_id).to eq(new_root_group.id)
+ expect(contacts[2].reload.group_id).to eq(new_root_group.id)
+ expect { contacts[1].reload }.to raise_error(ActiveRecord::RecordNotFound)
+ expect { contacts[3].reload }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+
+ it 'updates issue_contact.contact_id for dupes and leaves the rest untouched' do
+ expect(issue_contact1.reload.contact_id).to eq(contacts[0].id)
+ expect(issue_contact2.reload.contact_id).to eq(dupe_contact1.id)
+ end
+ end
end
diff --git a/spec/models/customer_relations/issue_contact_spec.rb b/spec/models/customer_relations/issue_contact_spec.rb
index f1fb574f86f..221378d26b2 100644
--- a/spec/models/customer_relations/issue_contact_spec.rb
+++ b/spec/models/customer_relations/issue_contact_spec.rb
@@ -92,4 +92,16 @@ RSpec.describe CustomerRelations::IssueContact do
expect { described_class.delete_for_project(project.id) }.to change { described_class.count }.by(-3)
end
end
+
+ describe '.delete_for_group' do
+ let(:project_for_root_group) { create(:project, group: group) }
+
+ it 'destroys all issue_contacts for projects in group and subgroups' do
+ create_list(:issue_customer_relations_contact, 2, :for_issue, issue: create(:issue, project: project))
+ create_list(:issue_customer_relations_contact, 2, :for_issue, issue: create(:issue, project: project_for_root_group))
+ create(:issue_customer_relations_contact)
+
+ expect { described_class.delete_for_group(group) }.to change { described_class.count }.by(-4)
+ end
+ end
end
diff --git a/spec/models/customer_relations/organization_spec.rb b/spec/models/customer_relations/organization_spec.rb
index 9fe754b7605..06ba9c5b7ad 100644
--- a/spec/models/customer_relations/organization_spec.rb
+++ b/spec/models/customer_relations/organization_spec.rb
@@ -50,4 +50,32 @@ RSpec.describe CustomerRelations::Organization, type: :model do
expect(described_class.find_by_name(group.id, 'TEST')).to eq([organiztion1])
end
end
+
+ describe '#self.move_to_root_group' do
+ let!(:old_root_group) { create(:group) }
+ let!(:organizations) { create_list(:organization, 4, group: old_root_group) }
+ let!(:new_root_group) { create(:group) }
+ let!(:contact1) { create(:contact, group: new_root_group, organization: organizations[0]) }
+ let!(:contact2) { create(:contact, group: new_root_group, organization: organizations[1]) }
+
+ let!(:dupe_organization1) { create(:organization, group: new_root_group, name: organizations[1].name) }
+ let!(:dupe_organization2) { create(:organization, group: new_root_group, name: organizations[3].name.upcase) }
+
+ before do
+ old_root_group.update!(parent: new_root_group)
+ CustomerRelations::Organization.move_to_root_group(old_root_group)
+ end
+
+ it 'moves organizations with unique names and deletes the rest' do
+ expect(organizations[0].reload.group_id).to eq(new_root_group.id)
+ expect(organizations[2].reload.group_id).to eq(new_root_group.id)
+ expect { organizations[1].reload }.to raise_error(ActiveRecord::RecordNotFound)
+ expect { organizations[3].reload }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+
+ it 'updates contact.organization_id for dupes and leaves the rest untouched' do
+ expect(contact1.reload.organization_id).to eq(organizations[0].id)
+ expect(contact2.reload.organization_id).to eq(dupe_organization1.id)
+ end
+ end
end
diff --git a/spec/models/deploy_token_spec.rb b/spec/models/deploy_token_spec.rb
index 88451307efb..c48f1fab3c6 100644
--- a/spec/models/deploy_token_spec.rb
+++ b/spec/models/deploy_token_spec.rb
@@ -9,6 +9,7 @@ RSpec.describe DeployToken do
it { is_expected.to have_many(:projects).through(:project_deploy_tokens) }
it { is_expected.to have_many :group_deploy_tokens }
it { is_expected.to have_many(:groups).through(:group_deploy_tokens) }
+ it { is_expected.to belong_to(:user).with_foreign_key('creator_id') }
it_behaves_like 'having unique enum values'
diff --git a/spec/models/deployment_spec.rb b/spec/models/deployment_spec.rb
index 47c246d12cc..705b9b4cc65 100644
--- a/spec/models/deployment_spec.rb
+++ b/spec/models/deployment_spec.rb
@@ -524,6 +524,16 @@ RSpec.describe Deployment do
is_expected.to contain_exactly(deployment1, deployment2, deployment3, deployment4, deployment5)
end
+
+ it 'has a corresponding database index' do
+ index = ApplicationRecord.connection.indexes('deployments').find do |i|
+ i.name == 'index_deployments_for_visible_scope'
+ end
+
+ scope_values = described_class::VISIBLE_STATUSES.map { |s| described_class.statuses[s] }.to_s
+
+ expect(index.where).to include(scope_values)
+ end
end
describe 'upcoming' do
@@ -1055,6 +1065,40 @@ RSpec.describe Deployment do
end
end
+ describe '#tier_in_yaml' do
+ context 'when deployable is nil' do
+ before do
+ subject.deployable = nil
+ end
+
+ it 'returns nil' do
+ expect(subject.tier_in_yaml).to be_nil
+ end
+ end
+
+ context 'when deployable is present' do
+ context 'when tier is specified' do
+ let(:deployable) { create(:ci_build, :success, :environment_with_deployment_tier) }
+
+ before do
+ subject.deployable = deployable
+ end
+
+ it 'returns the tier' do
+ expect(subject.tier_in_yaml).to eq('testing')
+ end
+
+ context 'when tier is not specified' do
+ let(:deployable) { create(:ci_build, :success) }
+
+ it 'returns nil' do
+ expect(subject.tier_in_yaml).to be_nil
+ end
+ end
+ end
+ end
+ end
+
describe '.fast_destroy_all' do
it 'cleans path_refs for destroyed environments' do
project = create(:project, :repository)
diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb
index 6144593395c..b42e73e6d93 100644
--- a/spec/models/environment_spec.rb
+++ b/spec/models/environment_spec.rb
@@ -23,7 +23,6 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
it { is_expected.to have_one(:upcoming_deployment) }
it { is_expected.to have_one(:latest_opened_most_severe_alert) }
- it { is_expected.to delegate_method(:stop_action).to(:last_deployment) }
it { is_expected.to delegate_method(:manual_actions).to(:last_deployment) }
it { is_expected.to validate_presence_of(:name) }
@@ -349,15 +348,28 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
end
describe '.with_deployment' do
- subject { described_class.with_deployment(sha) }
+ subject { described_class.with_deployment(sha, status: status) }
let(:environment) { create(:environment, project: project) }
let(:sha) { 'b83d6e391c22777fca1ed3012fce84f633d7fed0' }
+ let(:status) { nil }
context 'when deployment has the specified sha' do
let!(:deployment) { create(:deployment, environment: environment, sha: sha) }
it { is_expected.to eq([environment]) }
+
+ context 'with success status filter' do
+ let(:status) { :success }
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'with created status filter' do
+ let(:status) { :created }
+
+ it { is_expected.to contain_exactly(environment) }
+ end
end
context 'when deployment does not have the specified sha' do
@@ -459,8 +471,8 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
end
end
- describe '#stop_action_available?' do
- subject { environment.stop_action_available? }
+ describe '#stop_actions_available?' do
+ subject { environment.stop_actions_available? }
context 'when no other actions' do
it { is_expected.to be_falsey }
@@ -499,10 +511,10 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
end
end
- describe '#stop_with_action!' do
+ describe '#stop_with_actions!' do
let(:user) { create(:user) }
- subject { environment.stop_with_action!(user) }
+ subject { environment.stop_with_actions!(user) }
before do
expect(environment).to receive(:available?).and_call_original
@@ -515,9 +527,10 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
end
it do
- subject
+ actions = subject
expect(environment).to be_stopped
+ expect(actions).to match_array([])
end
end
@@ -536,18 +549,18 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
context 'when matching action is defined' do
let(:pipeline) { create(:ci_pipeline, project: project) }
- let(:build) { create(:ci_build, pipeline: pipeline) }
+ let(:build_a) { create(:ci_build, pipeline: pipeline) }
- let!(:deployment) do
+ before do
create(:deployment, :success,
- environment: environment,
- deployable: build,
- on_stop: 'close_app')
+ environment: environment,
+ deployable: build_a,
+ on_stop: 'close_app_a')
end
context 'when user is not allowed to stop environment' do
let!(:close_action) do
- create(:ci_build, :manual, pipeline: pipeline, name: 'close_app')
+ create(:ci_build, :manual, pipeline: pipeline, name: 'close_app_a')
end
it 'raises an exception' do
@@ -565,31 +578,200 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
context 'when action did not yet finish' do
let!(:close_action) do
- create(:ci_build, :manual, pipeline: pipeline, name: 'close_app')
+ create(:ci_build, :manual, pipeline: pipeline, name: 'close_app_a')
end
it 'returns the same action' do
- expect(subject).to eq(close_action)
- expect(subject.user).to eq(user)
+ action = subject.first
+ expect(action).to eq(close_action)
+ expect(action.user).to eq(user)
end
end
context 'if action did finish' do
let!(:close_action) do
create(:ci_build, :manual, :success,
- pipeline: pipeline, name: 'close_app')
+ pipeline: pipeline, name: 'close_app_a')
end
it 'returns a new action of the same type' do
- expect(subject).to be_persisted
- expect(subject.name).to eq(close_action.name)
- expect(subject.user).to eq(user)
+ action = subject.first
+
+ expect(action).to be_persisted
+ expect(action.name).to eq(close_action.name)
+ expect(action.user).to eq(user)
+ end
+ end
+
+ context 'close action does not raise ActiveRecord::StaleObjectError' do
+ let!(:close_action) do
+ create(:ci_build, :manual, pipeline: pipeline, name: 'close_app_a')
+ end
+
+ before do
+ # preload the build
+ environment.stop_actions
+
+ # Update record as the other process. This makes `environment.stop_action` stale.
+ close_action.drop!
end
+
+ it 'successfully plays the build even if the build was a stale object' do
+ # Since build is droped.
+ expect(close_action.processed).to be_falsey
+
+ # it encounters the StaleObjectError at first, but reloads the object and runs `build.play`
+ expect { subject }.not_to raise_error(ActiveRecord::StaleObjectError)
+
+ # Now the build should be processed.
+ expect(close_action.reload.processed).to be_truthy
+ end
+ end
+ end
+ end
+
+ context 'when there are more then one stop action for the environment' do
+ let(:pipeline) { create(:ci_pipeline, project: project) }
+ let(:build_a) { create(:ci_build, pipeline: pipeline) }
+ let(:build_b) { create(:ci_build, pipeline: pipeline) }
+
+ let!(:close_actions) do
+ [
+ create(:ci_build, :manual, pipeline: pipeline, name: 'close_app_a'),
+ create(:ci_build, :manual, pipeline: pipeline, name: 'close_app_b')
+ ]
+ end
+
+ before do
+ project.add_developer(user)
+
+ create(:deployment, :success,
+ environment: environment,
+ deployable: build_a,
+ finished_at: 5.minutes.ago,
+ on_stop: 'close_app_a')
+
+ create(:deployment, :success,
+ environment: environment,
+ deployable: build_b,
+ finished_at: 1.second.ago,
+ on_stop: 'close_app_b')
+ end
+
+ it 'returns the same actions' do
+ actions = subject
+
+ expect(actions.count).to eq(close_actions.count)
+ expect(actions.pluck(:id)).to match_array(close_actions.pluck(:id))
+ expect(actions.pluck(:user)).to match_array(close_actions.pluck(:user))
+ end
+
+ context 'when there are failed deployment jobs' do
+ before do
+ create(:ci_build, pipeline: pipeline, name: 'close_app_c')
+
+ create(:deployment, :failed,
+ environment: environment,
+ deployable: create(:ci_build, pipeline: pipeline),
+ on_stop: 'close_app_c')
+ end
+
+ it 'returns only stop actions from successful deployment jobs' do
+ actions = subject
+
+ expect(actions).to match_array(close_actions)
+ expect(actions.count).to eq(environment.successful_deployments.count)
+ end
+ end
+
+ context 'when the feature is disabled' do
+ before do
+ stub_feature_flags(environment_multiple_stop_actions: false)
+ end
+
+ it 'returns the last deployment job stop action' do
+ stop_actions = subject
+
+ expect(stop_actions.first).to eq(close_actions[1])
+ expect(stop_actions.count).to eq(1)
end
end
end
end
+ describe '#stop_actions' do
+ subject { environment.stop_actions }
+
+ context 'when there are no deployments and builds' do
+ it 'returns empty array' do
+ is_expected.to match_array([])
+ end
+ end
+
+ context 'when there are multiple deployments with actions' do
+ let(:pipeline) { create(:ci_pipeline, project: project) }
+ let(:ci_build_a) { create(:ci_build, project: project, pipeline: pipeline) }
+ let(:ci_build_b) { create(:ci_build, project: project, pipeline: pipeline) }
+ let!(:ci_build_c) { create(:ci_build, :manual, project: project, pipeline: pipeline, name: 'close_app_a') }
+ let!(:ci_build_d) { create(:ci_build, :manual, project: project, pipeline: pipeline, name: 'close_app_b') }
+
+ let!(:deployment_a) do
+ create(:deployment,
+ :success, project: project, environment: environment, deployable: ci_build_a, on_stop: 'close_app_a')
+ end
+
+ let!(:deployment_b) do
+ create(:deployment,
+ :success, project: project, environment: environment, deployable: ci_build_b, on_stop: 'close_app_b')
+ end
+
+ before do
+ # Create failed deployment without stop_action.
+ build = create(:ci_build, project: project, pipeline: pipeline)
+ create(:deployment, :failed, project: project, environment: environment, deployable: build)
+ end
+
+ it 'returns only the stop actions' do
+ expect(subject.pluck(:id)).to contain_exactly(ci_build_c.id, ci_build_d.id)
+ end
+ end
+ end
+
+ describe '#last_deployment_group' do
+ subject { environment.last_deployment_group }
+
+ context 'when there are no deployments and builds' do
+ it do
+ is_expected.to eq(Deployment.none)
+ end
+ end
+
+ context 'when there are deployments for multiple pipelines' do
+ let(:pipeline_a) { create(:ci_pipeline, project: project) }
+ let(:pipeline_b) { create(:ci_pipeline, project: project) }
+ let(:ci_build_a) { create(:ci_build, project: project, pipeline: pipeline_a) }
+ let(:ci_build_b) { create(:ci_build, project: project, pipeline: pipeline_b) }
+ let(:ci_build_c) { create(:ci_build, project: project, pipeline: pipeline_a) }
+ let(:ci_build_d) { create(:ci_build, project: project, pipeline: pipeline_a) }
+
+ # Successful deployments for pipeline_a
+ let!(:deployment_a) { create(:deployment, :success, project: project, environment: environment, deployable: ci_build_a) }
+ let!(:deployment_b) { create(:deployment, :success, project: project, environment: environment, deployable: ci_build_c) }
+
+ before do
+ # Failed deployment for pipeline_a
+ create(:deployment, :failed, project: project, environment: environment, deployable: ci_build_d)
+
+ # Failed deployment for pipeline_b
+ create(:deployment, :failed, project: project, environment: environment, deployable: ci_build_b)
+ end
+
+ it 'returns the successful deployment jobs for the last deployment pipeline' do
+ expect(subject.pluck(:id)).to contain_exactly(deployment_a.id, deployment_b.id)
+ end
+ end
+ end
+
describe 'recently_updated_on_branch?' do
subject { environment.recently_updated_on_branch?('feature') }
@@ -698,10 +880,29 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
context 'when there is a deployment record with success status' do
let!(:deployment) { create(:deployment, :success, environment: environment) }
+ let!(:old_deployment) { create(:deployment, :success, environment: environment, finished_at: 2.days.ago) }
it 'returns the latest successful deployment' do
is_expected.to eq(deployment)
end
+
+ context 'env_last_deployment_by_finished_at feature flag' do
+ it 'when enabled it returns the deployment with the latest finished_at' do
+ stub_feature_flags(env_last_deployment_by_finished_at: true)
+
+ expect(old_deployment.finished_at < deployment.finished_at).to be_truthy
+
+ is_expected.to eq(deployment)
+ end
+
+ it 'when disabled it returns the deployment with the highest id' do
+ stub_feature_flags(env_last_deployment_by_finished_at: false)
+
+ expect(old_deployment.finished_at < deployment.finished_at).to be_truthy
+
+ is_expected.to eq(old_deployment)
+ end
+ end
end
end
end
@@ -728,6 +929,26 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
end
end
+ describe '#last_deployment_pipeline' do
+ subject { environment.last_deployment_pipeline }
+
+ let(:pipeline_a) { create(:ci_pipeline, project: project) }
+ let(:pipeline_b) { create(:ci_pipeline, project: project) }
+ let(:ci_build_a) { create(:ci_build, project: project, pipeline: pipeline_a) }
+ let(:ci_build_b) { create(:ci_build, project: project, pipeline: pipeline_b) }
+
+ before do
+ create(:deployment, :success, project: project, environment: environment, deployable: ci_build_a)
+ create(:deployment, :failed, project: project, environment: environment, deployable: ci_build_b)
+ end
+
+ it 'does not join across databases' do
+ with_cross_joins_prevented do
+ expect(subject.id).to eq(pipeline_a.id)
+ end
+ end
+ end
+
describe '#last_visible_deployment' do
subject { environment.last_visible_deployment }
diff --git a/spec/models/environment_status_spec.rb b/spec/models/environment_status_spec.rb
index 1db1171401c..a9aa5698ebb 100644
--- a/spec/models/environment_status_spec.rb
+++ b/spec/models/environment_status_spec.rb
@@ -34,6 +34,13 @@ RSpec.describe EnvironmentStatus do
subject { environment_status.deployment }
it { is_expected.to eq(deployment) }
+
+ context 'multiple deployments' do
+ it {
+ new_deployment = create(:deployment, :succeed, environment: deployment.environment, sha: deployment.sha )
+ is_expected.to eq(new_deployment)
+ }
+ end
end
# $ git diff --stat pages-deploy-target...pages-deploy
diff --git a/spec/models/error_tracking/project_error_tracking_setting_spec.rb b/spec/models/error_tracking/project_error_tracking_setting_spec.rb
index d700eb5eaf7..2939a40a84f 100644
--- a/spec/models/error_tracking/project_error_tracking_setting_spec.rb
+++ b/spec/models/error_tracking/project_error_tracking_setting_spec.rb
@@ -8,6 +8,8 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do
let_it_be(:project) { create(:project) }
+ let(:sentry_client) { instance_double(ErrorTracking::SentryClient) }
+
subject(:setting) { build(:project_error_tracking_setting, project: project) }
describe 'Associations' do
@@ -48,7 +50,7 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do
expect(subject.errors.messages[:project]).to include('is a required field')
end
- context 'presence validations' do
+ describe 'presence validations' do
using RSpec::Parameterized::TableSyntax
valid_api_url = 'http://example.com/api/0/projects/org-slug/proj-slug/'
@@ -83,12 +85,12 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do
describe 'after_save :create_client_key!' do
subject { build(:project_error_tracking_setting, :integrated, project: project) }
- context 'no client key yet' do
+ context 'without client key' do
it 'creates a new client key' do
expect { subject.save! }.to change { ErrorTracking::ClientKey.count }.by(1)
end
- context 'sentry backend' do
+ context 'with sentry backend' do
before do
subject.integrated = false
end
@@ -98,7 +100,7 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do
end
end
- context 'feature disabled' do
+ context 'when feature disabled' do
before do
subject.enabled = false
end
@@ -109,7 +111,7 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do
end
end
- context 'client key already exists' do
+ context 'when client key already exists' do
let!(:client_key) { create(:error_tracking_client_key, project: project) }
it 'does not create a new client key' do
@@ -122,13 +124,13 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do
describe '.extract_sentry_external_url' do
subject { described_class.extract_sentry_external_url(sentry_url) }
- describe 'when passing a URL' do
+ context 'when passing a URL' do
let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project' }
it { is_expected.to eq('https://sentrytest.gitlab.com/sentry-org/sentry-project') }
end
- describe 'when passing nil' do
+ context 'when passing nil' do
let(:sentry_url) { nil }
it { is_expected.to be_nil }
@@ -159,23 +161,15 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do
describe '#list_sentry_issues' do
let(:issues) { [:list, :of, :issues] }
-
- let(:opts) do
- { issue_status: 'unresolved', limit: 10 }
- end
-
- let(:result) do
- subject.list_sentry_issues(**opts)
- end
+ let(:result) { subject.list_sentry_issues(**opts) }
+ let(:opts) { { issue_status: 'unresolved', limit: 10 } }
context 'when cached' do
- let(:sentry_client) { spy(:sentry_client) }
-
before do
stub_reactive_cache(subject, issues, opts)
synchronous_reactive_cache(subject)
- expect(subject).to receive(:sentry_client).and_return(sentry_client)
+ allow(subject).to receive(:sentry_client).and_return(sentry_client)
end
it 'returns cached issues' do
@@ -195,8 +189,6 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do
end
context 'when sentry client raises ErrorTracking::SentryClient::Error' do
- let(:sentry_client) { spy(:sentry_client) }
-
before do
synchronous_reactive_cache(subject)
@@ -214,14 +206,13 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do
end
context 'when sentry client raises ErrorTracking::SentryClient::MissingKeysError' do
- let(:sentry_client) { spy(:sentry_client) }
-
before do
synchronous_reactive_cache(subject)
allow(subject).to receive(:sentry_client).and_return(sentry_client)
allow(sentry_client).to receive(:list_issues).with(opts)
- .and_raise(ErrorTracking::SentryClient::MissingKeysError, 'Sentry API response is missing keys. key not found: "id"')
+ .and_raise(ErrorTracking::SentryClient::MissingKeysError,
+ 'Sentry API response is missing keys. key not found: "id"')
end
it 'returns error' do
@@ -233,8 +224,7 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do
end
context 'when sentry client raises ErrorTracking::SentryClient::ResponseInvalidSizeError' do
- let(:sentry_client) { spy(:sentry_client) }
- let(:error_msg) {"Sentry API response is too big. Limit is #{Gitlab::Utils::DeepSize.human_default_max_size}."}
+ let(:error_msg) { "Sentry API response is too big. Limit is #{Gitlab::Utils::DeepSize.human_default_max_size}." }
before do
synchronous_reactive_cache(subject)
@@ -253,8 +243,6 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do
end
context 'when sentry client raises StandardError' do
- let(:sentry_client) { spy(:sentry_client) }
-
before do
synchronous_reactive_cache(subject)
@@ -270,7 +258,6 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do
describe '#list_sentry_projects' do
let(:projects) { [:list, :of, :projects] }
- let(:sentry_client) { spy(:sentry_client) }
it 'calls sentry client' do
expect(subject).to receive(:sentry_client).and_return(sentry_client)
@@ -284,19 +271,17 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do
describe '#issue_details' do
let(:issue) { build(:error_tracking_sentry_detailed_error) }
- let(:sentry_client) { double('sentry_client', issue_details: issue) }
let(:commit_id) { issue.first_release_version }
-
- let(:result) do
- subject.issue_details
- end
+ let(:result) { subject.issue_details(opts) }
+ let(:opts) { { issue_id: 1 } }
context 'when cached' do
before do
stub_reactive_cache(subject, issue, {})
synchronous_reactive_cache(subject)
- expect(subject).to receive(:sentry_client).and_return(sentry_client)
+ allow(subject).to receive(:sentry_client).and_return(sentry_client)
+ allow(sentry_client).to receive(:issue_details).with(opts).and_return(issue)
end
it { expect(result).to eq(issue: issue) }
@@ -314,15 +299,15 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do
end
context 'when repo commit matches first relase version' do
- let(:commit) { double('commit', id: commit_id) }
- let(:repository) { double('repository', commit: commit) }
+ let(:commit) { instance_double(Commit, id: commit_id) }
+ let(:repository) { instance_double(Repository, commit: commit) }
before do
- expect(project).to receive(:repository).and_return(repository)
+ allow(project).to receive(:repository).and_return(repository)
end
it { expect(result[:issue].gitlab_commit).to eq(commit_id) }
- it { expect(result[:issue].gitlab_commit_path).to eq("/#{project.namespace.path}/#{project.path}/-/commit/#{commit_id}") }
+ it { expect(result[:issue].gitlab_commit_path).to eq(project_commit_path(project, commit_id)) }
end
end
@@ -333,19 +318,15 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do
end
describe '#update_issue' do
- let(:opts) do
- { status: 'resolved' }
- end
+ let(:result) { subject.update_issue(**opts) }
+ let(:opts) { { issue_id: 1, params: {} } }
- let(:result) do
- subject.update_issue(**opts)
+ before do
+ allow(subject).to receive(:sentry_client).and_return(sentry_client)
end
- let(:sentry_client) { spy(:sentry_client) }
-
- context 'successful call to sentry' do
+ context 'when sentry response is successful' do
before do
- allow(subject).to receive(:sentry_client).and_return(sentry_client)
allow(sentry_client).to receive(:update_issue).with(opts).and_return(true)
end
@@ -354,9 +335,8 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do
end
end
- context 'sentry raises an error' do
+ context 'when sentry raises an error' do
before do
- allow(subject).to receive(:sentry_client).and_return(sentry_client)
allow(sentry_client).to receive(:update_issue).with(opts).and_raise(StandardError)
end
@@ -366,7 +346,7 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do
end
end
- context 'slugs' do
+ describe 'slugs' do
shared_examples_for 'slug from api_url' do |method, slug|
context 'when api_url is correct' do
before do
@@ -393,9 +373,9 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do
it_behaves_like 'slug from api_url', :organization_slug, 'org-slug'
end
- context 'names from api_url' do
+ describe 'names from api_url' do
shared_examples_for 'name from api_url' do |name, titleized_slug|
- context 'name is present in DB' do
+ context 'when name is present in DB' do
it 'returns name from DB' do
subject[name] = 'Sentry name'
subject.api_url = 'http://gitlab.com/api/0/projects/org-slug/project-slug'
@@ -404,7 +384,7 @@ RSpec.describe ErrorTracking::ProjectErrorTrackingSetting do
end
end
- context 'name is null in DB' do
+ context 'when name is null in DB' do
it 'titleizes and returns slug from api_url' do
subject[name] = nil
subject.api_url = 'http://gitlab.com/api/0/projects/org-slug/project-slug'
diff --git a/spec/models/group_group_link_spec.rb b/spec/models/group_group_link_spec.rb
index 034a5c1dfc6..72c700e7981 100644
--- a/spec/models/group_group_link_spec.rb
+++ b/spec/models/group_group_link_spec.rb
@@ -29,6 +29,49 @@ RSpec.describe GroupGroupLink do
])
end
end
+
+ describe '.distinct_on_shared_with_group_id_with_group_access' do
+ let_it_be(:sub_shared_group) { create(:group, parent: shared_group) }
+ let_it_be(:other_group) { create(:group) }
+
+ let_it_be(:group_group_link_2) do
+ create(
+ :group_group_link,
+ shared_group: shared_group,
+ shared_with_group: other_group,
+ group_access: Gitlab::Access::GUEST
+ )
+ end
+
+ let_it_be(:group_group_link_3) do
+ create(
+ :group_group_link,
+ shared_group: sub_shared_group,
+ shared_with_group: group,
+ group_access: Gitlab::Access::GUEST
+ )
+ end
+
+ let_it_be(:group_group_link_4) do
+ create(
+ :group_group_link,
+ shared_group: sub_shared_group,
+ shared_with_group: other_group,
+ group_access: Gitlab::Access::DEVELOPER
+ )
+ end
+
+ it 'returns only one group link per group (with max group access)' do
+ distinct_group_group_links = described_class.distinct_on_shared_with_group_id_with_group_access
+
+ expect(described_class.all.count).to eq(4)
+ expect(distinct_group_group_links.count).to eq(2)
+ expect(distinct_group_group_links).to include(group_group_link)
+ expect(distinct_group_group_links).not_to include(group_group_link_2)
+ expect(distinct_group_group_links).not_to include(group_group_link_3)
+ expect(distinct_group_group_links).to include(group_group_link_4)
+ end
+ end
end
describe 'validation' do
@@ -57,4 +100,9 @@ RSpec.describe GroupGroupLink do
group_group_link.human_access
end
end
+
+ describe 'search by group name' do
+ it { expect(described_class.search(group.name)).to eq([group_group_link]) }
+ it { expect(described_class.search('not-a-group-name')).to be_empty }
+ end
end
diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb
index 45a2c134077..0ca1fe1c8a6 100644
--- a/spec/models/group_spec.rb
+++ b/spec/models/group_spec.rb
@@ -41,6 +41,7 @@ RSpec.describe Group do
it { is_expected.to have_many(:contacts).class_name('CustomerRelations::Contact') }
it { is_expected.to have_many(:organizations).class_name('CustomerRelations::Organization') }
it { is_expected.to have_one(:crm_settings) }
+ it { is_expected.to have_one(:group_feature) }
describe '#members & #requesters' do
let(:requester) { create(:user) }
@@ -293,6 +294,23 @@ RSpec.describe Group do
end
end
+ it_behaves_like 'a BulkUsersByEmailLoad model'
+
+ context 'after initialized' do
+ it 'has a group_feature' do
+ expect(described_class.new.group_feature).to be_present
+ end
+ end
+
+ context 'when creating a new project' do
+ let_it_be(:group) { create(:group) }
+
+ it 'automatically creates the groups feature for the group' do
+ expect(group.group_feature).to be_an_instance_of(Groups::FeatureSetting)
+ expect(group.group_feature).to be_persisted
+ end
+ end
+
context 'traversal_ids on create' do
context 'default traversal_ids' do
let(:group) { build(:group) }
@@ -533,6 +551,10 @@ RSpec.describe Group do
describe '#ancestors_upto' do
it { expect(group.ancestors_upto.to_sql).not_to include "WITH ORDINALITY" }
end
+
+ describe '.shortest_traversal_ids_prefixes' do
+ it { expect { described_class.shortest_traversal_ids_prefixes }.to raise_error /Feature not supported since the `:use_traversal_ids` is disabled/ }
+ end
end
context 'linear' do
@@ -574,6 +596,90 @@ RSpec.describe Group do
it { expect(group.ancestors_upto.to_sql).to include "WITH ORDINALITY" }
end
+ describe '.shortest_traversal_ids_prefixes' do
+ subject { filter.shortest_traversal_ids_prefixes }
+
+ context 'for many top-level namespaces' do
+ let!(:top_level_groups) { create_list(:group, 4) }
+
+ context 'when querying all groups' do
+ let(:filter) { described_class.id_in(top_level_groups) }
+
+ it "returns all traversal_ids" do
+ is_expected.to contain_exactly(
+ *top_level_groups.map { |group| [group.id] }
+ )
+ end
+ end
+
+ context 'when querying selected groups' do
+ let(:filter) { described_class.id_in(top_level_groups.first) }
+
+ it "returns only a selected traversal_ids" do
+ is_expected.to contain_exactly([top_level_groups.first.id])
+ end
+ end
+ end
+
+ context 'for namespace hierarchy' do
+ let!(:group_a) { create(:group) }
+ let!(:group_a_sub_1) { create(:group, parent: group_a) }
+ let!(:group_a_sub_2) { create(:group, parent: group_a) }
+ let!(:group_b) { create(:group) }
+ let!(:group_b_sub_1) { create(:group, parent: group_b) }
+ let!(:group_c) { create(:group) }
+
+ context 'when querying all groups' do
+ let(:filter) { described_class.id_in([group_a, group_a_sub_1, group_a_sub_2, group_b, group_b_sub_1, group_c]) }
+
+ it 'returns only shortest prefixes of top-level groups' do
+ is_expected.to contain_exactly(
+ [group_a.id],
+ [group_b.id],
+ [group_c.id]
+ )
+ end
+ end
+
+ context 'when sub-group is reparented' do
+ let(:filter) { described_class.id_in([group_b_sub_1, group_c]) }
+
+ before do
+ group_b_sub_1.update!(parent: group_c)
+ end
+
+ it 'returns a proper shortest prefix of a new group' do
+ is_expected.to contain_exactly(
+ [group_c.id]
+ )
+ end
+ end
+
+ context 'when querying sub-groups' do
+ let(:filter) { described_class.id_in([group_a_sub_1, group_b_sub_1, group_c]) }
+
+ it 'returns sub-groups as they are shortest prefixes' do
+ is_expected.to contain_exactly(
+ [group_a.id, group_a_sub_1.id],
+ [group_b.id, group_b_sub_1.id],
+ [group_c.id]
+ )
+ end
+ end
+
+ context 'when querying group and sub-group of this group' do
+ let(:filter) { described_class.id_in([group_a, group_a_sub_1, group_c]) }
+
+ it 'returns parent groups as this contains all sub-groups' do
+ is_expected.to contain_exactly(
+ [group_a.id],
+ [group_c.id]
+ )
+ end
+ end
+ end
+ end
+
context 'when project namespace exists in the group' do
let!(:project) { create(:project, group: group) }
let!(:project_namespace) { project.project_namespace }
@@ -737,11 +843,23 @@ RSpec.describe Group do
describe '#add_user' do
let(:user) { create(:user) }
- before do
+ it 'adds the user with a blocking refresh by default' do
+ expect_next_instance_of(GroupMember) do |member|
+ expect(member).to receive(:refresh_member_authorized_projects).with(blocking: true)
+ end
+
group.add_user(user, GroupMember::MAINTAINER)
+
+ expect(group.group_members.maintainers.map(&:user)).to include(user)
end
- it { expect(group.group_members.maintainers.map(&:user)).to include(user) }
+ it 'passes the blocking refresh value to member' do
+ expect_next_instance_of(GroupMember) do |member|
+ expect(member).to receive(:refresh_member_authorized_projects).with(blocking: false)
+ end
+
+ group.add_user(user, GroupMember::MAINTAINER, blocking_refresh: false)
+ end
end
describe '#add_users' do
@@ -3246,4 +3364,57 @@ RSpec.describe Group do
it_behaves_like 'no effective expiration interval'
end
end
+
+ describe '#work_items_feature_flag_enabled?' do
+ it_behaves_like 'checks self and root ancestor feature flag' do
+ let(:feature_flag) { :work_items }
+ let(:feature_flag_method) { :work_items_feature_flag_enabled? }
+ end
+ end
+
+ describe 'group shares' do
+ let!(:sub_group) { create(:group, parent: group) }
+ let!(:sub_sub_group) { create(:group, parent: sub_group) }
+ let!(:shared_group_1) { create(:group) }
+ let!(:shared_group_2) { create(:group) }
+ let!(:shared_group_3) { create(:group) }
+
+ before do
+ group.shared_with_groups << shared_group_1
+ sub_group.shared_with_groups << shared_group_2
+ sub_sub_group.shared_with_groups << shared_group_3
+ end
+
+ describe '#shared_with_group_links.of_ancestors' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:subject_group, :result) do
+ ref(:group) | []
+ ref(:sub_group) | lazy { [shared_group_1].map(&:id) }
+ ref(:sub_sub_group) | lazy { [shared_group_1, shared_group_2].map(&:id) }
+ end
+
+ with_them do
+ it 'returns correct group shares' do
+ expect(subject_group.shared_with_group_links.of_ancestors.map(&:shared_with_group_id)).to match_array(result)
+ end
+ end
+ end
+
+ describe '#shared_with_group_links.of_ancestors_and_self' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:subject_group, :result) do
+ ref(:group) | lazy { [shared_group_1].map(&:id) }
+ ref(:sub_group) | lazy { [shared_group_1, shared_group_2].map(&:id) }
+ ref(:sub_sub_group) | lazy { [shared_group_1, shared_group_2, shared_group_3].map(&:id) }
+ end
+
+ with_them do
+ it 'returns correct group shares' do
+ expect(subject_group.shared_with_group_links.of_ancestors_and_self.map(&:shared_with_group_id)).to match_array(result)
+ end
+ end
+ end
+ end
end
diff --git a/spec/models/groups/feature_setting_spec.rb b/spec/models/groups/feature_setting_spec.rb
new file mode 100644
index 00000000000..f1e66744b90
--- /dev/null
+++ b/spec/models/groups/feature_setting_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Groups::FeatureSetting do
+ describe 'associations' do
+ it { is_expected.to belong_to(:group) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:group) }
+ end
+end
diff --git a/spec/models/integration_spec.rb b/spec/models/integration_spec.rb
index 48d8ba975b6..0f596d3908d 100644
--- a/spec/models/integration_spec.rb
+++ b/spec/models/integration_spec.rb
@@ -60,6 +60,17 @@ RSpec.describe Integration do
end
describe 'Scopes' do
+ describe '.third_party_wikis' do
+ let!(:integration1) { create(:jira_integration) }
+ let!(:integration2) { create(:redmine_integration) }
+ let!(:integration3) { create(:confluence_integration) }
+ let!(:integration4) { create(:shimo_integration) }
+
+ it 'returns the right group integration' do
+ expect(described_class.third_party_wikis).to contain_exactly(integration3, integration4)
+ end
+ end
+
describe '.with_default_settings' do
it 'returns the correct integrations' do
instance_integration = create(:integration, :instance)
@@ -265,6 +276,20 @@ RSpec.describe Integration do
end
end
+ describe '#inheritable?' do
+ it 'is true for an instance integration' do
+ expect(create(:integration, :instance)).to be_inheritable
+ end
+
+ it 'is true for a group integration' do
+ expect(create(:integration, :group)).to be_inheritable
+ end
+
+ it 'is false for a project integration' do
+ expect(create(:integration)).not_to be_inheritable
+ end
+ end
+
describe '.build_from_integration' do
context 'when integration is invalid' do
let(:invalid_integration) do
@@ -462,6 +487,18 @@ RSpec.describe Integration do
expect(project.reload.integrations.first.inherit_from_id).to eq(group_integration.id)
end
+ context 'there are multiple inheritable integrations, and a duplicate' do
+ let!(:group_integration_2) { create(:jenkins_integration, :group, group: group) }
+ let!(:group_integration_3) { create(:datadog_integration, :instance) }
+ let!(:duplicate) { create(:jenkins_integration, project: project) }
+
+ it 'returns the number of successfully created integrations' do
+ expect(described_class.create_from_active_default_integrations(project, :project_id)).to eq 2
+
+ expect(project.reload.integrations.size).to eq(3)
+ end
+ end
+
context 'passing a group' do
let!(:subgroup) { create(:group, parent: group) }
@@ -621,6 +658,33 @@ RSpec.describe Integration do
end
end
+ describe '#properties=' do
+ let(:integration_type) do
+ Class.new(described_class) do
+ field :foo
+ field :bar
+ end
+ end
+
+ it 'supports indifferent access' do
+ integration = integration_type.new
+
+ integration.properties = { foo: 1, 'bar' => 2 }
+
+ expect(integration).to have_attributes(foo: 1, bar: 2)
+ end
+ end
+
+ describe '#properties' do
+ it 'is not mutable' do
+ integration = described_class.new
+
+ integration.properties = { foo: 1, bar: 2 }
+
+ expect { integration.properties[:foo] = 3 }.to raise_error
+ end
+ end
+
describe "{property}_touched?" do
let(:integration) do
Integrations::Bamboo.create!(
@@ -720,7 +784,7 @@ RSpec.describe Integration do
describe '#api_field_names' do
shared_examples 'api field names' do
- it 'filters out sensitive fields' do
+ it 'filters out secret fields' do
safe_fields = %w[some_safe_field safe_field url trojan_gift]
expect(fake_integration.new).to have_attributes(
@@ -857,7 +921,7 @@ RSpec.describe Integration do
end
end
- describe '#password_fields' do
+ describe '#secret_fields' do
it 'returns all fields with type `password`' do
allow(subject).to receive(:fields).and_return([
{ name: 'password', type: 'password' },
@@ -865,53 +929,34 @@ RSpec.describe Integration do
{ name: 'public', type: 'text' }
])
- expect(subject.password_fields).to match_array(%w[password secret])
+ expect(subject.secret_fields).to match_array(%w[password secret])
end
- it 'returns an empty array if no password fields exist' do
- expect(subject.password_fields).to eq([])
+ it 'returns an empty array if no secret fields exist' do
+ expect(subject.secret_fields).to eq([])
end
end
- describe 'encrypted_properties' do
+ describe '#to_integration_hash' do
let(:properties) { { foo: 1, bar: true } }
let(:db_props) { properties.stringify_keys }
let(:record) { create(:integration, :instance, properties: properties) }
- it 'contains the same data as properties' do
- expect(record).to have_attributes(
- properties: db_props,
- encrypted_properties_tmp: db_props
- )
- end
-
- it 'is persisted' do
- encrypted_properties = described_class.id_in(record.id)
-
- expect(encrypted_properties).to contain_exactly have_attributes(encrypted_properties_tmp: db_props)
- end
-
- it 'is updated when using prop_accessors' do
- some_integration = Class.new(described_class) do
- prop_accessor :foo
- end
-
- record = some_integration.new
-
- record.foo = 'the foo'
+ it 'does not include the properties key' do
+ hash = record.to_integration_hash
- expect(record.encrypted_properties_tmp).to eq({ 'foo' => 'the foo' })
+ expect(hash).not_to have_key('properties')
end
it 'saves correctly using insert_all' do
hash = record.to_integration_hash
- hash[:project_id] = project
+ hash[:project_id] = project.id
expect do
described_class.insert_all([hash])
end.to change(described_class, :count).by(1)
- expect(described_class.last).to have_attributes(encrypted_properties_tmp: db_props)
+ expect(described_class.last).to have_attributes(properties: db_props)
end
it 'is part of the to_integration_hash' do
@@ -921,7 +966,7 @@ RSpec.describe Integration do
expect(hash['encrypted_properties']).not_to eq(record.encrypted_properties)
expect(hash['encrypted_properties_iv']).not_to eq(record.encrypted_properties_iv)
- decrypted = described_class.decrypt(:encrypted_properties_tmp,
+ decrypted = described_class.decrypt(:properties,
hash['encrypted_properties'],
{ iv: hash['encrypted_properties_iv'] })
@@ -946,7 +991,7 @@ RSpec.describe Integration do
end.to change(described_class, :count).by(1)
expect(described_class.last).not_to eq record
- expect(described_class.last).to have_attributes(encrypted_properties_tmp: db_props)
+ expect(described_class.last).to have_attributes(properties: db_props)
end
end
end
@@ -1021,4 +1066,97 @@ RSpec.describe Integration do
)
end
end
+
+ describe 'boolean_accessor' do
+ let(:klass) do
+ Class.new(Integration) do
+ boolean_accessor :test_value
+ end
+ end
+
+ let(:integration) { klass.new(properties: { test_value: input }) }
+
+ where(:input, :method_result, :predicate_method_result) do
+ true | true | true
+ false | false | false
+ 1 | true | true
+ 0 | false | false
+ '1' | true | true
+ '0' | false | false
+ 'true' | true | true
+ 'false' | false | false
+ 'foobar' | nil | false
+ '' | nil | false
+ nil | nil | false
+ 'on' | true | true
+ 'off' | false | false
+ 'yes' | true | true
+ 'no' | false | false
+ 'n' | false | false
+ 'y' | true | true
+ 't' | true | true
+ 'f' | false | false
+ end
+
+ with_them do
+ it 'has the correct value' do
+ expect(integration).to have_attributes(
+ test_value: be(method_result),
+ test_value?: be(predicate_method_result)
+ )
+ end
+ end
+
+ it 'returns values when initialized without input' do
+ integration = klass.new
+
+ expect(integration).to have_attributes(
+ test_value: be(nil),
+ test_value?: be(false)
+ )
+ end
+ end
+
+ describe '#attributes' do
+ it 'does not include properties' do
+ expect(create(:integration).attributes).not_to have_key('properties')
+ end
+
+ it 'can be used in assign_attributes without nullifying properties' do
+ record = create(:integration, :instance, properties: { url: generate(:url) })
+
+ attrs = record.attributes
+
+ expect { record.assign_attributes(attrs) }.not_to change(record, :properties)
+ end
+ end
+
+ describe '#dup' do
+ let(:original) { create(:integration, properties: { one: 1, two: 2, three: 3 }) }
+
+ it 'results in distinct ciphertexts, but identical properties' do
+ copy = original.dup
+
+ expect(copy).to have_attributes(properties: eq(original.properties))
+
+ expect(copy).not_to have_attributes(
+ encrypted_properties: eq(original.encrypted_properties)
+ )
+ end
+
+ context 'when the model supports data-fields' do
+ let(:original) { create(:jira_integration, username: generate(:username), url: generate(:url)) }
+
+ it 'creates distinct but identical data-fields' do
+ copy = original.dup
+
+ expect(copy).to have_attributes(
+ username: original.username,
+ url: original.url
+ )
+
+ expect(copy.data_fields).not_to eq(original.data_fields)
+ end
+ end
+ end
end
diff --git a/spec/models/integrations/base_third_party_wiki_spec.rb b/spec/models/integrations/base_third_party_wiki_spec.rb
new file mode 100644
index 00000000000..11e044c2a18
--- /dev/null
+++ b/spec/models/integrations/base_third_party_wiki_spec.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Integrations::BaseThirdPartyWiki do
+ describe 'Validations' do
+ let_it_be_with_reload(:project) { create(:project) }
+
+ describe 'only one third party wiki per project' do
+ subject(:integration) { create(:shimo_integration, project: project, active: true) }
+
+ before_all do
+ create(:confluence_integration, project: project, active: true)
+ end
+
+ context 'when integration is changed manually by user' do
+ it 'executes the validation' do
+ valid = integration.valid?(:manual_change)
+
+ expect(valid).to be_falsey
+ error_message = 'Another third-party wiki is already in use. '\
+ 'Only one third-party wiki integration can be active at a time'
+ expect(integration.errors[:base]).to include _(error_message)
+ end
+ end
+
+ context 'when integration is changed internally' do
+ it 'does not execute the validation' do
+ expect(integration.valid?).to be_truthy
+ end
+ end
+
+ context 'when integration is not on the project level' do
+ subject(:integration) { create(:shimo_integration, :instance, active: true) }
+
+ it 'executes the validation' do
+ expect(integration.valid?(:manual_change)).to be_truthy
+ end
+ end
+ end
+ end
+end
diff --git a/spec/models/integrations/emails_on_push_spec.rb b/spec/models/integrations/emails_on_push_spec.rb
index bdca267f6cb..15aa105e379 100644
--- a/spec/models/integrations/emails_on_push_spec.rb
+++ b/spec/models/integrations/emails_on_push_spec.rb
@@ -78,9 +78,10 @@ RSpec.describe Integrations::EmailsOnPush do
end
describe '.valid_recipients' do
- let(:recipients) { '<invalid> foobar Valid@recipient.com Dup@lica.te dup@lica.te Dup@Lica.te' }
+ let(:recipients) { '<invalid> foobar valid@dup@asd Valid@recipient.com Dup@lica.te dup@lica.te Dup@Lica.te' }
it 'removes invalid email addresses and removes duplicates by keeping the original capitalization' do
+ expect(described_class.valid_recipients(recipients)).not_to contain_exactly('valid@dup@asd')
expect(described_class.valid_recipients(recipients)).to contain_exactly('Valid@recipient.com', 'Dup@lica.te')
end
end
diff --git a/spec/models/integrations/external_wiki_spec.rb b/spec/models/integrations/external_wiki_spec.rb
index e4d6a1c7c84..1621605d39f 100644
--- a/spec/models/integrations/external_wiki_spec.rb
+++ b/spec/models/integrations/external_wiki_spec.rb
@@ -24,7 +24,7 @@ RSpec.describe Integrations::ExternalWiki do
describe 'test' do
before do
- subject.properties['external_wiki_url'] = url
+ subject.external_wiki_url = url
end
let(:url) { 'http://foo' }
diff --git a/spec/models/integrations/field_spec.rb b/spec/models/integrations/field_spec.rb
index 0d660c4a3ab..c8caf831191 100644
--- a/spec/models/integrations/field_spec.rb
+++ b/spec/models/integrations/field_spec.rb
@@ -84,17 +84,17 @@ RSpec.describe ::Integrations::Field do
end
end
- describe '#sensitive' do
+ describe '#secret?' do
context 'when empty' do
- it { is_expected.not_to be_sensitive }
+ it { is_expected.not_to be_secret }
end
- context 'when a password field' do
+ context 'when a secret field' do
before do
attrs[:type] = 'password'
end
- it { is_expected.to be_sensitive }
+ it { is_expected.to be_secret }
end
%w[token api_token api_key secret_key secret_sauce password passphrase].each do |name|
@@ -103,7 +103,7 @@ RSpec.describe ::Integrations::Field do
attrs[:name] = name
end
- it { is_expected.to be_sensitive }
+ it { is_expected.to be_secret }
end
end
@@ -112,7 +112,7 @@ RSpec.describe ::Integrations::Field do
attrs[:name] = :url
end
- it { is_expected.not_to be_sensitive }
+ it { is_expected.not_to be_secret }
end
end
end
diff --git a/spec/models/integrations/jira_spec.rb b/spec/models/integrations/jira_spec.rb
index 08656bfe543..d244b1d33d5 100644
--- a/spec/models/integrations/jira_spec.rb
+++ b/spec/models/integrations/jira_spec.rb
@@ -187,7 +187,7 @@ RSpec.describe Integrations::Jira do
subject(:integration) { described_class.create!(params) }
it 'does not store data into properties' do
- expect(integration.properties).to be_nil
+ expect(integration.properties).to be_empty
end
it 'stores data in data_fields correctly' do
diff --git a/spec/models/integrations/slack_spec.rb b/spec/models/integrations/slack_spec.rb
index 9f69f4f51f8..3997d69f947 100644
--- a/spec/models/integrations/slack_spec.rb
+++ b/spec/models/integrations/slack_spec.rb
@@ -6,12 +6,12 @@ RSpec.describe Integrations::Slack do
it_behaves_like Integrations::SlackMattermostNotifier, "Slack"
describe '#execute' do
+ let_it_be(:slack_integration) { create(:integrations_slack, branches_to_be_notified: 'all') }
+
before do
stub_request(:post, slack_integration.webhook)
end
- let_it_be(:slack_integration) { create(:integrations_slack, branches_to_be_notified: 'all') }
-
it 'uses only known events', :aggregate_failures do
described_class::SUPPORTED_EVENTS_FOR_USAGE_LOG.each do |action|
expect(Gitlab::UsageDataCounters::HLLRedisCounter.known_event?("i_ecosystem_slack_service_#{action}_notification")).to be true
diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb
index 29305ba435c..fe09dadd0db 100644
--- a/spec/models/issue_spec.rb
+++ b/spec/models/issue_spec.rb
@@ -238,6 +238,24 @@ RSpec.describe Issue do
end
end
+ context 'order by escalation status' do
+ let_it_be(:triggered_incident) { create(:incident_management_issuable_escalation_status, :triggered).issue }
+ let_it_be(:resolved_incident) { create(:incident_management_issuable_escalation_status, :resolved).issue }
+ let_it_be(:issue_no_status) { create(:issue) }
+
+ describe '.order_escalation_status_asc' do
+ subject { described_class.order_escalation_status_asc }
+
+ it { is_expected.to eq([triggered_incident, resolved_incident, issue_no_status]) }
+ end
+
+ describe '.order_escalation_status_desc' do
+ subject { described_class.order_escalation_status_desc }
+
+ it { is_expected.to eq([resolved_incident, triggered_incident, issue_no_status]) }
+ end
+ end
+
# TODO: Remove when NOT NULL constraint is added to the relationship
describe '#work_item_type' do
let(:issue) { create(:issue, :incident, project: reusable_project, work_item_type: nil) }
@@ -1154,18 +1172,6 @@ RSpec.describe Issue do
end
end
- describe '#hook_attrs' do
- it 'delegates to Gitlab::HookData::IssueBuilder#build' do
- builder = double
-
- expect(Gitlab::HookData::IssueBuilder)
- .to receive(:new).with(subject).and_return(builder)
- expect(builder).to receive(:build)
-
- subject.hook_attrs
- end
- end
-
describe '#check_for_spam?' do
let_it_be(:support_bot) { ::User.support_bot }
@@ -1314,15 +1320,6 @@ RSpec.describe Issue do
subject { create(:issue, updated_at: 1.hour.ago) }
end
- describe "#labels_hook_attrs" do
- let(:label) { create(:label) }
- let(:issue) { create(:labeled_issue, project: reusable_project, labels: [label]) }
-
- it "returns a list of label hook attributes" do
- expect(issue.labels_hook_attrs).to eq([label.hook_attrs])
- end
- end
-
context "relative positioning" do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
diff --git a/spec/models/key_spec.rb b/spec/models/key_spec.rb
index 6cf73de6cef..e1135aa440b 100644
--- a/spec/models/key_spec.rb
+++ b/spec/models/key_spec.rb
@@ -123,25 +123,55 @@ RSpec.describe Key, :mailer do
end
end
- context "validation of uniqueness (based on fingerprint uniqueness)" do
+ context 'validation of uniqueness (based on fingerprint uniqueness)' do
let(:user) { create(:user) }
- it "accepts the key once" do
- expect(build(:key, user: user)).to be_valid
+ shared_examples 'fingerprint uniqueness' do
+ it 'accepts the key once' do
+ expect(build(:rsa_key_4096, user: user)).to be_valid
+ end
+
+ it 'does not accept the exact same key twice' do
+ first_key = create(:rsa_key_4096, user: user)
+
+ expect(build(:key, user: user, key: first_key.key)).not_to be_valid
+ end
+
+ it 'does not accept a duplicate key with a different comment' do
+ first_key = create(:rsa_key_4096, user: user)
+ duplicate = build(:key, user: user, key: first_key.key)
+ duplicate.key << ' extra comment'
+
+ expect(duplicate).not_to be_valid
+ end
end
- it "does not accept the exact same key twice" do
- first_key = create(:key, user: user)
+ context 'with FIPS mode off' do
+ it_behaves_like 'fingerprint uniqueness'
+ end
- expect(build(:key, user: user, key: first_key.key)).not_to be_valid
+ context 'with FIPS mode', :fips_mode do
+ it_behaves_like 'fingerprint uniqueness'
end
+ end
- it "does not accept a duplicate key with a different comment" do
- first_key = create(:key, user: user)
- duplicate = build(:key, user: user, key: first_key.key)
- duplicate.key << ' extra comment'
+ context 'fingerprint generation' do
+ it 'generates both md5 and sha256 fingerprints' do
+ key = build(:rsa_key_4096)
+
+ expect(key).to be_valid
+ expect(key.fingerprint).to be_kind_of(String)
+ expect(key.fingerprint_sha256).to be_kind_of(String)
+ end
- expect(duplicate).not_to be_valid
+ context 'with FIPS mode', :fips_mode do
+ it 'generates only sha256 fingerprint' do
+ key = build(:rsa_key_4096)
+
+ expect(key).to be_valid
+ expect(key.fingerprint).to be_nil
+ expect(key.fingerprint_sha256).to be_kind_of(String)
+ end
end
end
diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb
index 79491edba94..4ab17ee1e6d 100644
--- a/spec/models/member_spec.rb
+++ b/spec/models/member_spec.rb
@@ -682,14 +682,46 @@ RSpec.describe Member do
member.accept_invite!(user)
end
- it "refreshes user's authorized projects", :delete do
- project = member.source
+ context 'authorized projects' do
+ let(:project) { member.source }
- expect(user.authorized_projects).not_to include(project)
+ before do
+ expect(user.authorized_projects).not_to include(project)
+ end
- member.accept_invite!(user)
+ it 'successfully completes a blocking refresh', :delete do
+ expect(member).to receive(:refresh_member_authorized_projects).with(blocking: true).and_call_original
+
+ member.accept_invite!(user)
+
+ expect(user.authorized_projects.reload).to include(project)
+ end
+
+ it 'successfully completes a non-blocking refresh', :delete, :sidekiq_inline do
+ member.blocking_refresh = false
+
+ expect(member).to receive(:refresh_member_authorized_projects).with(blocking: false).and_call_original
+
+ member.accept_invite!(user)
+
+ expect(user.authorized_projects.reload).to include(project)
+ end
- expect(user.authorized_projects.reload).to include(project)
+ context 'when the feature flag is disabled' do
+ before do
+ stub_feature_flags(allow_non_blocking_member_refresh: false)
+ end
+
+ it 'successfully completes a blocking refresh', :delete, :sidekiq_inline do
+ member.blocking_refresh = false
+
+ expect(member).to receive(:refresh_member_authorized_projects).with(blocking: true).and_call_original
+
+ member.accept_invite!(user)
+
+ expect(user.authorized_projects.reload).to include(project)
+ end
+ end
end
it 'does not accept the invite if saving a new user fails' do
@@ -926,4 +958,64 @@ RSpec.describe Member do
end
end
end
+
+ describe '.sort_by_attribute' do
+ let_it_be(:user1) { create(:user, created_at: Date.today, last_sign_in_at: Date.today, last_activity_on: Date.today, name: 'Alpha') }
+ let_it_be(:user2) { create(:user, created_at: Date.today - 1, last_sign_in_at: Date.today - 1, last_activity_on: Date.today - 1, name: 'Omega') }
+ let_it_be(:user3) { create(:user, created_at: Date.today - 2, name: 'Beta') }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:member1) { create(:group_member, :reporter, group: group, user: user1) }
+ let_it_be(:member2) { create(:group_member, :developer, group: group, user: user2) }
+ let_it_be(:member3) { create(:group_member, :maintainer, group: group, user: user3) }
+
+ it 'sort users in ascending order by access-level' do
+ expect(described_class.sort_by_attribute('access_level_asc')).to eq([member1, member2, member3])
+ end
+
+ it 'sort users in descending order by access-level' do
+ expect(described_class.sort_by_attribute('access_level_desc')).to eq([member3, member2, member1])
+ end
+
+ context 'when sort by recent_sign_in' do
+ subject { described_class.sort_by_attribute('recent_sign_in') }
+
+ it 'sorts users by recent sign-in time' do
+ expect(subject.first).to eq(member1)
+ expect(subject.second).to eq(member2)
+ end
+
+ it 'pushes users who never signed in to the end' do
+ expect(subject.third).to eq(member3)
+ end
+ end
+
+ context 'when sort by oldest_sign_in' do
+ subject { described_class.sort_by_attribute('oldest_sign_in') }
+
+ it 'sorts users by the oldest sign-in time' do
+ expect(subject.first).to eq(member2)
+ expect(subject.second).to eq(member1)
+ end
+
+ it 'pushes users who never signed in to the end' do
+ expect(subject.third).to eq(member3)
+ end
+ end
+
+ it 'sorts users in descending order by their creation time' do
+ expect(described_class.sort_by_attribute('recent_created_user')).to eq([member1, member2, member3])
+ end
+
+ it 'sorts users in ascending order by their creation time' do
+ expect(described_class.sort_by_attribute('oldest_created_user')).to eq([member3, member2, member1])
+ end
+
+ it 'sort users by recent last activity' do
+ expect(described_class.sort_by_attribute('recent_last_activity')).to eq([member1, member2, member3])
+ end
+
+ it 'sort users by oldest last activity' do
+ expect(described_class.sort_by_attribute('oldest_last_activity')).to eq([member3, member2, member1])
+ end
+ end
end
diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb
index 0d15851e583..8545c7bc6c7 100644
--- a/spec/models/merge_request_spec.rb
+++ b/spec/models/merge_request_spec.rb
@@ -1757,18 +1757,6 @@ RSpec.describe MergeRequest, factory_default: :keep do
end
end
- describe '#hook_attrs' do
- it 'delegates to Gitlab::HookData::MergeRequestBuilder#build' do
- builder = double
-
- expect(Gitlab::HookData::MergeRequestBuilder)
- .to receive(:new).with(subject).and_return(builder)
- expect(builder).to receive(:build)
-
- subject.hook_attrs
- end
- end
-
describe '#diverged_commits_count' do
let(:project) { create(:project, :repository) }
let(:forked_project) { fork_project(project, nil, repository: true) }
@@ -3550,8 +3538,8 @@ RSpec.describe MergeRequest, factory_default: :keep do
end
end
- describe "#environments" do
- subject { merge_request.environments }
+ describe "#legacy_environments" do
+ subject { merge_request.legacy_environments }
let(:merge_request) { create(:merge_request, source_branch: 'feature', target_branch: 'master') }
let(:project) { merge_request.project }
diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb
index ebd153f6f10..09ac15429a5 100644
--- a/spec/models/namespace_spec.rb
+++ b/spec/models/namespace_spec.rb
@@ -2230,4 +2230,10 @@ RSpec.describe Namespace do
expect(namespace.storage_enforcement_date).to be(nil)
end
end
+
+ describe 'serialization' do
+ let(:object) { build(:namespace) }
+
+ it_behaves_like 'blocks unsafe serialization'
+ end
end
diff --git a/spec/models/namespaces/project_namespace_spec.rb b/spec/models/namespaces/project_namespace_spec.rb
index 47cf866c143..c995571c3c9 100644
--- a/spec/models/namespaces/project_namespace_spec.rb
+++ b/spec/models/namespaces/project_namespace_spec.rb
@@ -17,11 +17,11 @@ RSpec.describe Namespaces::ProjectNamespace, type: :model do
let_it_be(:project) { create(:project) }
let_it_be(:project_namespace) { project.project_namespace }
- it 'keeps the associated project' do
+ it 'also deletes associated project' do
project_namespace.delete
expect { project_namespace.reload }.to raise_error(ActiveRecord::RecordNotFound)
- expect(project.reload.project_namespace).to be_nil
+ expect { project.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
end
diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb
index cbfedf54ffa..4b262c1f3a9 100644
--- a/spec/models/note_spec.rb
+++ b/spec/models/note_spec.rb
@@ -105,6 +105,104 @@ RSpec.describe Note do
it { is_expected.to be_valid }
end
end
+
+ describe 'confidentiality' do
+ context 'for existing public note' do
+ let_it_be(:existing_note) { create(:note) }
+
+ it 'is not possible to change the note to confidential' do
+ existing_note.confidential = true
+
+ expect(existing_note).not_to be_valid
+ expect(existing_note.errors[:confidential]).to include('can not be changed for existing notes')
+ end
+
+ it 'is possible to change confidentiality from nil to false' do
+ existing_note.confidential = false
+
+ expect(existing_note).to be_valid
+ end
+ end
+
+ context 'for existing confidential note' do
+ let_it_be(:existing_note) { create(:note, confidential: true) }
+
+ it 'is not possible to change the note to public' do
+ existing_note.confidential = false
+
+ expect(existing_note).not_to be_valid
+ expect(existing_note.errors[:confidential]).to include('can not be changed for existing notes')
+ end
+ end
+
+ context 'for a new note' do
+ let_it_be(:noteable) { create(:issue) }
+
+ let(:note_params) { { confidential: true, noteable: noteable, project: noteable.project } }
+
+ subject { build(:note, **note_params) }
+
+ it 'allows to create a confidential note for an issue' do
+ expect(subject).to be_valid
+ end
+
+ context 'when noteable is not allowed to have confidential notes' do
+ let_it_be(:noteable) { create(:merge_request) }
+
+ it 'can not be set confidential' do
+ expect(subject).not_to be_valid
+ expect(subject.errors[:confidential]).to include('can not be set for this resource')
+ end
+ end
+
+ context 'when note type is not allowed to be confidential' do
+ let(:note_params) { { type: 'DiffNote', confidential: true, noteable: noteable, project: noteable.project } }
+
+ it 'can not be set confidential' do
+ expect(subject).not_to be_valid
+ expect(subject.errors[:confidential]).to include('can not be set for this type of note')
+ end
+ end
+
+ context 'when the note is a discussion note' do
+ let(:note_params) { { type: 'DiscussionNote', confidential: true, noteable: noteable, project: noteable.project } }
+
+ it { is_expected.to be_valid }
+ end
+
+ context 'when replying to a note' do
+ let(:note_params) { { confidential: true, noteable: noteable, project: noteable.project } }
+
+ subject { build(:discussion_note, discussion_id: original_note.discussion_id, **note_params) }
+
+ context 'when the note is reply to a confidential note' do
+ let_it_be(:original_note) { create(:note, confidential: true, noteable: noteable, project: noteable.project) }
+
+ it { is_expected.to be_valid }
+ end
+
+ context 'when the note is reply to a public note' do
+ let_it_be(:original_note) { create(:note, noteable: noteable, project: noteable.project) }
+
+ it 'can not be set confidential' do
+ expect(subject).not_to be_valid
+ expect(subject.errors[:confidential]).to include('reply should have same confidentiality as top-level note')
+ end
+ end
+
+ context 'when reply note is public but discussion is confidential' do
+ let_it_be(:original_note) { create(:note, confidential: true, noteable: noteable, project: noteable.project) }
+
+ let(:note_params) { { noteable: noteable, project: noteable.project } }
+
+ it 'can not be set confidential' do
+ expect(subject).not_to be_valid
+ expect(subject.errors[:confidential]).to include('reply should have same confidentiality as top-level note')
+ end
+ end
+ end
+ end
+ end
end
describe 'callbacks' do
@@ -1169,8 +1267,8 @@ RSpec.describe Note do
end
describe "#discussion" do
- let!(:note1) { create(:discussion_note_on_merge_request) }
- let!(:note2) { create(:diff_note_on_merge_request, project: note1.project, noteable: note1.noteable) }
+ let_it_be(:note1) { create(:discussion_note_on_merge_request) }
+ let_it_be(:note2) { create(:diff_note_on_merge_request, project: note1.project, noteable: note1.noteable) }
context 'when the note is part of a discussion' do
subject { create(:discussion_note_on_merge_request, project: note1.project, noteable: note1.noteable, in_reply_to: note1) }
@@ -1655,4 +1753,27 @@ RSpec.describe Note do
expect(note.commands_changes.keys).to contain_exactly(:emoji_award, :time_estimate, :spend_time)
end
end
+
+ describe '#bump_updated_at', :freeze_time do
+ it 'sets updated_at to the current timestamp' do
+ note = create(:note, updated_at: 1.day.ago)
+
+ note.bump_updated_at
+ note.reload
+
+ expect(note.updated_at).to be_like_time(Time.current)
+ end
+
+ context 'with legacy edited note' do
+ it 'copies updated_at to last_edited_at before bumping the timestamp' do
+ note = create(:note, updated_at: 1.day.ago, updated_by: create(:user), last_edited_at: nil)
+
+ note.bump_updated_at
+ note.reload
+
+ expect(note.last_edited_at).to be_like_time(1.day.ago)
+ expect(note.updated_at).to be_like_time(Time.current)
+ end
+ end
+ end
end
diff --git a/spec/models/packages/package_file_spec.rb b/spec/models/packages/package_file_spec.rb
index fd453d8e5a9..f6af8f6a951 100644
--- a/spec/models/packages/package_file_spec.rb
+++ b/spec/models/packages/package_file_spec.rb
@@ -23,6 +23,28 @@ RSpec.describe Packages::PackageFile, type: :model do
describe 'validations' do
it { is_expected.to validate_presence_of(:package) }
+
+ context 'with pypi package' do
+ let_it_be(:package) { create(:pypi_package) }
+
+ let(:package_file) { package.package_files.first }
+ let(:status) { :default }
+ let(:file) { fixture_file_upload('spec/fixtures/dk.png') }
+
+ subject { package.package_files.create!(file: file, file_name: package_file.file_name, status: status) }
+
+ it 'can not save a duplicated file' do
+ expect { subject }.to raise_error(ActiveRecord::RecordInvalid, "Validation failed: File name has already been taken")
+ end
+
+ context 'with a pending destruction package duplicated file' do
+ let(:status) { :pending_destruction }
+
+ it 'can save it' do
+ expect { subject }.to change { package.package_files.count }.from(1).to(2)
+ end
+ end
+ end
end
context 'with package filenames' do
diff --git a/spec/models/packages/package_spec.rb b/spec/models/packages/package_spec.rb
index 52ed52de193..6c86db1197f 100644
--- a/spec/models/packages/package_spec.rb
+++ b/spec/models/packages/package_spec.rb
@@ -1021,13 +1021,13 @@ RSpec.describe Packages::Package, type: :model do
context 'ascending direction' do
let(:direction) { :asc }
- it { is_expected.to eq('projects.name asc NULLS LAST, "packages_packages"."id" ASC') }
+ it { is_expected.to eq('"projects"."name" ASC NULLS LAST, "packages_packages"."id" ASC') }
end
context 'descending direction' do
let(:direction) { :desc }
- it { is_expected.to eq('projects.name desc NULLS FIRST, "packages_packages"."id" DESC') }
+ it { is_expected.to eq('"projects"."name" DESC NULLS FIRST, "packages_packages"."id" DESC') }
end
end
end
diff --git a/spec/models/plan_limits_spec.rb b/spec/models/plan_limits_spec.rb
index 72fda2280e5..381e42978f4 100644
--- a/spec/models/plan_limits_spec.rb
+++ b/spec/models/plan_limits_spec.rb
@@ -214,6 +214,7 @@ RSpec.describe PlanLimits do
daily_invites
web_hook_calls
ci_daily_pipeline_schedule_triggers
+ repository_size
] + disabled_max_artifact_size_columns
end
diff --git a/spec/models/preloaders/environments/deployment_preloader_spec.rb b/spec/models/preloaders/environments/deployment_preloader_spec.rb
index 3f2f28a069e..4c05d9632de 100644
--- a/spec/models/preloaders/environments/deployment_preloader_spec.rb
+++ b/spec/models/preloaders/environments/deployment_preloader_spec.rb
@@ -6,14 +6,14 @@ RSpec.describe Preloaders::Environments::DeploymentPreloader do
let_it_be(:user) { create(:user) }
let_it_be(:project, reload: true) { create(:project, :repository) }
- let_it_be(:pipeline) { create(:ci_pipeline, user: user, project: project, sha: project.commit.sha) }
- let_it_be(:ci_build_a) { create(:ci_build, user: user, project: project, pipeline: pipeline) }
- let_it_be(:ci_build_b) { create(:ci_build, user: user, project: project, pipeline: pipeline) }
- let_it_be(:ci_build_c) { create(:ci_build, user: user, project: project, pipeline: pipeline) }
-
let_it_be(:environment_a) { create(:environment, project: project, state: :available) }
let_it_be(:environment_b) { create(:environment, project: project, state: :available) }
+ let_it_be(:pipeline) { create(:ci_pipeline, user: user, project: project, sha: project.commit.sha) }
+ let_it_be(:ci_build_a) { create(:ci_build, user: user, project: project, pipeline: pipeline, environment: environment_a.name) }
+ let_it_be(:ci_build_b) { create(:ci_build, user: user, project: project, pipeline: pipeline, environment: environment_a.name) }
+ let_it_be(:ci_build_c) { create(:ci_build, user: user, project: project, pipeline: pipeline, environment: environment_b.name) }
+
before do
create(:deployment, :success, project: project, environment: environment_a, deployable: ci_build_a)
create(:deployment, :success, project: project, environment: environment_a, deployable: ci_build_b)
diff --git a/spec/models/preloaders/group_root_ancestor_preloader_spec.rb b/spec/models/preloaders/group_root_ancestor_preloader_spec.rb
new file mode 100644
index 00000000000..0d622e84ef1
--- /dev/null
+++ b/spec/models/preloaders/group_root_ancestor_preloader_spec.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Preloaders::GroupRootAncestorPreloader do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:root_parent1) { create(:group, :private, name: 'root-1', path: 'root-1') }
+ let_it_be(:root_parent2) { create(:group, :private, name: 'root-2', path: 'root-2') }
+ let_it_be(:guest_group) { create(:group, name: 'public guest', path: 'public-guest') }
+ let_it_be(:private_maintainer_group) { create(:group, :private, name: 'b private maintainer', path: 'b-private-maintainer', parent: root_parent1) }
+ let_it_be(:private_developer_group) { create(:group, :private, project_creation_level: nil, name: 'c public developer', path: 'c-public-developer') }
+ let_it_be(:public_maintainer_group) { create(:group, :private, name: 'a public maintainer', path: 'a-public-maintainer', parent: root_parent2) }
+
+ let(:root_query_regex) { /\ASELECT.+FROM "namespaces" WHERE "namespaces"."id" = \d+/ }
+ let(:additional_preloads) { [] }
+ let(:groups) { [guest_group, private_maintainer_group, private_developer_group, public_maintainer_group] }
+ let(:pristine_groups) { Group.where(id: groups) }
+
+ shared_examples 'executes N matching DB queries' do |expected_query_count, query_method = nil|
+ it 'executes the specified root_ancestor queries' do
+ expect do
+ pristine_groups.each do |group|
+ root_ancestor = group.root_ancestor
+
+ root_ancestor.public_send(query_method) if query_method.present?
+ end
+ end.to make_queries_matching(root_query_regex, expected_query_count)
+ end
+
+ it 'strong_memoizes the correct root_ancestor' do
+ pristine_groups.each do |group|
+ expected_parent_id = group.root_ancestor.id == group.id ? nil : group.root_ancestor.id
+
+ expect(group.parent_id).to eq(expected_parent_id)
+ end
+ end
+ end
+
+ context 'when the preloader is used' do
+ before do
+ preload_ancestors
+ end
+
+ context 'when no additional preloads are provided' do
+ it_behaves_like 'executes N matching DB queries', 0
+ end
+
+ context 'when additional preloads are provided' do
+ let(:additional_preloads) { [:route] }
+ let(:root_query_regex) { /\ASELECT.+FROM "routes" WHERE "routes"."source_id" = \d+/ }
+
+ it_behaves_like 'executes N matching DB queries', 0, :full_path
+ end
+ end
+
+ context 'when the preloader is not used' do
+ it_behaves_like 'executes N matching DB queries', 2
+ end
+
+ def preload_ancestors
+ described_class.new(pristine_groups, additional_preloads).execute
+ end
+end
diff --git a/spec/models/programming_language_spec.rb b/spec/models/programming_language_spec.rb
index f2201eabd1c..b202c10e30b 100644
--- a/spec/models/programming_language_spec.rb
+++ b/spec/models/programming_language_spec.rb
@@ -10,4 +10,22 @@ RSpec.describe ProgrammingLanguage do
it { is_expected.to allow_value("#000000").for(:color) }
it { is_expected.not_to allow_value("000000").for(:color) }
it { is_expected.not_to allow_value("#0z0000").for(:color) }
+
+ describe '.with_name_case_insensitive scope' do
+ let_it_be(:ruby) { create(:programming_language, name: 'Ruby') }
+ let_it_be(:python) { create(:programming_language, name: 'Python') }
+ let_it_be(:swift) { create(:programming_language, name: 'Swift') }
+
+ it 'accepts a single name parameter' do
+ expect(described_class.with_name_case_insensitive('swift')).to(
+ contain_exactly(swift)
+ )
+ end
+
+ it 'accepts multiple names' do
+ expect(described_class.with_name_case_insensitive('ruby', 'python')).to(
+ contain_exactly(ruby, python)
+ )
+ end
+ end
end
diff --git a/spec/models/project_feature_spec.rb b/spec/models/project_feature_spec.rb
index 75e43ed9a67..941f6c0a49d 100644
--- a/spec/models/project_feature_spec.rb
+++ b/spec/models/project_feature_spec.rb
@@ -5,8 +5,8 @@ require 'spec_helper'
RSpec.describe ProjectFeature do
using RSpec::Parameterized::TableSyntax
- let(:project) { create(:project) }
- let(:user) { create(:user) }
+ let_it_be_with_reload(:project) { create(:project) }
+ let_it_be(:user) { create(:user) }
it { is_expected.to belong_to(:project) }
@@ -242,4 +242,95 @@ RSpec.describe ProjectFeature do
end
end
end
+
+ # rubocop:disable Gitlab/FeatureAvailableUsage
+ describe '#feature_available?' do
+ let(:features) { ProjectFeature::FEATURES }
+
+ context 'when features are disabled' do
+ it 'returns false' do
+ update_all_project_features(project, features, ProjectFeature::DISABLED)
+
+ features.each do |feature|
+ expect(project.feature_available?(feature.to_sym, user)).to eq(false), "#{feature} failed"
+ end
+ end
+ end
+
+ context 'when features are enabled only for team members' do
+ it 'returns false when user is not a team member' do
+ update_all_project_features(project, features, ProjectFeature::PRIVATE)
+
+ features.each do |feature|
+ expect(project.feature_available?(feature.to_sym, user)).to eq(false), "#{feature} failed"
+ end
+ end
+
+ it 'returns true when user is a team member' do
+ project.add_developer(user)
+
+ update_all_project_features(project, features, ProjectFeature::PRIVATE)
+
+ features.each do |feature|
+ expect(project.feature_available?(feature.to_sym, user)).to eq(true)
+ end
+ end
+
+ it 'returns true when user is a member of project group' do
+ group = create(:group)
+ project = create(:project, namespace: group)
+ group.add_developer(user)
+
+ update_all_project_features(project, features, ProjectFeature::PRIVATE)
+
+ features.each do |feature|
+ expect(project.feature_available?(feature.to_sym, user)).to eq(true)
+ end
+ end
+
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it 'returns true if user is an admin' do
+ user.update_attribute(:admin, true)
+
+ update_all_project_features(project, features, ProjectFeature::PRIVATE)
+
+ features.each do |feature|
+ expect(project.feature_available?(feature.to_sym, user)).to eq(true)
+ end
+ end
+ end
+
+ context 'when admin mode is disabled' do
+ it 'returns false when user is an admin' do
+ user.update_attribute(:admin, true)
+
+ update_all_project_features(project, features, ProjectFeature::PRIVATE)
+
+ features.each do |feature|
+ expect(project.feature_available?(feature.to_sym, user)).to eq(false), "#{feature} failed"
+ end
+ end
+ end
+ end
+
+ context 'when feature is enabled for everyone' do
+ it 'returns true' do
+ expect(project.feature_available?(:issues, user)).to eq(true)
+ end
+ end
+
+ context 'when feature has any other value' do
+ it 'returns true' do
+ project.project_feature.update_attribute(:issues_access_level, 200)
+
+ expect(project.feature_available?(:issues)).to eq(true)
+ end
+ end
+
+ def update_all_project_features(project, features, value)
+ project_feature_attributes = features.to_h { |f| ["#{f}_access_level", value] }
+ project.project_feature.update!(project_feature_attributes)
+ end
+ end
+ # rubocop:enable Gitlab/FeatureAvailableUsage
end
diff --git a/spec/models/project_import_state_spec.rb b/spec/models/project_import_state_spec.rb
index 4ad2446f8d0..42ca8130734 100644
--- a/spec/models/project_import_state_spec.rb
+++ b/spec/models/project_import_state_spec.rb
@@ -22,7 +22,7 @@ RSpec.describe ProjectImportState, type: :model do
before do
allow_any_instance_of(Gitlab::GitalyClient::RepositoryService).to receive(:import_repository)
- .with(project.import_url).and_return(true)
+ .with(project.import_url, http_authorization_header: '', mirror: false).and_return(true)
# Works around https://github.com/rspec/rspec-mocks/issues/910
allow(Project).to receive(:find).with(project.id).and_return(project)
@@ -89,19 +89,6 @@ RSpec.describe ProjectImportState, type: :model do
import_state.mark_as_failed(error_message)
end.to change { project.reload.import_data }.from(import_data).to(nil)
end
-
- context 'when remove_import_data_on_failure feature flag is disabled' do
- it 'removes project import data' do
- stub_feature_flags(remove_import_data_on_failure: false)
-
- project = create(:project, import_data: ProjectImportData.new(data: { 'test' => 'some data' }))
- import_state = create(:import_state, :started, project: project)
-
- expect do
- import_state.mark_as_failed(error_message)
- end.not_to change { project.reload.import_data }
- end
- end
end
describe '#human_status_name' do
@@ -114,6 +101,34 @@ RSpec.describe ProjectImportState, type: :model do
end
end
+ describe '#expire_etag_cache' do
+ context 'when project import type has realtime changes endpoint' do
+ before do
+ import_state.project.import_type = 'github'
+ end
+
+ it 'expires revelant etag cache' do
+ expect_next_instance_of(Gitlab::EtagCaching::Store) do |instance|
+ expect(instance).to receive(:touch).with(Gitlab::Routing.url_helpers.realtime_changes_import_github_path(format: :json))
+ end
+
+ subject.expire_etag_cache
+ end
+ end
+
+ context 'when project import type does not have realtime changes endpoint' do
+ before do
+ import_state.project.import_type = 'jira'
+ end
+
+ it 'does not touch etag caches' do
+ expect(Gitlab::EtagCaching::Store).not_to receive(:new)
+
+ subject.expire_etag_cache
+ end
+ end
+ end
+
describe 'import state transitions' do
context 'state transition: [:started] => [:finished]' do
let(:after_import_service) { spy(:after_import_service) }
@@ -191,4 +206,20 @@ RSpec.describe ProjectImportState, type: :model do
end
end
end
+
+ describe 'callbacks' do
+ context 'after_commit :expire_etag_cache' do
+ before do
+ import_state.project.import_type = 'github'
+ end
+
+ it 'expires etag cache' do
+ expect_next_instance_of(Gitlab::EtagCaching::Store) do |instance|
+ expect(instance).to receive(:touch).with(Gitlab::Routing.url_helpers.realtime_changes_import_github_path(format: :json))
+ end
+
+ subject.save!
+ end
+ end
+ end
end
diff --git a/spec/models/project_setting_spec.rb b/spec/models/project_setting_spec.rb
index 5572304d666..d03eb3c8bfe 100644
--- a/spec/models/project_setting_spec.rb
+++ b/spec/models/project_setting_spec.rb
@@ -4,4 +4,34 @@ require 'spec_helper'
RSpec.describe ProjectSetting, type: :model do
it { is_expected.to belong_to(:project) }
+
+ describe 'validations' do
+ it { is_expected.not_to allow_value(nil).for(:target_platforms) }
+ it { is_expected.to allow_value([]).for(:target_platforms) }
+
+ it 'allows any combination of the allowed target platforms' do
+ valid_target_platform_combinations.each do |target_platforms|
+ expect(subject).to allow_value(target_platforms).for(:target_platforms)
+ end
+ end
+
+ [nil, 'not_allowed', :invalid].each do |invalid_value|
+ it { is_expected.not_to allow_value([invalid_value]).for(:target_platforms) }
+ end
+ end
+
+ describe 'target_platforms=' do
+ it 'stringifies and sorts' do
+ project_setting = build(:project_setting, target_platforms: [:watchos, :ios])
+ expect(project_setting.target_platforms).to eq %w(ios watchos)
+ end
+ end
+
+ def valid_target_platform_combinations
+ target_platforms = described_class::ALLOWED_TARGET_PLATFORMS
+
+ 0.upto(target_platforms.size).flat_map do |n|
+ target_platforms.permutation(n).to_a
+ end
+ end
end
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index fc7ac35ed41..0bb584845c2 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -536,7 +536,7 @@ RSpec.describe Project, factory_default: :keep do
project = build(:project)
aggregate_failures do
- urls_with_CRLF.each do |url|
+ urls_with_crlf.each do |url|
project.import_url = url
expect(project).not_to be_valid
@@ -549,7 +549,7 @@ RSpec.describe Project, factory_default: :keep do
project = build(:project)
aggregate_failures do
- valid_urls_with_CRLF.each do |url|
+ valid_urls_with_crlf.each do |url|
project.import_url = url
expect(project).to be_valid
@@ -635,6 +635,8 @@ RSpec.describe Project, factory_default: :keep do
end
end
+ it_behaves_like 'a BulkUsersByEmailLoad model'
+
describe '#all_pipelines' do
let_it_be(:project) { create(:project) }
@@ -724,6 +726,33 @@ RSpec.describe Project, factory_default: :keep do
end
end
+ describe '#personal_namespace_holder?' do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:namespace_user) { create(:user) }
+ let_it_be(:admin_user) { create(:user, :admin) }
+ let_it_be(:personal_project) { create(:project, namespace: namespace_user.namespace) }
+ let_it_be(:group_project) { create(:project, group: group) }
+ let_it_be(:another_user) { create(:user) }
+ let_it_be(:group_owner_user) { create(:user).tap { |user| group.add_owner(user) } }
+
+ where(:project, :user, :result) do
+ ref(:personal_project) | ref(:namespace_user) | true
+ ref(:personal_project) | ref(:admin_user) | false
+ ref(:personal_project) | ref(:another_user) | false
+ ref(:personal_project) | nil | false
+ ref(:group_project) | ref(:namespace_user) | false
+ ref(:group_project) | ref(:group_owner_user) | false
+ ref(:group_project) | ref(:another_user) | false
+ ref(:group_project) | nil | false
+ ref(:group_project) | nil | false
+ ref(:group_project) | ref(:admin_user) | false
+ end
+
+ with_them do
+ it { expect(project.personal_namespace_holder?(user)).to eq(result) }
+ end
+ end
+
describe '#default_pipeline_lock' do
let(:project) { build_stubbed(:project) }
@@ -1189,29 +1218,8 @@ RSpec.describe Project, factory_default: :keep do
end
describe 'last_activity_date' do
- it 'returns the creation date of the project\'s last event if present' do
- new_event = create(:event, :closed, project: project, created_at: Time.current)
-
- project.reload
- expect(project.last_activity_at.to_i).to eq(new_event.created_at.to_i)
- end
-
- it 'returns the project\'s last update date if it has no events' do
- expect(project.last_activity_date).to eq(project.updated_at)
- end
-
- it 'returns the most recent timestamp' do
- project.update!(updated_at: nil,
- last_activity_at: timestamp,
- last_repository_updated_at: timestamp - 1.hour)
-
- expect(project.last_activity_date).to be_like_time(timestamp)
-
- project.update!(updated_at: timestamp,
- last_activity_at: timestamp - 1.hour,
- last_repository_updated_at: nil)
-
- expect(project.last_activity_date).to be_like_time(timestamp)
+ it 'returns the project\'s last update date' do
+ expect(project.last_activity_date).to be_like_time(project.updated_at)
end
end
end
@@ -1688,15 +1696,27 @@ RSpec.describe Project, factory_default: :keep do
end
describe '.sort_by_attribute' do
- it 'reorders the input relation by start count desc' do
- project1 = create(:project, star_count: 2)
- project2 = create(:project, star_count: 1)
- project3 = create(:project)
+ let_it_be(:project1) { create(:project, star_count: 2, updated_at: 1.minute.ago) }
+ let_it_be(:project2) { create(:project, star_count: 1) }
+ let_it_be(:project3) { create(:project, updated_at: 2.minutes.ago) }
+ it 'reorders the input relation by start count desc' do
projects = described_class.sort_by_attribute(:stars_desc)
expect(projects).to eq([project1, project2, project3])
end
+
+ it 'reorders the input relation by last activity desc' do
+ projects = described_class.sort_by_attribute(:latest_activity_desc)
+
+ expect(projects).to eq([project2, project1, project3])
+ end
+
+ it 'reorders the input relation by last activity asc' do
+ projects = described_class.sort_by_attribute(:latest_activity_asc)
+
+ expect(projects).to eq([project3, project1, project2])
+ end
end
describe '.with_shared_runners' do
@@ -2273,6 +2293,44 @@ RSpec.describe Project, factory_default: :keep do
end
end
+ describe '#pages_show_onboarding?' do
+ let(:project) { create(:project) }
+
+ subject { project.pages_show_onboarding? }
+
+ context "if there is no metadata" do
+ it { is_expected.to be_truthy }
+ end
+
+ context 'if onboarding is complete' do
+ before do
+ project.pages_metadatum.update_column(:onboarding_complete, true)
+ end
+
+ it { is_expected.to be_falsey }
+ end
+
+ context 'if there is metadata, but onboarding is not complete' do
+ before do
+ project.pages_metadatum.update_column(:onboarding_complete, false)
+ end
+
+ it { is_expected.to be_truthy }
+ end
+
+ # During migration, the onboarding_complete property can still be false,
+ # but will be updated later. To account for that case, pages_show_onboarding?
+ # should return false if `deployed` is true.
+ context "will return false if pages is deployed even if onboarding_complete is false" do
+ before do
+ project.pages_metadatum.update_column(:onboarding_complete, false)
+ project.pages_metadatum.update_column(:deployed, true)
+ end
+
+ it { is_expected.to be_falsey }
+ end
+ end
+
describe '#pages_deployed?' do
let(:project) { create(:project) }
@@ -2695,6 +2753,39 @@ RSpec.describe Project, factory_default: :keep do
end
end
+ describe '#container_repositories_size' do
+ let(:project) { build(:project) }
+
+ subject { project.container_repositories_size }
+
+ context 'on gitlab.com' do
+ where(:no_container_repositories, :all_migrated, :gitlab_api_supported, :returned_size, :expected_result) do
+ true | nil | nil | nil | 0
+ false | false | nil | nil | nil
+ false | true | false | nil | nil
+ false | true | true | 555 | 555
+ false | true | true | nil | nil
+ end
+
+ with_them do
+ before do
+ stub_container_registry_config(enabled: true, api_url: 'http://container-registry', key: 'spec/fixtures/x509_certificate_pk.key')
+ allow(Gitlab).to receive(:com?).and_return(true)
+ allow(project.container_repositories).to receive(:empty?).and_return(no_container_repositories)
+ allow(project.container_repositories).to receive(:all_migrated?).and_return(all_migrated)
+ allow(ContainerRegistry::GitlabApiClient).to receive(:supports_gitlab_api?).and_return(gitlab_api_supported)
+ allow(ContainerRegistry::GitlabApiClient).to receive(:deduplicated_size).with(project.full_path).and_return(returned_size)
+ end
+
+ it { is_expected.to eq(expected_result) }
+ end
+ end
+
+ context 'not on gitlab.com' do
+ it { is_expected.to eq(nil) }
+ end
+ end
+
describe '#container_registry_enabled=' do
let_it_be_with_reload(:project) { create(:project) }
@@ -5602,6 +5693,18 @@ RSpec.describe Project, factory_default: :keep do
expect(project.protected_branches.first.merge_access_levels.map(&:access_level)).to eq([Gitlab::Access::MAINTAINER])
end
end
+
+ describe 'project target platforms detection' do
+ before do
+ create(:import_state, :started, project: project)
+ end
+
+ it 'calls enqueue_record_project_target_platforms' do
+ expect(project).to receive(:enqueue_record_project_target_platforms)
+
+ project.after_import
+ end
+ end
end
describe '#update_project_counter_caches' do
@@ -6256,6 +6359,10 @@ RSpec.describe Project, factory_default: :keep do
expect(subject.find_or_initialize_integration('prometheus')).to be_nil
end
+ it 'returns nil if integration does not exist' do
+ expect(subject.find_or_initialize_integration('non-existing')).to be_nil
+ end
+
context 'with an existing integration' do
subject { create(:project) }
@@ -6557,6 +6664,25 @@ RSpec.describe Project, factory_default: :keep do
end
end
+ describe '#mark_pages_onboarding_complete' do
+ let(:project) { create(:project) }
+
+ it "creates new record and sets onboarding_complete to true if none exists yet" do
+ project.mark_pages_onboarding_complete
+
+ expect(project.pages_metadatum.reload.onboarding_complete).to eq(true)
+ end
+
+ it "overrides an existing setting" do
+ pages_metadatum = project.pages_metadatum
+ pages_metadatum.update!(onboarding_complete: false)
+
+ expect do
+ project.mark_pages_onboarding_complete
+ end.to change { pages_metadatum.reload.onboarding_complete }.from(false).to(true)
+ end
+ end
+
describe '#mark_pages_as_deployed' do
let(:project) { create(:project) }
@@ -8009,12 +8135,112 @@ RSpec.describe Project, factory_default: :keep do
end
end
+ describe '#work_items_feature_flag_enabled?' do
+ shared_examples 'project checking work_items feature flag' do
+ context 'when work_items FF is disabled globally' do
+ before do
+ stub_feature_flags(work_items: false)
+ end
+
+ it { is_expected.to be_falsey }
+ end
+
+ context 'when work_items FF is enabled for the project' do
+ before do
+ stub_feature_flags(work_items: project)
+ end
+
+ it { is_expected.to be_truthy }
+ end
+
+ context 'when work_items FF is enabled globally' do
+ it { is_expected.to be_truthy }
+ end
+ end
+
+ subject { project.work_items_feature_flag_enabled? }
+
+ context 'when a project does not belong to a group' do
+ let_it_be(:project) { create(:project, namespace: namespace) }
+
+ it_behaves_like 'project checking work_items feature flag'
+ end
+
+ context 'when project belongs to a group' do
+ let_it_be(:root_group) { create(:group) }
+ let_it_be(:group) { create(:group, parent: root_group) }
+ let_it_be(:project) { create(:project, group: group) }
+
+ it_behaves_like 'project checking work_items feature flag'
+
+ context 'when work_items FF is enabled for the root group' do
+ before do
+ stub_feature_flags(work_items: root_group)
+ end
+
+ it { is_expected.to be_truthy }
+ end
+
+ context 'when work_items FF is enabled for the group' do
+ before do
+ stub_feature_flags(work_items: group)
+ end
+
+ it { is_expected.to be_truthy }
+ end
+ end
+ end
+
describe 'serialization' do
let(:object) { build(:project) }
it_behaves_like 'blocks unsafe serialization'
end
+ describe '#enqueue_record_project_target_platforms' do
+ let_it_be(:project) { create(:project) }
+
+ let(:com) { true }
+
+ before do
+ allow(Gitlab).to receive(:com?).and_return(com)
+ end
+
+ it 'enqueues a Projects::RecordTargetPlatformsWorker' do
+ expect(Projects::RecordTargetPlatformsWorker).to receive(:perform_async).with(project.id)
+
+ project.enqueue_record_project_target_platforms
+ end
+
+ shared_examples 'does not enqueue a Projects::RecordTargetPlatformsWorker' do
+ it 'does not enqueue a Projects::RecordTargetPlatformsWorker' do
+ expect(Projects::RecordTargetPlatformsWorker).not_to receive(:perform_async)
+
+ project.enqueue_record_project_target_platforms
+ end
+ end
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(record_projects_target_platforms: false)
+ end
+
+ it_behaves_like 'does not enqueue a Projects::RecordTargetPlatformsWorker'
+ end
+
+ context 'when not in gitlab.com' do
+ let(:com) { false }
+
+ it_behaves_like 'does not enqueue a Projects::RecordTargetPlatformsWorker'
+ end
+ end
+
+ describe '#inactive?' do
+ let_it_be_with_reload(:project) { create(:project, name: 'test-project') }
+
+ it_behaves_like 'returns true if project is inactive'
+ end
+
private
def finish_job(export_job)
diff --git a/spec/models/project_statistics_spec.rb b/spec/models/project_statistics_spec.rb
index 5fbf1a9c502..20fc14113ef 100644
--- a/spec/models/project_statistics_spec.rb
+++ b/spec/models/project_statistics_spec.rb
@@ -35,7 +35,8 @@ RSpec.describe ProjectStatistics do
build_artifacts_size: 1.exabyte,
snippets_size: 1.exabyte,
pipeline_artifacts_size: 512.petabytes - 1,
- uploads_size: 512.petabytes
+ uploads_size: 512.petabytes,
+ container_registry_size: 8.exabytes - 1
)
statistics.reload
@@ -49,6 +50,7 @@ RSpec.describe ProjectStatistics do
expect(statistics.snippets_size).to eq(1.exabyte)
expect(statistics.pipeline_artifacts_size).to eq(512.petabytes - 1)
expect(statistics.uploads_size).to eq(512.petabytes)
+ expect(statistics.container_registry_size).to eq(8.exabytes - 1)
end
end
diff --git a/spec/models/projects/build_artifacts_size_refresh_spec.rb b/spec/models/projects/build_artifacts_size_refresh_spec.rb
index 22c27c986f8..a55e4b31d21 100644
--- a/spec/models/projects/build_artifacts_size_refresh_spec.rb
+++ b/spec/models/projects/build_artifacts_size_refresh_spec.rb
@@ -14,13 +14,13 @@ RSpec.describe Projects::BuildArtifactsSizeRefresh, type: :model do
end
describe 'scopes' do
- let_it_be(:refresh_1) { create(:project_build_artifacts_size_refresh, :running, updated_at: 4.days.ago) }
- let_it_be(:refresh_2) { create(:project_build_artifacts_size_refresh, :running, updated_at: 2.days.ago) }
+ let_it_be(:refresh_1) { create(:project_build_artifacts_size_refresh, :running, updated_at: (described_class::STALE_WINDOW + 1.second).ago) }
+ let_it_be(:refresh_2) { create(:project_build_artifacts_size_refresh, :running, updated_at: 1.hour.ago) }
let_it_be(:refresh_3) { create(:project_build_artifacts_size_refresh, :pending) }
let_it_be(:refresh_4) { create(:project_build_artifacts_size_refresh, :created) }
describe 'stale' do
- it 'returns records in running state and has not been updated for more than 3 days' do
+ it 'returns records in running state and has not been updated for more than 2 hours' do
expect(described_class.stale).to eq([refresh_1])
end
end
diff --git a/spec/models/projects/topic_spec.rb b/spec/models/projects/topic_spec.rb
index aa3230da1e6..8fc4d11f0d9 100644
--- a/spec/models/projects/topic_spec.rb
+++ b/spec/models/projects/topic_spec.rb
@@ -56,6 +56,14 @@ RSpec.describe Projects::Topic do
end
end
+ describe '#find_by_name_case_insensitive' do
+ it 'returns topic with case insensitive name' do
+ %w(topic TOPIC Topic).each do |name|
+ expect(described_class.find_by_name_case_insensitive(name)).to eq(topic)
+ end
+ end
+ end
+
describe '#search' do
it 'returns topics with a matching name' do
expect(described_class.search(topic.name)).to eq([topic])
diff --git a/spec/models/user_preference_spec.rb b/spec/models/user_preference_spec.rb
index d4491aacd9f..2492521c634 100644
--- a/spec/models/user_preference_spec.rb
+++ b/spec/models/user_preference_spec.rb
@@ -5,6 +5,48 @@ require 'spec_helper'
RSpec.describe UserPreference do
let(:user_preference) { create(:user_preference) }
+ describe 'validations' do
+ describe 'diffs_deletion_color and diffs_addition_color' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(color: [
+ '#000000',
+ '#123456',
+ '#abcdef',
+ '#AbCdEf',
+ '#ffffff',
+ '#fFfFfF',
+ '#000',
+ '#123',
+ '#abc',
+ '#AbC',
+ '#fff',
+ '#fFf',
+ ''
+ ])
+
+ with_them do
+ it { is_expected.to allow_value(color).for(:diffs_deletion_color) }
+ it { is_expected.to allow_value(color).for(:diffs_addition_color) }
+ end
+
+ where(color: [
+ '#1',
+ '#12',
+ '#1234',
+ '#12345',
+ '#1234567',
+ '123456',
+ '#12345x'
+ ])
+
+ with_them do
+ it { is_expected.not_to allow_value(color).for(:diffs_deletion_color) }
+ it { is_expected.not_to allow_value(color).for(:diffs_addition_color) }
+ end
+ end
+ end
+
describe 'notes filters global keys' do
it 'contains expected values' do
expect(UserPreference::NOTES_FILTERS.keys).to match_array([:all_notes, :only_comments, :only_activity])
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 6ee38048025..bc425b15c6e 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -69,6 +69,12 @@ RSpec.describe User do
it { is_expected.to delegate_method(:markdown_surround_selection).to(:user_preference) }
it { is_expected.to delegate_method(:markdown_surround_selection=).to(:user_preference).with_arguments(:args) }
+ it { is_expected.to delegate_method(:diffs_deletion_color).to(:user_preference) }
+ it { is_expected.to delegate_method(:diffs_deletion_color=).to(:user_preference).with_arguments(:args) }
+
+ it { is_expected.to delegate_method(:diffs_addition_color).to(:user_preference) }
+ it { is_expected.to delegate_method(:diffs_addition_color=).to(:user_preference).with_arguments(:args) }
+
it { is_expected.to delegate_method(:job_title).to(:user_detail).allow_nil }
it { is_expected.to delegate_method(:job_title=).to(:user_detail).with_arguments(:args).allow_nil }
@@ -1554,7 +1560,7 @@ RSpec.describe User do
end
it 'adds the confirmed primary email to emails' do
- expect(user.emails.confirmed.map(&:email)).not_to include(user.email)
+ expect(user.emails.confirmed.map(&:email)).not_to include(user.unconfirmed_email)
user.confirm
@@ -1613,14 +1619,23 @@ RSpec.describe User do
context 'when the email is changed but not confirmed' do
let(:user) { create(:user, email: 'primary@example.com') }
- it 'does not add the new email to emails yet' do
+ before do
user.update!(email: 'new_primary@example.com')
+ end
+ it 'does not add the new email to emails yet' do
expect(user.unconfirmed_email).to eq('new_primary@example.com')
expect(user.email).to eq('primary@example.com')
expect(user).to be_confirmed
expect(user.emails.pluck(:email)).not_to include('new_primary@example.com')
end
+
+ it 'adds the new email to emails upon confirmation' do
+ user.confirm
+ expect(user.email).to eq('new_primary@example.com')
+ expect(user).to be_confirmed
+ expect(user.emails.pluck(:email)).to include('new_primary@example.com')
+ end
end
context 'when the user is created as not confirmed' do
@@ -1630,6 +1645,11 @@ RSpec.describe User do
expect(user).not_to be_confirmed
expect(user.emails.pluck(:email)).not_to include('primary@example.com')
end
+
+ it 'adds the email to emails upon confirmation' do
+ user.confirm
+ expect(user.emails.pluck(:email)).to include('primary@example.com')
+ end
end
context 'when the user is created as confirmed' do
@@ -2083,6 +2103,74 @@ RSpec.describe User do
end
end
+ describe 'needs_new_otp_secret?', :freeze_time do
+ let(:user) { create(:user) }
+
+ context 'when two-factor is not enabled' do
+ it 'returns true if otp_secret_expires_at is nil' do
+ expect(user.needs_new_otp_secret?).to eq(true)
+ end
+
+ it 'returns true if the otp_secret_expires_at has passed' do
+ user.update!(otp_secret_expires_at: 10.minutes.ago)
+
+ expect(user.reload.needs_new_otp_secret?).to eq(true)
+ end
+
+ it 'returns false if the otp_secret_expires_at has not passed' do
+ user.update!(otp_secret_expires_at: 10.minutes.from_now)
+
+ expect(user.reload.needs_new_otp_secret?).to eq(false)
+ end
+ end
+
+ context 'when two-factor is enabled' do
+ let(:user) { create(:user, :two_factor) }
+
+ it 'returns false even if ttl is expired' do
+ user.otp_secret_expires_at = 10.minutes.ago
+
+ expect(user.needs_new_otp_secret?).to eq(false)
+ end
+ end
+ end
+
+ describe 'otp_secret_expired?', :freeze_time do
+ let(:user) { create(:user) }
+
+ it 'returns true if otp_secret_expires_at is nil' do
+ expect(user.otp_secret_expired?).to eq(true)
+ end
+
+ it 'returns true if the otp_secret_expires_at has passed' do
+ user.otp_secret_expires_at = 10.minutes.ago
+
+ expect(user.otp_secret_expired?).to eq(true)
+ end
+
+ it 'returns false if the otp_secret_expires_at has not passed' do
+ user.otp_secret_expires_at = 20.minutes.from_now
+
+ expect(user.otp_secret_expired?).to eq(false)
+ end
+ end
+
+ describe 'update_otp_secret!', :freeze_time do
+ let(:user) { create(:user) }
+
+ before do
+ user.update_otp_secret!
+ end
+
+ it 'sets the otp_secret' do
+ expect(user.otp_secret).to have_attributes(length: described_class::OTP_SECRET_LENGTH)
+ end
+
+ it 'updates the otp_secret_expires_at' do
+ expect(user.otp_secret_expires_at).to eq(Time.current + described_class::OTP_SECRET_TTL)
+ end
+ end
+
describe 'projects' do
before do
@user = create(:user)
@@ -2653,6 +2741,19 @@ RSpec.describe User do
let_it_be(:user3) { create(:user, name: 'us', username: 'se', email: 'foo@example.com') }
let_it_be(:email) { create(:email, user: user, email: 'alias@example.com') }
+ describe 'name user and email relative ordering' do
+ let_it_be(:named_alexander) { create(:user, name: 'Alexander Person', username: 'abcd', email: 'abcd@example.com') }
+ let_it_be(:username_alexand) { create(:user, name: 'Joao Alexander', username: 'Alexand', email: 'joao@example.com') }
+
+ it 'prioritizes exact matches' do
+ expect(described_class.search('Alexand')).to eq([username_alexand, named_alexander])
+ end
+
+ it 'falls back to ordering by name' do
+ expect(described_class.search('Alexander')).to eq([named_alexander, username_alexand])
+ end
+ end
+
describe 'name matching' do
it 'returns users with a matching name with exact match first' do
expect(described_class.search(user.name)).to eq([user, user2])
@@ -4251,16 +4352,34 @@ RSpec.describe User do
end
end
- it_behaves_like '#ci_owned_runners'
+ describe '#ci_owned_runners' do
+ it_behaves_like '#ci_owned_runners'
- context 'when FF ci_owned_runners_cross_joins_fix is disabled' do
- before do
- skip_if_multiple_databases_are_setup
+ context 'when FF use_traversal_ids is disabled fallbacks to inefficient implementation' do
+ before do
+ stub_feature_flags(use_traversal_ids: false)
+ end
- stub_feature_flags(ci_owned_runners_cross_joins_fix: false)
+ it_behaves_like '#ci_owned_runners'
end
- it_behaves_like '#ci_owned_runners'
+ context 'when FF ci_owned_runners_cross_joins_fix is disabled' do
+ before do
+ skip_if_multiple_databases_are_setup
+
+ stub_feature_flags(ci_owned_runners_cross_joins_fix: false)
+ end
+
+ it_behaves_like '#ci_owned_runners'
+ end
+
+ context 'when FF ci_owned_runners_unnest_index is disabled uses GIN index' do
+ before do
+ stub_feature_flags(ci_owned_runners_unnest_index: false)
+ end
+
+ it_behaves_like '#ci_owned_runners'
+ end
end
describe '#projects_with_reporter_access_limited_to' do
@@ -4882,17 +5001,36 @@ RSpec.describe User do
end
describe '#attention_requested_open_merge_requests_count' do
- it 'returns number of open merge requests from non-archived projects' do
- user = create(:user)
- project = create(:project, :public)
- archived_project = create(:project, :public, :archived)
+ let(:user) { create(:user) }
+ let(:project) { create(:project, :public) }
+ let(:archived_project) { create(:project, :public, :archived) }
+ before do
create(:merge_request, source_project: project, author: user, reviewers: [user])
create(:merge_request, :closed, source_project: project, author: user, reviewers: [user])
create(:merge_request, source_project: archived_project, author: user, reviewers: [user])
+ end
+ it 'returns number of open merge requests from non-archived projects' do
+ expect(Rails.cache).not_to receive(:fetch)
expect(user.attention_requested_open_merge_requests_count(force: true)).to eq 1
end
+
+ context 'when uncached_mr_attention_requests_count is disabled' do
+ before do
+ stub_feature_flags(uncached_mr_attention_requests_count: false)
+ end
+
+ it 'fetches from cache' do
+ expect(Rails.cache).to receive(:fetch).with(
+ user.attention_request_cache_key,
+ force: false,
+ expires_in: described_class::COUNT_CACHE_VALIDITY_PERIOD
+ ).and_call_original
+
+ expect(user.attention_requested_open_merge_requests_count).to eq 1
+ end
+ end
end
describe '#assigned_open_issues_count' do
@@ -6632,6 +6770,23 @@ RSpec.describe User do
end
end
+ describe '.without_forbidden_states' do
+ let_it_be(:normal_user) { create(:user, username: 'johndoe') }
+ let_it_be(:admin_user) { create(:user, :admin, username: 'iamadmin') }
+ let_it_be(:blocked_user) { create(:user, :blocked, username: 'notsorandom') }
+ let_it_be(:banned_user) { create(:user, :banned, username: 'iambanned') }
+ let_it_be(:external_user) { create(:user, :external) }
+ let_it_be(:unconfirmed_user) { create(:user, confirmed_at: nil) }
+ let_it_be(:omniauth_user) { create(:omniauth_user, provider: 'twitter', extern_uid: '123456') }
+ let_it_be(:internal_user) { User.alert_bot.tap { |u| u.confirm } }
+
+ it 'does not return blocked or banned users' do
+ expect(described_class.without_forbidden_states).to match_array([
+ normal_user, admin_user, external_user, unconfirmed_user, omniauth_user, internal_user
+ ])
+ end
+ end
+
describe 'user_project' do
it 'returns users project matched by username and public visibility' do
user = create(:user)
diff --git a/spec/models/users/in_product_marketing_email_spec.rb b/spec/models/users/in_product_marketing_email_spec.rb
index cf08cf7ceed..ca03c3e645d 100644
--- a/spec/models/users/in_product_marketing_email_spec.rb
+++ b/spec/models/users/in_product_marketing_email_spec.rb
@@ -19,13 +19,6 @@ RSpec.describe Users::InProductMarketingEmail, type: :model do
it { is_expected.to validate_uniqueness_of(:user_id).scoped_to([:track, :series]).with_message('has already been sent') }
end
- describe '.tracks' do
- it 'has an entry for every track' do
- tracks = [Namespaces::InviteTeamEmailService::TRACK, Namespaces::InProductMarketingEmailsService::TRACKS.keys].flatten
- expect(tracks).to match_array(described_class.tracks.keys.map(&:to_sym))
- end
- end
-
describe '.without_track_and_series' do
let_it_be(:user) { create(:user) }
@@ -135,4 +128,15 @@ RSpec.describe Users::InProductMarketingEmail, type: :model do
end
end
end
+
+ describe '.ACTIVE_TRACKS' do
+ it 'has an entry for every track' do
+ tracks = Namespaces::InProductMarketingEmailsService::TRACKS.keys
+ expect(tracks).to match_array(described_class::ACTIVE_TRACKS.keys.map(&:to_sym))
+ end
+
+ it 'does not include INACTIVE_TRACK_NAMES' do
+ expect(described_class::ACTIVE_TRACKS.keys).not_to include(*described_class::INACTIVE_TRACK_NAMES)
+ end
+ end
end
diff --git a/spec/models/web_ide_terminal_spec.rb b/spec/models/web_ide_terminal_spec.rb
index 149fce33f43..fc30bc18f68 100644
--- a/spec/models/web_ide_terminal_spec.rb
+++ b/spec/models/web_ide_terminal_spec.rb
@@ -41,7 +41,7 @@ RSpec.describe WebIdeTerminal do
context 'when image does not have an alias' do
let(:config) do
- { image: 'ruby:2.7' }.merge(services_with_aliases)
+ { image: 'image:1.0' }.merge(services_with_aliases)
end
it 'returns services aliases' do
@@ -51,7 +51,7 @@ RSpec.describe WebIdeTerminal do
context 'when both image and services have aliases' do
let(:config) do
- { image: { name: 'ruby:2.7', alias: 'ruby' } }.merge(services_with_aliases)
+ { image: { name: 'image:1.0', alias: 'ruby' } }.merge(services_with_aliases)
end
it 'returns all aliases' do
@@ -61,7 +61,7 @@ RSpec.describe WebIdeTerminal do
context 'when image and services does not have any alias' do
let(:config) do
- { image: 'ruby:2.7', services: ['postgres'] }
+ { image: 'image:1.0', services: ['postgres'] }
end
it 'returns an empty array' do
diff --git a/spec/models/wiki_page_spec.rb b/spec/models/wiki_page_spec.rb
index 0016d2f517b..51970064c54 100644
--- a/spec/models/wiki_page_spec.rb
+++ b/spec/models/wiki_page_spec.rb
@@ -473,6 +473,21 @@ RSpec.describe WikiPage do
end
end
+ describe 'in subdir' do
+ it 'keeps the page in the same dir when the content is updated' do
+ title = 'foo/Existing Page'
+ page = create_wiki_page(title: title)
+
+ expect(page.slug).to eq 'foo/Existing-Page'
+ expect(page.update(title: title, content: 'new_content')).to be_truthy
+
+ page = wiki.find_page(title)
+
+ expect(page.slug).to eq 'foo/Existing-Page'
+ expect(page.content).to eq 'new_content'
+ end
+ end
+
context 'when renaming a page' do
it 'raises an error if the page already exists' do
existing_page = create_wiki_page
diff --git a/spec/policies/alert_management/alert_policy_spec.rb b/spec/policies/alert_management/alert_policy_spec.rb
index 3e08d8b4ccc..2027c205c7b 100644
--- a/spec/policies/alert_management/alert_policy_spec.rb
+++ b/spec/policies/alert_management/alert_policy_spec.rb
@@ -3,9 +3,10 @@
require 'spec_helper'
RSpec.describe AlertManagement::AlertPolicy, :models do
- let(:alert) { create(:alert_management_alert) }
- let(:project) { alert.project }
- let(:user) { create(:user) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:alert) { create(:alert_management_alert, project: project, issue: incident) }
+ let_it_be(:incident) { nil }
subject(:policy) { described_class.new(user, alert) }
@@ -21,5 +22,50 @@ RSpec.describe AlertManagement::AlertPolicy, :models do
it { is_expected.to be_allowed :read_alert_management_alert }
it { is_expected.to be_allowed :update_alert_management_alert }
end
+
+ shared_examples 'does not allow metric image reads' do
+ it { expect(policy).to be_disallowed(:read_alert_management_metric_image) }
+ end
+
+ shared_examples 'does not allow metric image updates' do
+ specify do
+ expect(policy).to be_disallowed(:upload_alert_management_metric_image)
+ expect(policy).to be_disallowed(:destroy_alert_management_metric_image)
+ end
+ end
+
+ shared_examples 'allows metric image reads' do
+ it { expect(policy).to be_allowed(:read_alert_management_metric_image) }
+ end
+
+ shared_examples 'allows metric image updates' do
+ specify do
+ expect(policy).to be_allowed(:upload_alert_management_metric_image)
+ expect(policy).to be_allowed(:destroy_alert_management_metric_image)
+ end
+ end
+
+ context 'when user is not a member' do
+ include_examples 'does not allow metric image reads'
+ include_examples 'does not allow metric image updates'
+ end
+
+ context 'when user is a guest' do
+ before do
+ project.add_guest(user)
+ end
+
+ include_examples 'does not allow metric image reads'
+ include_examples 'does not allow metric image updates'
+ end
+
+ context 'when user is a developer' do
+ before do
+ project.add_developer(user)
+ end
+
+ include_examples 'allows metric image reads'
+ include_examples 'allows metric image updates'
+ end
end
end
diff --git a/spec/policies/note_policy_spec.rb b/spec/policies/note_policy_spec.rb
index f6cd84f29ae..eeaa77a4589 100644
--- a/spec/policies/note_policy_spec.rb
+++ b/spec/policies/note_policy_spec.rb
@@ -359,39 +359,6 @@ RSpec.describe NotePolicy do
expect(permissions(assignee, confidential_note)).to be_disallowed(:admin_note, :reposition_note, :resolve_note)
end
end
-
- context 'for merge requests' do
- let(:merge_request) { create(:merge_request, source_project: project, author: author, assignees: [assignee]) }
- let(:confidential_note) { create(:note, :confidential, project: project, noteable: merge_request) }
-
- it_behaves_like 'confidential notes permissions'
-
- it 'allows noteable assignees to read all notes' do
- expect(permissions(assignee, confidential_note)).to be_allowed(:read_note, :award_emoji)
- expect(permissions(assignee, confidential_note)).to be_disallowed(:admin_note, :reposition_note, :resolve_note)
- end
- end
-
- context 'for project snippets' do
- let(:project_snippet) { create(:project_snippet, project: project, author: author) }
- let(:confidential_note) { create(:note, :confidential, project: project, noteable: project_snippet) }
-
- it_behaves_like 'confidential notes permissions'
- end
-
- context 'for personal snippets' do
- let(:personal_snippet) { create(:personal_snippet, author: author) }
- let(:confidential_note) { create(:note, :confidential, project: nil, noteable: personal_snippet) }
-
- it 'allows snippet author to read and resolve all notes' do
- expect(permissions(author, confidential_note)).to be_allowed(:read_note, :resolve_note, :award_emoji)
- expect(permissions(author, confidential_note)).to be_disallowed(:admin_note, :reposition_note)
- end
-
- it 'does not allow maintainers to read confidential notes and replies' do
- expect(permissions(maintainer, confidential_note)).to be_disallowed(:read_note, :admin_note, :reposition_note, :resolve_note, :award_emoji)
- end
- end
end
end
end
diff --git a/spec/policies/project_member_policy_spec.rb b/spec/policies/project_member_policy_spec.rb
index 12b3e60fdb2..b19ab71fcb5 100644
--- a/spec/policies/project_member_policy_spec.rb
+++ b/spec/policies/project_member_policy_spec.rb
@@ -23,9 +23,9 @@ RSpec.describe ProjectMemberPolicy do
it { is_expected.not_to be_allowed(:destroy_project_bot_member) }
end
- context 'when user is project owner' do
- let(:member_user) { project.first_owner }
- let(:member) { project.members.find_by!(user: member_user) }
+ context 'when user is the holder of personal namespace in which the project resides' do
+ let(:namespace_holder) { project.namespace.owner }
+ let(:member) { project.members.find_by!(user: namespace_holder) }
it { is_expected.to be_allowed(:read_project) }
it { is_expected.to be_disallowed(:update_project_member) }
diff --git a/spec/policies/project_policy_spec.rb b/spec/policies/project_policy_spec.rb
index fb1c5874335..bde83d647db 100644
--- a/spec/policies/project_policy_spec.rb
+++ b/spec/policies/project_policy_spec.rb
@@ -346,6 +346,36 @@ RSpec.describe ProjectPolicy do
end
end
+ context 'reading usage quotas' do
+ %w(maintainer owner).each do |role|
+ context "with #{role}" do
+ let(:current_user) { send(role) }
+
+ it { is_expected.to be_allowed(:read_usage_quotas) }
+ end
+ end
+
+ %w(guest reporter developer anonymous).each do |role|
+ context "with #{role}" do
+ let(:current_user) { send(role) }
+
+ it { is_expected.to be_disallowed(:read_usage_quotas) }
+ end
+ end
+
+ context 'with an admin' do
+ let(:current_user) { admin }
+
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it { expect_allowed(:read_usage_quotas) }
+ end
+
+ context 'when admin mode is disabled' do
+ it { expect_disallowed(:read_usage_quotas) }
+ end
+ end
+ end
+
it_behaves_like 'clusterable policies' do
let_it_be(:clusterable) { create(:project, :repository) }
let_it_be(:cluster) do
diff --git a/spec/presenters/ci/bridge_presenter_spec.rb b/spec/presenters/ci/bridge_presenter_spec.rb
index 6291c3426e2..bd6c4777d0c 100644
--- a/spec/presenters/ci/bridge_presenter_spec.rb
+++ b/spec/presenters/ci/bridge_presenter_spec.rb
@@ -3,9 +3,10 @@
require 'spec_helper'
RSpec.describe Ci::BridgePresenter do
+ let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:pipeline) { create(:ci_pipeline, project: project) }
- let_it_be(:bridge) { create(:ci_bridge, pipeline: pipeline, status: :failed) }
+ let_it_be(:bridge) { create(:ci_bridge, pipeline: pipeline, status: :failed, user: user) }
subject(:presenter) do
described_class.new(bridge)
@@ -14,4 +15,10 @@ RSpec.describe Ci::BridgePresenter do
it 'presents information about recoverable state' do
expect(presenter).to be_recoverable
end
+
+ it 'presents the detailed status for the user' do
+ expect(bridge).to receive(:detailed_status).with(user)
+
+ presenter.detailed_status
+ end
end
diff --git a/spec/presenters/ci/build_runner_presenter_spec.rb b/spec/presenters/ci/build_runner_presenter_spec.rb
index d25102532a7..ace65307321 100644
--- a/spec/presenters/ci/build_runner_presenter_spec.rb
+++ b/spec/presenters/ci/build_runner_presenter_spec.rb
@@ -78,16 +78,72 @@ RSpec.describe Ci::BuildRunnerPresenter do
artifact_format: Ci::JobArtifact::TYPE_AND_FORMAT_PAIRS.fetch(file_type),
paths: [filename],
when: 'always'
- }
+ }.compact
end
it 'presents correct hash' do
- expect(presenter.artifacts.first).to include(report_expectation)
+ expect(presenter.artifacts).to contain_exactly(report_expectation)
end
end
end
end
+ context 'when a specific coverage_report type is given' do
+ let(:coverage_format) { :cobertura }
+ let(:filename) { 'cobertura-coverage.xml' }
+ let(:coverage_report) { { path: filename, coverage_format: coverage_format } }
+ let(:report) { { coverage_report: coverage_report } }
+ let(:build) { create(:ci_build, options: { artifacts: { reports: report } }) }
+
+ let(:expected_coverage_report) do
+ {
+ name: filename,
+ artifact_type: coverage_format,
+ artifact_format: Ci::JobArtifact::TYPE_AND_FORMAT_PAIRS.fetch(coverage_format),
+ paths: [filename],
+ when: 'always'
+ }
+ end
+
+ it 'presents the coverage report hash with the coverage format' do
+ expect(presenter.artifacts).to contain_exactly(expected_coverage_report)
+ end
+ end
+
+ context 'when a specific coverage_report type is given with another report type' do
+ let(:coverage_format) { :cobertura }
+ let(:coverage_filename) { 'cobertura-coverage.xml' }
+ let(:coverage_report) { { path: coverage_filename, coverage_format: coverage_format } }
+ let(:ds_filename) { 'gl-dependency-scanning-report.json' }
+
+ let(:report) { { coverage_report: coverage_report, dependency_scanning: [ds_filename] } }
+ let(:build) { create(:ci_build, options: { artifacts: { reports: report } }) }
+
+ let(:expected_coverage_report) do
+ {
+ name: coverage_filename,
+ artifact_type: coverage_format,
+ artifact_format: Ci::JobArtifact::TYPE_AND_FORMAT_PAIRS.fetch(coverage_format),
+ paths: [coverage_filename],
+ when: 'always'
+ }
+ end
+
+ let(:expected_ds_report) do
+ {
+ name: ds_filename,
+ artifact_type: :dependency_scanning,
+ artifact_format: Ci::JobArtifact::TYPE_AND_FORMAT_PAIRS.fetch(:dependency_scanning),
+ paths: [ds_filename],
+ when: 'always'
+ }
+ end
+
+ it 'presents both reports' do
+ expect(presenter.artifacts).to contain_exactly(expected_coverage_report, expected_ds_report)
+ end
+ end
+
context "when option has both archive and reports specification" do
let(:report) { { junit: ['junit.xml'] } }
let(:build) { create(:ci_build, options: { script: 'echo', artifacts: { **archive, reports: report } }) }
diff --git a/spec/presenters/gitlab/blame_presenter_spec.rb b/spec/presenters/gitlab/blame_presenter_spec.rb
index b163926154b..ff128416692 100644
--- a/spec/presenters/gitlab/blame_presenter_spec.rb
+++ b/spec/presenters/gitlab/blame_presenter_spec.rb
@@ -27,6 +27,14 @@ RSpec.describe Gitlab::BlamePresenter do
end
end
+ describe '#first_line' do
+ it 'delegates #first_line call to the blame' do
+ expect(blame).to receive(:first_line).at_least(:once).and_call_original
+
+ subject.first_line
+ end
+ end
+
describe '#commit_data' do
it 'has the data necessary to render the view' do
commit = blame.groups.first[:commit]
@@ -37,9 +45,28 @@ RSpec.describe Gitlab::BlamePresenter do
expect(data.age_map_class).to include('blame-commit-age-')
expect(data.commit_link.to_s).to include '913c66a37b4a45b9769037c55c2d238bd0942d2e">Files, encoding and much more</a>'
expect(data.commit_author_link.to_s).to include('<a class="commit-author-link" href=')
- expect(data.project_blame_link.to_s).to include('<a title="View blame prior to this change"')
expect(data.time_ago_tooltip.to_s).to include('data-container="body">Feb 27, 2014</time>')
end
end
+
+ context 'renamed file' do
+ let(:path) { 'files/plain_text/renamed' }
+ let(:commit) { project.commit('blame-on-renamed') }
+
+ it 'does not generate link to previous blame on initial commit' do
+ commit = blame.groups[0][:commit]
+ data = subject.commit_data(commit)
+
+ expect(data.project_blame_link.to_s).to eq('')
+ end
+
+ it 'generates link link to previous blame' do
+ commit = blame.groups[1][:commit]
+ data = subject.commit_data(commit)
+
+ expect(data.project_blame_link.to_s).to include('<a title="View blame prior to this change"')
+ expect(data.project_blame_link.to_s).to include('/blame/405a45736a75e439bb059e638afaa9a3c2eeda79/files/plain_text/initial-commit')
+ end
+ end
end
end
diff --git a/spec/presenters/issue_presenter_spec.rb b/spec/presenters/issue_presenter_spec.rb
index 55a6b50ffa7..e17ae218cd3 100644
--- a/spec/presenters/issue_presenter_spec.rb
+++ b/spec/presenters/issue_presenter_spec.rb
@@ -5,19 +5,42 @@ require 'spec_helper'
RSpec.describe IssuePresenter do
include Gitlab::Routing.url_helpers
- let(:user) { create(:user) }
- let(:group) { create(:group) }
- let(:project) { create(:project, group: group) }
- let(:issue) { create(:issue, project: project) }
- let(:presenter) { described_class.new(issue, current_user: user) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, group: group) }
+ let_it_be(:issue) { create(:issue, project: project) }
+ let_it_be(:task) { create(:issue, :task, project: project) }
- before do
+ let(:presented_issue) { issue }
+ let(:presenter) { described_class.new(presented_issue, current_user: user) }
+
+ before_all do
group.add_developer(user)
end
describe '#web_url' do
it 'returns correct path' do
- expect(presenter.web_url).to eq("http://localhost/#{group.name}/#{project.name}/-/issues/#{issue.iid}")
+ expect(presenter.web_url).to eq("http://localhost/#{group.name}/#{project.name}/-/issues/#{presented_issue.iid}")
+ end
+
+ context 'when issue type is task' do
+ let(:presented_issue) { task }
+
+ context 'when work_items feature flag is enabled' do
+ it 'returns a work item url for the task' do
+ expect(presenter.web_url).to eq(project_work_items_url(project, work_items_path: presented_issue.id))
+ end
+ end
+
+ context 'when work_items feature flag is disabled' do
+ before do
+ stub_feature_flags(work_items: false)
+ end
+
+ it 'returns an issue url for the task' do
+ expect(presenter.web_url).to eq("http://localhost/#{group.name}/#{project.name}/-/issues/#{presented_issue.iid}")
+ end
+ end
end
end
@@ -29,7 +52,7 @@ RSpec.describe IssuePresenter do
end
it 'returns subscribed' do
- create(:subscription, user: user, project: project, subscribable: issue, subscribed: true)
+ create(:subscription, user: user, project: project, subscribable: presented_issue, subscribed: true)
is_expected.to be(true)
end
@@ -37,7 +60,27 @@ RSpec.describe IssuePresenter do
describe '#issue_path' do
it 'returns correct path' do
- expect(presenter.issue_path).to eq("/#{group.name}/#{project.name}/-/issues/#{issue.iid}")
+ expect(presenter.issue_path).to eq("/#{group.name}/#{project.name}/-/issues/#{presented_issue.iid}")
+ end
+
+ context 'when issue type is task' do
+ let(:presented_issue) { task }
+
+ context 'when work_items feature flag is enabled' do
+ it 'returns a work item path for the task' do
+ expect(presenter.issue_path).to eq(project_work_items_path(project, work_items_path: presented_issue.id))
+ end
+ end
+
+ context 'when work_items feature flag is disabled' do
+ before do
+ stub_feature_flags(work_items: false)
+ end
+
+ it 'returns an issue path for the task' do
+ expect(presenter.issue_path).to eq("/#{group.name}/#{project.name}/-/issues/#{presented_issue.iid}")
+ end
+ end
end
end
diff --git a/spec/presenters/project_clusterable_presenter_spec.rb b/spec/presenters/project_clusterable_presenter_spec.rb
index 900630bb6e2..bd4319c9411 100644
--- a/spec/presenters/project_clusterable_presenter_spec.rb
+++ b/spec/presenters/project_clusterable_presenter_spec.rb
@@ -49,6 +49,12 @@ RSpec.describe ProjectClusterablePresenter do
it { is_expected.to eq(connect_project_clusters_path(project)) }
end
+ describe '#new_cluster_docs_path' do
+ subject { presenter.new_cluster_docs_path }
+
+ it { is_expected.to eq(new_cluster_docs_project_clusters_path(project)) }
+ end
+
describe '#authorize_aws_role_path' do
subject { presenter.authorize_aws_role_path }
diff --git a/spec/presenters/projects/security/configuration_presenter_spec.rb b/spec/presenters/projects/security/configuration_presenter_spec.rb
index 47ef0cf1192..779d6b88fd5 100644
--- a/spec/presenters/projects/security/configuration_presenter_spec.rb
+++ b/spec/presenters/projects/security/configuration_presenter_spec.rb
@@ -87,6 +87,7 @@ RSpec.describe Projects::Security::ConfigurationPresenter do
expect(feature['configuration_path']).to be_nil
expect(feature['available']).to eq(true)
expect(feature['can_enable_by_merge_request']).to eq(true)
+ expect(feature['meta_info_path']).to be_nil
end
context 'when checking features configured status' do
diff --git a/spec/requests/admin/background_migrations_controller_spec.rb b/spec/requests/admin/background_migrations_controller_spec.rb
index 55971a00e55..9933008502f 100644
--- a/spec/requests/admin/background_migrations_controller_spec.rb
+++ b/spec/requests/admin/background_migrations_controller_spec.rb
@@ -10,7 +10,7 @@ RSpec.describe Admin::BackgroundMigrationsController, :enable_admin_mode do
end
describe 'POST #retry' do
- let(:migration) { create(:batched_background_migration, status: 'failed') }
+ let(:migration) { create(:batched_background_migration, :failed) }
before do
create(:batched_background_migration_job, :failed, batched_migration: migration, batch_size: 10, min_value: 6, max_value: 15, attempts: 3)
@@ -37,11 +37,11 @@ RSpec.describe Admin::BackgroundMigrationsController, :enable_admin_mode do
it 'retries the migration' do
retry_migration
- expect(migration.reload.status).to eql 'active'
+ expect(migration.reload.status_name).to be :active
end
context 'when the migration is not failed' do
- let(:migration) { create(:batched_background_migration, status: 'paused') }
+ let(:migration) { create(:batched_background_migration, :paused) }
it 'keeps the same migration status' do
expect { retry_migration }.not_to change { migration.reload.status }
diff --git a/spec/requests/api/alert_management_alerts_spec.rb b/spec/requests/api/alert_management_alerts_spec.rb
new file mode 100644
index 00000000000..99293e5ae95
--- /dev/null
+++ b/spec/requests/api/alert_management_alerts_spec.rb
@@ -0,0 +1,411 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::AlertManagementAlerts do
+ let_it_be(:creator) { create(:user) }
+ let_it_be(:project) do
+ create(:project, :public, creator_id: creator.id, namespace: creator.namespace)
+ end
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:alert) { create(:alert_management_alert, project: project) }
+
+ describe 'PUT /projects/:id/alert_management_alerts/:alert_iid/metric_images/authorize' do
+ include_context 'workhorse headers'
+
+ before do
+ project.add_developer(user)
+ end
+
+ subject do
+ post api("/projects/#{project.id}/alert_management_alerts/#{alert.iid}/metric_images/authorize", user),
+ headers: workhorse_headers
+ end
+
+ it 'authorizes uploading with workhorse header' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.media_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
+ end
+
+ it 'rejects requests that bypassed gitlab-workhorse' do
+ workhorse_headers.delete(Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+
+ context 'when using remote storage' do
+ context 'when direct upload is enabled' do
+ before do
+ stub_uploads_object_storage(MetricImageUploader, enabled: true, direct_upload: true)
+ end
+
+ it 'responds with status 200, location of file remote store and object details' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.media_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
+ expect(json_response).not_to have_key('TempPath')
+ expect(json_response['RemoteObject']).to have_key('ID')
+ expect(json_response['RemoteObject']).to have_key('GetURL')
+ expect(json_response['RemoteObject']).to have_key('StoreURL')
+ expect(json_response['RemoteObject']).to have_key('DeleteURL')
+ expect(json_response['RemoteObject']).to have_key('MultipartUpload')
+ end
+ end
+
+ context 'when direct upload is disabled' do
+ before do
+ stub_uploads_object_storage(MetricImageUploader, enabled: true, direct_upload: false)
+ end
+
+ it 'handles as a local file' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.media_type.to_s).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
+ expect(json_response['TempPath']).to eq(MetricImageUploader.workhorse_local_upload_path)
+ expect(json_response['RemoteObject']).to be_nil
+ end
+ end
+ end
+ end
+
+ describe 'POST /projects/:id/alert_management_alerts/:alert_iid/metric_images' do
+ include WorkhorseHelpers
+ using RSpec::Parameterized::TableSyntax
+
+ include_context 'workhorse headers'
+
+ let(:file) { fixture_file_upload('spec/fixtures/rails_sample.jpg', 'image/jpg') }
+ let(:file_name) { 'rails_sample.jpg' }
+ let(:url) { 'http://gitlab.com' }
+ let(:url_text) { 'GitLab' }
+
+ let(:params) { { url: url, url_text: url_text } }
+
+ subject do
+ workhorse_finalize(
+ api("/projects/#{project.id}/alert_management_alerts/#{alert.iid}/metric_images", user),
+ method: :post,
+ file_key: :file,
+ params: params.merge(file: file),
+ headers: workhorse_headers,
+ send_rewritten_field: true
+ )
+ end
+
+ shared_examples 'can_upload_metric_image' do
+ it 'creates a new metric image' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['filename']).to eq(file_name)
+ expect(json_response['url']).to eq(url)
+ expect(json_response['url_text']).to eq(url_text)
+ expect(json_response['created_at']).not_to be_nil
+ expect(json_response['id']).not_to be_nil
+ file_path_regex = %r{/uploads/-/system/alert_management_metric_image/file/\d+/#{file_name}}
+ expect(json_response['file_path']).to match(file_path_regex)
+ end
+ end
+
+ shared_examples 'unauthorized_upload' do
+ it 'disallows the upload' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ expect(json_response['message']).to eq('403 Forbidden')
+ end
+ end
+
+ where(:user_role, :expected_status) do
+ :guest | :unauthorized_upload
+ :reporter | :unauthorized_upload
+ :developer | :can_upload_metric_image
+ end
+
+ with_them do
+ before do
+ # Local storage
+ stub_uploads_object_storage(MetricImageUploader, enabled: false)
+ allow_next_instance_of(MetricImageUploader) do |uploader|
+ allow(uploader).to receive(:file_storage?).and_return(true)
+ end
+
+ project.send("add_#{user_role}", user)
+ end
+
+ it_behaves_like "#{params[:expected_status]}"
+ end
+
+ context 'file size too large' do
+ before do
+ allow_next_instance_of(UploadedFile) do |upload_file|
+ allow(upload_file).to receive(:size).and_return(AlertManagement::MetricImage::MAX_FILE_SIZE + 1)
+ end
+ end
+
+ it 'returns an error' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(response.body).to match(/File is too large/)
+ end
+ end
+
+ context 'error when saving' do
+ before do
+ project.add_developer(user)
+
+ allow_next_instance_of(::AlertManagement::MetricImages::UploadService) do |service|
+ error = instance_double(ServiceResponse, success?: false, message: 'some error', http_status: :bad_request)
+ allow(service).to receive(:execute).and_return(error)
+ end
+ end
+
+ it 'returns an error' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(response.body).to match(/some error/)
+ end
+ end
+
+ context 'object storage enabled' do
+ before do
+ # Object storage
+ stub_uploads_object_storage(MetricImageUploader)
+
+ allow_next_instance_of(MetricImageUploader) do |uploader|
+ allow(uploader).to receive(:file_storage?).and_return(true)
+ end
+ project.add_developer(user)
+ end
+
+ it_behaves_like 'can_upload_metric_image'
+
+ it 'uploads to remote storage' do
+ subject
+
+ last_upload = AlertManagement::MetricImage.last.uploads.last
+ expect(last_upload.store).to eq(::ObjectStorage::Store::REMOTE)
+ end
+ end
+ end
+
+ describe 'GET /projects/:id/alert_management_alerts/:alert_iid/metric_images' do
+ using RSpec::Parameterized::TableSyntax
+
+ let!(:image) { create(:alert_metric_image, alert: alert) }
+
+ subject { get api("/projects/#{project.id}/alert_management_alerts/#{alert.iid}/metric_images", user) }
+
+ shared_examples 'can_read_metric_image' do
+ it 'can read the metric images' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response.first).to match(
+ {
+ id: image.id,
+ created_at: image.created_at.strftime('%Y-%m-%dT%H:%M:%S.%LZ'),
+ filename: image.filename,
+ file_path: image.file_path,
+ url: image.url,
+ url_text: nil
+ }.with_indifferent_access
+ )
+ end
+ end
+
+ shared_examples 'unauthorized_read' do
+ it 'cannot read the metric images' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ where(:user_role, :public_project, :expected_status) do
+ :not_member | false | :unauthorized_read
+ :not_member | true | :unauthorized_read
+ :guest | false | :unauthorized_read
+ :reporter | false | :unauthorized_read
+ :developer | false | :can_read_metric_image
+ end
+
+ with_them do
+ before do
+ project.send("add_#{user_role}", user) unless user_role == :not_member
+ project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) unless public_project
+ end
+
+ it_behaves_like "#{params[:expected_status]}"
+ end
+ end
+
+ describe 'PUT /projects/:id/alert_management_alerts/:alert_iid/metric_images/:metric_image_id' do
+ using RSpec::Parameterized::TableSyntax
+
+ let!(:image) { create(:alert_metric_image, alert: alert) }
+ let(:params) { { url: 'http://test.example.com', url_text: 'Example website 123' } }
+
+ subject do
+ put api("/projects/#{project.id}/alert_management_alerts/#{alert.iid}/metric_images/#{image.id}", user),
+ params: params
+ end
+
+ shared_examples 'can_update_metric_image' do
+ it 'can update the metric images' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(json_response['url']).to eq(params[:url])
+ expect(json_response['url_text']).to eq(params[:url_text])
+ end
+ end
+
+ shared_examples 'unauthorized_update' do
+ it 'cannot update the metric image' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ expect(image.reload).to eq(image)
+ end
+ end
+
+ where(:user_role, :public_project, :expected_status) do
+ :not_member | false | :unauthorized_update
+ :not_member | true | :unauthorized_update
+ :guest | false | :unauthorized_update
+ :reporter | false | :unauthorized_update
+ :developer | false | :can_update_metric_image
+ end
+
+ with_them do
+ before do
+ project.send("add_#{user_role}", user) unless user_role == :not_member
+ project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) unless public_project
+ end
+
+ it_behaves_like "#{params[:expected_status]}"
+ end
+
+ context 'when user has access' do
+ before do
+ project.add_developer(user)
+ end
+
+ context 'and metric image not found' do
+ subject do
+ put api("/projects/#{project.id}/alert_management_alerts/#{alert.iid}/metric_images/#{non_existing_record_id}", user) # rubocop: disable Layout/LineLength
+ end
+
+ it 'returns an error' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(json_response['message']).to eq('Metric image not found')
+ end
+ end
+
+ context 'metric image cannot be updated' do
+ let(:params) { { url_text: 'something_long' * 100 } }
+
+ it 'returns an error' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:unprocessable_entity)
+ expect(json_response['message']).to eq('Metric image could not be updated')
+ end
+ end
+ end
+ end
+
+ describe 'DELETE /projects/:id/alert_management_alerts/:alert_iid/metric_images/:metric_image_id' do
+ using RSpec::Parameterized::TableSyntax
+
+ let!(:image) { create(:alert_metric_image, alert: alert) }
+
+ subject do
+ delete api("/projects/#{project.id}/alert_management_alerts/#{alert.iid}/metric_images/#{image.id}", user)
+ end
+
+ shared_examples 'can delete metric image successfully' do
+ it 'can delete the metric images' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ expect { image.reload }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+ end
+
+ shared_examples 'unauthorized delete' do
+ it 'cannot delete the metric image' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ expect(image.reload).to eq(image)
+ end
+ end
+
+ where(:user_role, :public_project, :expected_status) do
+ :not_member | false | 'unauthorized delete'
+ :not_member | true | 'unauthorized delete'
+ :guest | false | 'unauthorized delete'
+ :reporter | false | 'unauthorized delete'
+ :developer | false | 'can delete metric image successfully'
+ end
+
+ with_them do
+ before do
+ project.send("add_#{user_role}", user) unless user_role == :not_member
+ project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) unless public_project
+ end
+
+ it_behaves_like "#{params[:expected_status]}"
+ end
+
+ context 'when user has access' do
+ before do
+ project.add_developer(user)
+ end
+
+ context 'when metric image not found' do
+ subject do
+ delete api("/projects/#{project.id}/alert_management_alerts/#{alert.iid}/metric_images/#{non_existing_record_id}", user) # rubocop: disable Layout/LineLength
+ end
+
+ it 'returns an error' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(json_response['message']).to eq('Metric image not found')
+ end
+ end
+
+ context 'when error when deleting' do
+ before do
+ allow_next_instance_of(AlertManagement::AlertsFinder) do |finder|
+ allow(finder).to receive(:execute).and_return([alert])
+ end
+
+ allow(alert).to receive_message_chain('metric_images.find_by_id') { image }
+ allow(image).to receive(:destroy).and_return(false)
+ end
+
+ it 'returns an error' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:unprocessable_entity)
+ expect(json_response['message']).to eq('Metric image could not be deleted')
+ end
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/award_emoji_spec.rb b/spec/requests/api/award_emoji_spec.rb
index 07a9f7dfd74..782e14593f7 100644
--- a/spec/requests/api/award_emoji_spec.rb
+++ b/spec/requests/api/award_emoji_spec.rb
@@ -26,6 +26,23 @@ RSpec.describe API::AwardEmoji do
expect(json_response.first['name']).to eq(award_emoji.name)
end
+ it "includes custom emoji attributes" do
+ group = create(:group)
+ group.add_maintainer(user)
+
+ project = create(:project, namespace: group)
+ custom_emoji = create(:custom_emoji, name: 'partyparrot', namespace: group)
+ issue = create(:issue, project: project)
+ create(:award_emoji, awardable: issue, user: user, name: custom_emoji.name)
+
+ get api("/projects/#{project.id}/issues/#{issue.iid}/award_emoji", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_an Array
+ expect(json_response.first['name']).to eq(custom_emoji.name)
+ expect(json_response.first['url']).to eq(custom_emoji.file)
+ end
+
it "returns a 404 error when issue id not found" do
get api("/projects/#{project.id}/issues/#{non_existing_record_iid}/award_emoji", user)
diff --git a/spec/requests/api/bulk_imports_spec.rb b/spec/requests/api/bulk_imports_spec.rb
index 1602819a02e..3b8136f265b 100644
--- a/spec/requests/api/bulk_imports_spec.rb
+++ b/spec/requests/api/bulk_imports_spec.rb
@@ -18,6 +18,29 @@ RSpec.describe API::BulkImports do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.pluck('id')).to contain_exactly(import_1.id, import_2.id)
end
+
+ context 'sort parameter' do
+ it 'sorts by created_at descending by default' do
+ get api('/bulk_imports', user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response.pluck('id')).to eq([import_2.id, import_1.id])
+ end
+
+ it 'sorts by created_at descending when explicitly specified' do
+ get api('/bulk_imports', user), params: { sort: 'desc' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response.pluck('id')).to eq([import_2.id, import_1.id])
+ end
+
+ it 'sorts by created_at ascending when explicitly specified' do
+ get api('/bulk_imports', user), params: { sort: 'asc' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response.pluck('id')).to eq([import_1.id, import_2.id])
+ end
+ end
end
describe 'POST /bulk_imports' do
diff --git a/spec/requests/api/ci/job_artifacts_spec.rb b/spec/requests/api/ci/job_artifacts_spec.rb
index 0db6acbc7b8..5abff85af9c 100644
--- a/spec/requests/api/ci/job_artifacts_spec.rb
+++ b/spec/requests/api/ci/job_artifacts_spec.rb
@@ -82,18 +82,6 @@ RSpec.describe API::Ci::JobArtifacts do
end
describe 'DELETE /projects/:id/artifacts' do
- context 'when feature flag is disabled' do
- before do
- stub_feature_flags(bulk_expire_project_artifacts: false)
- end
-
- it 'returns 404' do
- delete api("/projects/#{project.id}/artifacts", api_user)
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
-
context 'when user is anonymous' do
let(:api_user) { nil }
@@ -236,6 +224,8 @@ RSpec.describe API::Ci::JobArtifacts do
expect(response.headers.to_h)
.to include('Content-Type' => 'application/json',
'Gitlab-Workhorse-Send-Data' => /artifacts-entry/)
+ expect(response.headers.to_h)
+ .not_to include('Gitlab-Workhorse-Detect-Content-Type' => 'true')
expect(response.parsed_body).to be_empty
end
@@ -568,7 +558,8 @@ RSpec.describe API::Ci::JobArtifacts do
expect(response).to have_gitlab_http_status(:ok)
expect(response.headers.to_h)
.to include('Content-Type' => 'application/json',
- 'Gitlab-Workhorse-Send-Data' => /artifacts-entry/)
+ 'Gitlab-Workhorse-Send-Data' => /artifacts-entry/,
+ 'Gitlab-Workhorse-Detect-Content-Type' => 'true')
end
end
@@ -638,7 +629,8 @@ RSpec.describe API::Ci::JobArtifacts do
expect(response).to have_gitlab_http_status(:ok)
expect(response.headers.to_h)
.to include('Content-Type' => 'application/json',
- 'Gitlab-Workhorse-Send-Data' => /artifacts-entry/)
+ 'Gitlab-Workhorse-Send-Data' => /artifacts-entry/,
+ 'Gitlab-Workhorse-Detect-Content-Type' => 'true')
expect(response.parsed_body).to be_empty
end
end
@@ -656,7 +648,8 @@ RSpec.describe API::Ci::JobArtifacts do
expect(response).to have_gitlab_http_status(:ok)
expect(response.headers.to_h)
.to include('Content-Type' => 'application/json',
- 'Gitlab-Workhorse-Send-Data' => /artifacts-entry/)
+ 'Gitlab-Workhorse-Send-Data' => /artifacts-entry/,
+ 'Gitlab-Workhorse-Detect-Content-Type' => 'true')
end
end
diff --git a/spec/requests/api/ci/jobs_spec.rb b/spec/requests/api/ci/jobs_spec.rb
index f6dae7e8e23..d3820e4948e 100644
--- a/spec/requests/api/ci/jobs_spec.rb
+++ b/spec/requests/api/ci/jobs_spec.rb
@@ -623,6 +623,15 @@ RSpec.describe API::Ci::Jobs do
end
end
+ context 'when a build is not retryable' do
+ let(:job) { create(:ci_build, :created, pipeline: pipeline) }
+
+ it 'responds with unprocessable entity' do
+ expect(json_response['message']).to eq('403 Forbidden - Job is not retryable')
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
context 'user without :update_build permission' do
let(:api_user) { reporter }
diff --git a/spec/requests/api/ci/runner/jobs_request_post_spec.rb b/spec/requests/api/ci/runner/jobs_request_post_spec.rb
index d317386dc73..9e6bac41d59 100644
--- a/spec/requests/api/ci/runner/jobs_request_post_spec.rb
+++ b/spec/requests/api/ci/runner/jobs_request_post_spec.rb
@@ -216,7 +216,7 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
expect(json_response['token']).to eq(job.token)
expect(json_response['job_info']).to eq(expected_job_info)
expect(json_response['git_info']).to eq(expected_git_info)
- expect(json_response['image']).to eq({ 'name' => 'ruby:2.7', 'entrypoint' => '/bin/sh', 'ports' => [] })
+ expect(json_response['image']).to eq({ 'name' => 'image:1.0', 'entrypoint' => '/bin/sh', 'ports' => [] })
expect(json_response['services']).to eq([{ 'name' => 'postgres', 'entrypoint' => nil,
'alias' => nil, 'command' => nil, 'ports' => [], 'variables' => nil },
{ 'name' => 'docker:stable-dind', 'entrypoint' => '/bin/sh',
@@ -611,6 +611,40 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
end
end
+ context 'when job has code coverage report' do
+ let(:job) do
+ create(:ci_build, :pending, :queued, :coverage_report_cobertura,
+ pipeline: pipeline, name: 'spinach', stage: 'test', stage_idx: 0)
+ end
+
+ let(:expected_artifacts) do
+ [
+ {
+ 'name' => 'cobertura-coverage.xml',
+ 'paths' => ['cobertura.xml'],
+ 'when' => 'always',
+ 'expire_in' => '7d',
+ "artifact_type" => "cobertura",
+ "artifact_format" => "gzip"
+ }
+ ]
+ end
+
+ it 'returns job with the correct artifact specification', :aggregate_failures do
+ request_job info: { platform: :darwin, features: { upload_multiple_artifacts: true } }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(response.headers['Content-Type']).to eq('application/json')
+ expect(response.headers).not_to have_key('X-GitLab-Last-Update')
+ expect(runner.reload.platform).to eq('darwin')
+ expect(json_response['id']).to eq(job.id)
+ expect(json_response['token']).to eq(job.token)
+ expect(json_response['job_info']).to eq(expected_job_info)
+ expect(json_response['git_info']).to eq(expected_git_info)
+ expect(json_response['artifacts']).to eq(expected_artifacts)
+ end
+ end
+
context 'when triggered job is available' do
let(:expected_variables) do
[{ 'key' => 'CI_JOB_NAME', 'value' => 'spinach', 'public' => true, 'masked' => false },
@@ -819,11 +853,11 @@ RSpec.describe API::Ci::Runner, :clean_gitlab_redis_shared_state do
subject { request_job(id: job.id) }
it_behaves_like 'storing arguments in the application context for the API' do
- let(:expected_params) { { user: user.username, project: project.full_path, client_id: "user/#{user.id}" } }
+ let(:expected_params) { { user: user.username, project: project.full_path, client_id: "runner/#{runner.id}", job_id: job.id, pipeline_id: job.pipeline_id } }
end
- it_behaves_like 'not executing any extra queries for the application context', 3 do
- # Extra queries: User, Project, Route
+ it_behaves_like 'not executing any extra queries for the application context', 4 do
+ # Extra queries: User, Project, Route, Runner
let(:subject_proc) { proc { request_job(id: job.id) } }
end
end
diff --git a/spec/requests/api/ci/secure_files_spec.rb b/spec/requests/api/ci/secure_files_spec.rb
index aa479cb8713..6de6d1ef222 100644
--- a/spec/requests/api/ci/secure_files_spec.rb
+++ b/spec/requests/api/ci/secure_files_spec.rb
@@ -6,15 +6,24 @@ RSpec.describe API::Ci::SecureFiles do
before do
stub_ci_secure_file_object_storage
stub_feature_flags(ci_secure_files: true)
+ stub_feature_flags(ci_secure_files_read_only: false)
end
let_it_be(:maintainer) { create(:user) }
let_it_be(:developer) { create(:user) }
let_it_be(:guest) { create(:user) }
let_it_be(:anonymous) { create(:user) }
+ let_it_be(:unconfirmed) { create(:user, :unconfirmed) }
let_it_be(:project) { create(:project, creator_id: maintainer.id) }
let_it_be(:secure_file) { create(:ci_secure_file, project: project) }
+ let(:file_params) do
+ {
+ file: fixture_file_upload('spec/fixtures/ci_secure_files/upload-keystore.jks'),
+ name: 'upload-keystore.jks'
+ }
+ end
+
before_all do
project.add_maintainer(maintainer)
project.add_developer(developer)
@@ -39,6 +48,43 @@ RSpec.describe API::Ci::SecureFiles do
end
end
+ context 'ci_secure_files_read_only feature flag' do
+ context 'when the flag is enabled' do
+ before do
+ stub_feature_flags(ci_secure_files_read_only: true)
+ end
+
+ it 'returns a 503 when attempting to upload a file' do
+ stub_feature_flags(ci_secure_files_read_only: true)
+
+ expect do
+ post api("/projects/#{project.id}/secure_files", maintainer), params: file_params
+ end.not_to change {project.secure_files.count}
+
+ expect(response).to have_gitlab_http_status(:service_unavailable)
+ end
+
+ it 'returns a 200 when downloading a file' do
+ stub_feature_flags(ci_secure_files_read_only: true)
+
+ get api("/projects/#{project.id}/secure_files", developer)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_a(Array)
+ end
+ end
+
+ context 'when the flag is disabled' do
+ it 'returns a 201 when uploading a file when the ci_secure_files_read_only feature flag is disabled' do
+ expect do
+ post api("/projects/#{project.id}/secure_files", maintainer), params: file_params
+ end.to change {project.secure_files.count}.by(1)
+
+ expect(response).to have_gitlab_http_status(:created)
+ end
+ end
+ end
+
context 'authenticated user with admin permissions' do
it 'returns project secure files' do
get api("/projects/#{project.id}/secure_files", maintainer)
@@ -73,6 +119,14 @@ RSpec.describe API::Ci::SecureFiles do
end
end
+ context 'unconfirmed user' do
+ it 'does not return project secure files' do
+ get api("/projects/#{project.id}/secure_files", unconfirmed)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
context 'unauthenticated user' do
it 'does not return project secure files' do
get api("/projects/#{project.id}/secure_files")
@@ -117,6 +171,14 @@ RSpec.describe API::Ci::SecureFiles do
end
end
+ context 'unconfirmed user' do
+ it 'does not return project secure file details' do
+ get api("/projects/#{project.id}/secure_files/#{secure_file.id}", unconfirmed)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
context 'unauthenticated user' do
it 'does not return project secure file details' do
get api("/projects/#{project.id}/secure_files/#{secure_file.id}")
@@ -167,6 +229,14 @@ RSpec.describe API::Ci::SecureFiles do
end
end
+ context 'unconfirmed user' do
+ it 'does not return project secure file details' do
+ get api("/projects/#{project.id}/secure_files/#{secure_file.id}/download", unconfirmed)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
context 'unauthenticated user' do
it 'does not return project secure file details' do
get api("/projects/#{project.id}/secure_files/#{secure_file.id}/download")
@@ -179,14 +249,8 @@ RSpec.describe API::Ci::SecureFiles do
describe 'POST /projects/:id/secure_files' do
context 'authenticated user with admin permissions' do
it 'creates a secure file' do
- params = {
- file: fixture_file_upload('spec/fixtures/ci_secure_files/upload-keystore.jks'),
- name: 'upload-keystore.jks',
- permissions: 'execute'
- }
-
expect do
- post api("/projects/#{project.id}/secure_files", maintainer), params: params
+ post api("/projects/#{project.id}/secure_files", maintainer), params: file_params.merge(permissions: 'execute')
end.to change {project.secure_files.count}.by(1)
expect(response).to have_gitlab_http_status(:created)
@@ -204,26 +268,15 @@ RSpec.describe API::Ci::SecureFiles do
end
it 'creates a secure file with read_only permissions by default' do
- params = {
- file: fixture_file_upload('spec/fixtures/ci_secure_files/upload-keystore.jks'),
- name: 'upload-keystore.jks'
- }
-
expect do
- post api("/projects/#{project.id}/secure_files", maintainer), params: params
+ post api("/projects/#{project.id}/secure_files", maintainer), params: file_params
end.to change {project.secure_files.count}.by(1)
expect(json_response['permissions']).to eq('read_only')
end
it 'uploads and downloads a secure file' do
- post_params = {
- file: fixture_file_upload('spec/fixtures/ci_secure_files/upload-keystore.jks'),
- name: 'upload-keystore.jks',
- permissions: 'read_write'
- }
-
- post api("/projects/#{project.id}/secure_files", maintainer), params: post_params
+ post api("/projects/#{project.id}/secure_files", maintainer), params: file_params
secure_file_id = json_response['id']
@@ -243,12 +296,8 @@ RSpec.describe API::Ci::SecureFiles do
end
it 'returns an error when no file is uploaded' do
- post_params = {
- name: 'upload-keystore.jks'
- }
-
expect do
- post api("/projects/#{project.id}/secure_files", maintainer), params: post_params
+ post api("/projects/#{project.id}/secure_files", maintainer), params: { name: 'upload-keystore.jks' }
end.not_to change { project.secure_files.count }
expect(response).to have_gitlab_http_status(:bad_request)
@@ -256,7 +305,17 @@ RSpec.describe API::Ci::SecureFiles do
end
it 'returns an error when the file name is missing' do
+ expect do
+ post api("/projects/#{project.id}/secure_files", maintainer), params: { file: fixture_file_upload('spec/fixtures/ci_secure_files/upload-keystore.jks') }
+ end.not_to change { project.secure_files.count }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['error']).to eq('name is missing')
+ end
+
+ it 'returns an error when the file name has already been used' do
post_params = {
+ name: secure_file.name,
file: fixture_file_upload('spec/fixtures/ci_secure_files/upload-keystore.jks')
}
@@ -265,18 +324,12 @@ RSpec.describe API::Ci::SecureFiles do
end.not_to change { project.secure_files.count }
expect(response).to have_gitlab_http_status(:bad_request)
- expect(json_response['error']).to eq('name is missing')
+ expect(json_response['message']['name']).to include('has already been taken')
end
it 'returns an error when an unexpected permission is supplied' do
- post_params = {
- file: fixture_file_upload('spec/fixtures/ci_secure_files/upload-keystore.jks'),
- name: 'upload-keystore.jks',
- permissions: 'foo'
- }
-
expect do
- post api("/projects/#{project.id}/secure_files", maintainer), params: post_params
+ post api("/projects/#{project.id}/secure_files", maintainer), params: file_params.merge(permissions: 'foo')
end.not_to change { project.secure_files.count }
expect(response).to have_gitlab_http_status(:bad_request)
@@ -290,13 +343,8 @@ RSpec.describe API::Ci::SecureFiles do
allow(instance).to receive_message_chain(:errors, :messages).and_return(['Error 1', 'Error 2'])
end
- post_params = {
- file: fixture_file_upload('spec/fixtures/ci_secure_files/upload-keystore.jks'),
- name: 'upload-keystore.jks'
- }
-
expect do
- post api("/projects/#{project.id}/secure_files", maintainer), params: post_params
+ post api("/projects/#{project.id}/secure_files", maintainer), params: file_params
end.not_to change { project.secure_files.count }
expect(response).to have_gitlab_http_status(:bad_request)
@@ -307,13 +355,8 @@ RSpec.describe API::Ci::SecureFiles do
allow(instance).to receive_message_chain(:file, :size).and_return(6.megabytes.to_i)
end
- post_params = {
- file: fixture_file_upload('spec/fixtures/ci_secure_files/upload-keystore.jks'),
- name: 'upload-keystore.jks'
- }
-
expect do
- post api("/projects/#{project.id}/secure_files", maintainer), params: post_params
+ post api("/projects/#{project.id}/secure_files", maintainer), params: file_params
end.not_to change { project.secure_files.count }
expect(response).to have_gitlab_http_status(:payload_too_large)
@@ -340,6 +383,16 @@ RSpec.describe API::Ci::SecureFiles do
end
end
+ context 'unconfirmed user' do
+ it 'does not create a secure file' do
+ expect do
+ post api("/projects/#{project.id}/secure_files", unconfirmed)
+ end.not_to change { project.secure_files.count }
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
context 'unauthenticated user' do
it 'does not create a secure file' do
expect do
@@ -390,6 +443,16 @@ RSpec.describe API::Ci::SecureFiles do
end
end
+ context 'unconfirmed user' do
+ it 'does not delete the secure_file' do
+ expect do
+ delete api("/projects/#{project.id}/secure_files#{secure_file.id}", unconfirmed)
+ end.not_to change { project.secure_files.count }
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
context 'unauthenticated user' do
it 'does not delete the secure_file' do
expect do
diff --git a/spec/requests/api/clusters/agents_spec.rb b/spec/requests/api/clusters/agents_spec.rb
new file mode 100644
index 00000000000..e29be255289
--- /dev/null
+++ b/spec/requests/api/clusters/agents_spec.rb
@@ -0,0 +1,153 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Clusters::Agents do
+ let_it_be(:agent) { create(:cluster_agent) }
+
+ let(:user) { agent.created_by_user }
+ let(:unauthorized_user) { create(:user) }
+ let!(:project) { agent.project }
+
+ before do
+ project.add_maintainer(user)
+ end
+
+ describe 'GET /projects/:id/cluster_agents' do
+ context 'authorized user' do
+ it 'returns project agents' do
+ get api("/projects/#{project.id}/cluster_agents", user)
+
+ aggregate_failures "testing response" do
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(response).to match_response_schema('public_api/v4/agents')
+ expect(json_response.count).to eq(1)
+ expect(json_response.first['name']).to eq(agent.name)
+ end
+ end
+ end
+
+ context 'unauthorized user' do
+ it 'unable to access agents' do
+ get api("/projects/#{project.id}/cluster_agents", unauthorized_user)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ it 'avoids N+1 queries', :request_store do
+ # Establish baseline
+ get api("/projects/#{project.id}/cluster_agents", user)
+
+ control = ActiveRecord::QueryRecorder.new do
+ get api("/projects/#{project.id}/cluster_agents", user)
+ end
+
+ # Now create a second record and ensure that the API does not execute
+ # any more queries than before
+ create(:cluster_agent, project: project)
+
+ expect do
+ get api("/projects/#{project.id}/cluster_agents", user)
+ end.not_to exceed_query_limit(control)
+ end
+ end
+
+ describe 'GET /projects/:id/cluster_agents/:agent_id' do
+ context 'authorized user' do
+ it 'returns a project agent' do
+ get api("/projects/#{project.id}/cluster_agents/#{agent.id}", user)
+
+ aggregate_failures "testing response" do
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/agent')
+ expect(json_response['name']).to eq(agent.name)
+ end
+ end
+
+ it 'returns a 404 error if agent id is not available' do
+ get api("/projects/#{project.id}/cluster_agents/#{non_existing_record_id}", user)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'unauthorized user' do
+ it 'unable to access an existing agent' do
+ get api("/projects/#{project.id}/cluster_agents/#{agent.id}", unauthorized_user)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ describe 'POST /projects/:id/cluster_agents' do
+ it 'adds agent to project' do
+ expect do
+ post(api("/projects/#{project.id}/cluster_agents", user),
+ params: { name: 'some-agent' })
+ end.to change {project.cluster_agents.count}.by(1)
+
+ aggregate_failures "testing response" do
+ expect(response).to have_gitlab_http_status(:created)
+ expect(response).to match_response_schema('public_api/v4/agent')
+ expect(json_response['name']).to eq('some-agent')
+ end
+ end
+
+ it 'returns a 400 error if name not given' do
+ post api("/projects/#{project.id}/cluster_agents", user)
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+
+ it 'returns a 400 error if name is invalid' do
+ post api("/projects/#{project.id}/cluster_agents", user), params: { name: '#4^x' }
+
+ aggregate_failures "testing response" do
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['message'])
+ .to include("Name can contain only lowercase letters, digits, and '-', but cannot start or end with '-'")
+ end
+ end
+
+ it 'returns 404 error if project does not exist' do
+ post api("/projects/#{non_existing_record_id}/cluster_agents", user), params: { name: 'some-agent' }
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ describe 'DELETE /projects/:id/cluster_agents/:agent_id' do
+ it 'deletes agent from project' do
+ expect do
+ delete api("/projects/#{project.id}/cluster_agents/#{agent.id}", user)
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end.to change {project.cluster_agents.count}.by(-1)
+ end
+
+ it 'returns a 404 error when deleting non existent agent' do
+ delete api("/projects/#{project.id}/cluster_agents/#{non_existing_record_id}", user)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+
+ it 'returns a 404 error if agent id not given' do
+ delete api("/projects/#{project.id}/cluster_agents", user)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+
+ it 'returns a 404 if the user is unauthorized to delete' do
+ delete api("/projects/#{project.id}/cluster_agents/#{agent.id}", unauthorized_user)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+
+ it_behaves_like '412 response' do
+ let(:request) { api("/projects/#{project.id}/cluster_agents/#{agent.id}", user) }
+ end
+ end
+end
diff --git a/spec/requests/api/composer_packages_spec.rb b/spec/requests/api/composer_packages_spec.rb
index bc30fc3b230..53f3ef10743 100644
--- a/spec/requests/api/composer_packages_spec.rb
+++ b/spec/requests/api/composer_packages_spec.rb
@@ -430,11 +430,23 @@ RSpec.describe API::ComposerPackages do
context 'with valid project' do
let!(:package) { create(:composer_package, :with_metadatum, name: package_name, project: project) }
+ let(:headers) { basic_auth_header(user.username, personal_access_token.token) }
+
+ before do
+ project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
+ end
context 'when the sha does not match the package name' do
let(:sha) { '123' }
+ let(:headers) { basic_auth_header(user.username, personal_access_token.token) }
- it_behaves_like 'process Composer api request', :anonymous, :not_found
+ context 'anonymous' do
+ let(:headers) { {} }
+
+ it_behaves_like 'process Composer api request', :anonymous, :unauthorized
+ end
+
+ it_behaves_like 'process Composer api request', :developer, :not_found
end
context 'when the package name does not match the sha' do
@@ -442,7 +454,13 @@ RSpec.describe API::ComposerPackages do
let(:sha) { branch.target }
let(:url) { "/projects/#{project.id}/packages/composer/archives/unexisting-package-name.zip" }
- it_behaves_like 'process Composer api request', :anonymous, :not_found
+ context 'anonymous' do
+ let(:headers) { {} }
+
+ it_behaves_like 'process Composer api request', :anonymous, :unauthorized
+ end
+
+ it_behaves_like 'process Composer api request', :developer, :not_found
end
context 'with a match package name and sha' do
@@ -460,14 +478,14 @@ RSpec.describe API::ComposerPackages do
'PUBLIC' | :guest | false | false | :success
'PUBLIC' | :anonymous | false | true | :success
'PRIVATE' | :developer | true | true | :success
- 'PRIVATE' | :developer | true | false | :success
- 'PRIVATE' | :developer | false | true | :success
- 'PRIVATE' | :developer | false | false | :success
- 'PRIVATE' | :guest | true | true | :success
- 'PRIVATE' | :guest | true | false | :success
- 'PRIVATE' | :guest | false | true | :success
- 'PRIVATE' | :guest | false | false | :success
- 'PRIVATE' | :anonymous | false | true | :success
+ 'PRIVATE' | :developer | true | false | :unauthorized
+ 'PRIVATE' | :developer | false | true | :not_found
+ 'PRIVATE' | :developer | false | false | :unauthorized
+ 'PRIVATE' | :guest | true | true | :forbidden
+ 'PRIVATE' | :guest | true | false | :unauthorized
+ 'PRIVATE' | :guest | false | true | :not_found
+ 'PRIVATE' | :guest | false | false | :unauthorized
+ 'PRIVATE' | :anonymous | false | true | :unauthorized
end
with_them do
@@ -480,8 +498,17 @@ RSpec.describe API::ComposerPackages do
end
it_behaves_like 'process Composer api request', params[:user_role], params[:expected_status], params[:member]
- it_behaves_like 'a package tracking event', described_class.name, 'pull_package'
+
+ include_context 'Composer user type', params[:user_role], params[:member] do
+ if params[:expected_status] == :success
+ it_behaves_like 'a package tracking event', described_class.name, 'pull_package'
+ else
+ it_behaves_like 'not a package tracking event'
+ end
+ end
end
+
+ it_behaves_like 'Composer publish with deploy tokens'
end
end
diff --git a/spec/requests/api/files_spec.rb b/spec/requests/api/files_spec.rb
index 6aa12b6ff48..cb0b5f6bfc3 100644
--- a/spec/requests/api/files_spec.rb
+++ b/spec/requests/api/files_spec.rb
@@ -9,6 +9,7 @@ RSpec.describe API::Files do
let!(:project) { create(:project, :repository, namespace: user.namespace ) }
let(:guest) { create(:user) { |u| project.add_guest(u) } }
let(:file_path) { "files%2Fruby%2Fpopen%2Erb" }
+ let(:executable_file_path) { "files%2Fexecutables%2Fls" }
let(:rouge_file_path) { "%2e%2e%2f" }
let(:absolute_path) { "%2Fetc%2Fpasswd.rb" }
let(:invalid_file_message) { 'file_path should be a valid file path' }
@@ -18,6 +19,12 @@ RSpec.describe API::Files do
}
end
+ let(:executable_ref_params) do
+ {
+ ref: 'with-executables'
+ }
+ end
+
let(:author_email) { 'user@example.org' }
let(:author_name) { 'John Doe' }
@@ -219,9 +226,26 @@ RSpec.describe API::Files do
expect(json_response['file_name']).to eq('popen.rb')
expect(json_response['last_commit_id']).to eq('570e7b2abdd848b95f2f578043fc23bd6f6fd24d')
expect(json_response['content_sha256']).to eq('c440cd09bae50c4632cc58638ad33c6aa375b6109d811e76a9cc3a613c1e8887')
+ expect(json_response['execute_filemode']).to eq(false)
expect(Base64.decode64(json_response['content']).lines.first).to eq("require 'fileutils'\n")
end
+ context 'for executable file' do
+ it 'returns file attributes as json' do
+ get api(route(executable_file_path), api_user, **options), params: executable_ref_params
+
+ aggregate_failures 'testing response' do
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['file_path']).to eq(CGI.unescape(executable_file_path))
+ expect(json_response['file_name']).to eq('ls')
+ expect(json_response['last_commit_id']).to eq('6b8dc4a827797aa025ff6b8f425e583858a10d4f')
+ expect(json_response['content_sha256']).to eq('2c74b1181ef780dfb692c030d3a0df6e0b624135c38a9344e56b9f80007b6191')
+ expect(json_response['execute_filemode']).to eq(true)
+ expect(Base64.decode64(json_response['content']).lines.first).to eq("#!/bin/sh\n")
+ end
+ end
+ end
+
it 'returns json when file has txt extension' do
file_path = "bar%2Fbranch-test.txt"
@@ -386,6 +410,23 @@ RSpec.describe API::Files do
expect(response.headers['X-Gitlab-Last-Commit-Id']).to eq('570e7b2abdd848b95f2f578043fc23bd6f6fd24d')
expect(response.headers['X-Gitlab-Content-Sha256'])
.to eq('c440cd09bae50c4632cc58638ad33c6aa375b6109d811e76a9cc3a613c1e8887')
+ expect(response.headers['X-Gitlab-Execute-Filemode']).to eq("false")
+ end
+
+ context 'for executable file' do
+ it 'returns file attributes in headers' do
+ head api(route(executable_file_path) + '/blame', current_user), params: executable_ref_params
+
+ aggregate_failures 'testing response' do
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.headers['X-Gitlab-File-Path']).to eq(CGI.unescape(executable_file_path))
+ expect(response.headers['X-Gitlab-File-Name']).to eq('ls')
+ expect(response.headers['X-Gitlab-Last-Commit-Id']).to eq('6b8dc4a827797aa025ff6b8f425e583858a10d4f')
+ expect(response.headers['X-Gitlab-Content-Sha256'])
+ .to eq('2c74b1181ef780dfb692c030d3a0df6e0b624135c38a9344e56b9f80007b6191')
+ expect(response.headers['X-Gitlab-Execute-Filemode']).to eq("true")
+ end
+ end
end
it 'returns 400 when file path is invalid' do
@@ -642,6 +683,15 @@ RSpec.describe API::Files do
}
end
+ let(:executable_params) do
+ {
+ branch: "master",
+ content: "puts 8",
+ commit_message: "Added newfile",
+ execute_filemode: true
+ }
+ end
+
it 'returns 400 when file path is invalid' do
post api(route(rouge_file_path), user), params: params
@@ -661,6 +711,18 @@ RSpec.describe API::Files do
last_commit = project.repository.commit.raw
expect(last_commit.author_email).to eq(user.email)
expect(last_commit.author_name).to eq(user.name)
+ expect(project.repository.blob_at_branch(params[:branch], CGI.unescape(file_path)).executable?).to eq(false)
+ end
+
+ it "creates a new executable file in project repo" do
+ post api(route(file_path), user), params: executable_params
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response["file_path"]).to eq(CGI.unescape(file_path))
+ last_commit = project.repository.commit.raw
+ expect(last_commit.author_email).to eq(user.email)
+ expect(last_commit.author_name).to eq(user.name)
+ expect(project.repository.blob_at_branch(params[:branch], CGI.unescape(file_path)).executable?).to eq(true)
end
it "returns a 400 bad request if no mandatory params given" do
@@ -820,6 +882,44 @@ RSpec.describe API::Files do
expect(last_commit.author_name).to eq(author_name)
end
end
+
+ context 'when specifying the execute_filemode' do
+ let(:executable_params) do
+ {
+ branch: 'master',
+ content: 'puts 8',
+ commit_message: 'Changed file',
+ execute_filemode: true
+ }
+ end
+
+ let(:non_executable_params) do
+ {
+ branch: 'with-executables',
+ content: 'puts 8',
+ commit_message: 'Changed file',
+ execute_filemode: false
+ }
+ end
+
+ it 'updates to executable file mode' do
+ put api(route(file_path), user), params: executable_params
+
+ aggregate_failures 'testing response' do
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(project.repository.blob_at_branch(executable_params[:branch], CGI.unescape(file_path)).executable?).to eq(true)
+ end
+ end
+
+ it 'updates to non-executable file mode' do
+ put api(route(executable_file_path), user), params: non_executable_params
+
+ aggregate_failures 'testing response' do
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(project.repository.blob_at_branch(non_executable_params[:branch], CGI.unescape(executable_file_path)).executable?).to eq(false)
+ end
+ end
+ end
end
describe "DELETE /projects/:id/repository/files" do
diff --git a/spec/requests/api/graphql/ci/job_spec.rb b/spec/requests/api/graphql/ci/job_spec.rb
index b0514a0a963..ddb2664d353 100644
--- a/spec/requests/api/graphql/ci/job_spec.rb
+++ b/spec/requests/api/graphql/ci/job_spec.rb
@@ -52,6 +52,7 @@ RSpec.describe 'Query.project(fullPath).pipelines.job(id)' do
'name' => job_2.name,
'allowFailure' => job_2.allow_failure,
'duration' => 25,
+ 'kind' => 'BUILD',
'queuedDuration' => 2.0,
'status' => job_2.status.upcase
)
diff --git a/spec/requests/api/graphql/ci/jobs_spec.rb b/spec/requests/api/graphql/ci/jobs_spec.rb
index b191b585d06..2d1bb45390b 100644
--- a/spec/requests/api/graphql/ci/jobs_spec.rb
+++ b/spec/requests/api/graphql/ci/jobs_spec.rb
@@ -155,6 +155,56 @@ RSpec.describe 'Query.project.pipeline' do
end
end
+ describe '.jobs.kind' do
+ let_it_be(:pipeline) { create(:ci_pipeline, project: project) }
+
+ let(:query) do
+ %(
+ query {
+ project(fullPath: "#{project.full_path}") {
+ pipeline(iid: "#{pipeline.iid}") {
+ stages {
+ nodes {
+ groups{
+ nodes {
+ jobs {
+ nodes {
+ kind
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ )
+ end
+
+ context 'when the job is a build' do
+ it 'returns BUILD' do
+ create(:ci_build, pipeline: pipeline)
+
+ post_graphql(query, current_user: user)
+
+ job_data = graphql_data_at(:project, :pipeline, :stages, :nodes, :groups, :nodes, :jobs, :nodes).first
+ expect(job_data['kind']).to eq 'BUILD'
+ end
+ end
+
+ context 'when the job is a bridge' do
+ it 'returns BRIDGE' do
+ create(:ci_bridge, pipeline: pipeline)
+
+ post_graphql(query, current_user: user)
+
+ job_data = graphql_data_at(:project, :pipeline, :stages, :nodes, :groups, :nodes, :jobs, :nodes).first
+ expect(job_data['kind']).to eq 'BRIDGE'
+ end
+ end
+ end
+
describe '.jobs.artifacts' do
let_it_be(:pipeline) { create(:ci_pipeline, project: project) }
diff --git a/spec/requests/api/graphql/ci/runner_spec.rb b/spec/requests/api/graphql/ci/runner_spec.rb
index b99a3d14fb9..39f0f696b08 100644
--- a/spec/requests/api/graphql/ci/runner_spec.rb
+++ b/spec/requests/api/graphql/ci/runner_spec.rb
@@ -27,27 +27,18 @@ RSpec.describe 'Query.runner(id)' do
let_it_be(:active_project_runner) { create(:ci_runner, :project) }
- def get_runner(id)
- case id
- when :active_instance_runner
- active_instance_runner
- when :inactive_instance_runner
- inactive_instance_runner
- when :active_group_runner
- active_group_runner
- when :active_project_runner
- active_project_runner
- end
+ before do
+ allow(Gitlab::Ci::RunnerUpgradeCheck.instance).to receive(:check_runner_upgrade_status)
end
- shared_examples 'runner details fetch' do |runner_id|
+ shared_examples 'runner details fetch' do
let(:query) do
wrap_fields(query_graphql_path(query_path, all_graphql_fields_for('CiRunner')))
end
let(:query_path) do
[
- [:runner, { id: get_runner(runner_id).to_global_id.to_s }]
+ [:runner, { id: runner.to_global_id.to_s }]
]
end
@@ -57,7 +48,6 @@ RSpec.describe 'Query.runner(id)' do
runner_data = graphql_data_at(:runner)
expect(runner_data).not_to be_nil
- runner = get_runner(runner_id)
expect(runner_data).to match a_hash_including(
'id' => runner.to_global_id.to_s,
'description' => runner.description,
@@ -90,14 +80,14 @@ RSpec.describe 'Query.runner(id)' do
end
end
- shared_examples 'retrieval with no admin url' do |runner_id|
+ shared_examples 'retrieval with no admin url' do
let(:query) do
wrap_fields(query_graphql_path(query_path, all_graphql_fields_for('CiRunner')))
end
let(:query_path) do
[
- [:runner, { id: get_runner(runner_id).to_global_id.to_s }]
+ [:runner, { id: runner.to_global_id.to_s }]
]
end
@@ -107,7 +97,6 @@ RSpec.describe 'Query.runner(id)' do
runner_data = graphql_data_at(:runner)
expect(runner_data).not_to be_nil
- runner = get_runner(runner_id)
expect(runner_data).to match a_hash_including(
'id' => runner.to_global_id.to_s,
'adminUrl' => nil
@@ -116,14 +105,14 @@ RSpec.describe 'Query.runner(id)' do
end
end
- shared_examples 'retrieval by unauthorized user' do |runner_id|
+ shared_examples 'retrieval by unauthorized user' do
let(:query) do
wrap_fields(query_graphql_path(query_path, all_graphql_fields_for('CiRunner')))
end
let(:query_path) do
[
- [:runner, { id: get_runner(runner_id).to_global_id.to_s }]
+ [:runner, { id: runner.to_global_id.to_s }]
]
end
@@ -135,7 +124,9 @@ RSpec.describe 'Query.runner(id)' do
end
describe 'for active runner' do
- it_behaves_like 'runner details fetch', :active_instance_runner
+ let(:runner) { active_instance_runner }
+
+ it_behaves_like 'runner details fetch'
context 'when tagList is not requested' do
let(:query) do
@@ -144,7 +135,7 @@ RSpec.describe 'Query.runner(id)' do
let(:query_path) do
[
- [:runner, { id: active_instance_runner.to_global_id.to_s }]
+ [:runner, { id: runner.to_global_id.to_s }]
]
end
@@ -193,7 +184,9 @@ RSpec.describe 'Query.runner(id)' do
end
describe 'for inactive runner' do
- it_behaves_like 'runner details fetch', :inactive_instance_runner
+ let(:runner) { inactive_instance_runner }
+
+ it_behaves_like 'runner details fetch'
end
describe 'for group runner request' do
@@ -369,15 +362,21 @@ RSpec.describe 'Query.runner(id)' do
let(:user) { create(:user) }
context 'on instance runner' do
- it_behaves_like 'retrieval by unauthorized user', :active_instance_runner
+ let(:runner) { active_instance_runner }
+
+ it_behaves_like 'retrieval by unauthorized user'
end
context 'on group runner' do
- it_behaves_like 'retrieval by unauthorized user', :active_group_runner
+ let(:runner) { active_group_runner }
+
+ it_behaves_like 'retrieval by unauthorized user'
end
context 'on project runner' do
- it_behaves_like 'retrieval by unauthorized user', :active_project_runner
+ let(:runner) { active_project_runner }
+
+ it_behaves_like 'retrieval by unauthorized user'
end
end
@@ -388,13 +387,17 @@ RSpec.describe 'Query.runner(id)' do
group.add_user(user, Gitlab::Access::OWNER)
end
- it_behaves_like 'retrieval with no admin url', :active_group_runner
+ it_behaves_like 'retrieval with no admin url' do
+ let(:runner) { active_group_runner }
+ end
end
describe 'by unauthenticated user' do
let(:user) { nil }
- it_behaves_like 'retrieval by unauthorized user', :active_instance_runner
+ it_behaves_like 'retrieval by unauthorized user' do
+ let(:runner) { active_instance_runner }
+ end
end
describe 'Query limits' do
diff --git a/spec/requests/api/graphql/ci/runners_spec.rb b/spec/requests/api/graphql/ci/runners_spec.rb
index 267dd1b5e6f..6b88c82b025 100644
--- a/spec/requests/api/graphql/ci/runners_spec.rb
+++ b/spec/requests/api/graphql/ci/runners_spec.rb
@@ -34,6 +34,8 @@ RSpec.describe 'Query.runners' do
end
before do
+ allow(Gitlab::Ci::RunnerUpgradeCheck.instance).to receive(:check_runner_upgrade_status)
+
post_graphql(query, current_user: current_user)
end
diff --git a/spec/requests/api/graphql/mutations/boards/create_spec.rb b/spec/requests/api/graphql/mutations/boards/create_spec.rb
index 22d05f36f0f..ca848c0c92f 100644
--- a/spec/requests/api/graphql/mutations/boards/create_spec.rb
+++ b/spec/requests/api/graphql/mutations/boards/create_spec.rb
@@ -4,6 +4,16 @@ require 'spec_helper'
RSpec.describe Mutations::Boards::Create do
let_it_be(:parent) { create(:project) }
+ let_it_be(:current_user, reload: true) { create(:user) }
+
+ let(:name) { 'board name' }
+ let(:mutation) { graphql_mutation(:create_board, params) }
+
+ subject { post_graphql_mutation(mutation, current_user: current_user) }
+
+ def mutation_response
+ graphql_mutation_response(:create_board)
+ end
let(:project_path) { parent.full_path }
let(:params) do
diff --git a/spec/requests/api/graphql/mutations/ci/job_retry_spec.rb b/spec/requests/api/graphql/mutations/ci/job_retry_spec.rb
index a14935379dc..ef640183bd8 100644
--- a/spec/requests/api/graphql/mutations/ci/job_retry_spec.rb
+++ b/spec/requests/api/graphql/mutations/ci/job_retry_spec.rb
@@ -8,7 +8,8 @@ RSpec.describe 'JobRetry' do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:pipeline) { create(:ci_pipeline, project: project, user: user) }
- let_it_be(:job) { create(:ci_build, :success, pipeline: pipeline, name: 'build') }
+
+ let(:job) { create(:ci_build, :success, pipeline: pipeline, name: 'build') }
let(:mutation) do
variables = {
@@ -37,10 +38,23 @@ RSpec.describe 'JobRetry' do
end
it 'retries a job' do
- job_id = ::Gitlab::GlobalId.build(job, id: job.id).to_s
post_graphql_mutation(mutation, current_user: user)
expect(response).to have_gitlab_http_status(:success)
- expect(mutation_response['job']['id']).to eq(job_id)
+ new_job_id = GitlabSchema.object_from_id(mutation_response['job']['id']).sync.id
+
+ new_job = ::Ci::Build.find(new_job_id)
+ expect(new_job).not_to be_retried
+ end
+
+ context 'when the job is not retryable' do
+ let(:job) { create(:ci_build, :retried, pipeline: pipeline) }
+
+ it 'returns an error' do
+ post_graphql_mutation(mutation, current_user: user)
+
+ expect(mutation_response['job']).to be(nil)
+ expect(mutation_response['errors']).to match_array(['Job cannot be retried'])
+ end
end
end
diff --git a/spec/requests/api/graphql/mutations/ci/pipeline_cancel_spec.rb b/spec/requests/api/graphql/mutations/ci/pipeline_cancel_spec.rb
index a20ac823550..d9106aa42c4 100644
--- a/spec/requests/api/graphql/mutations/ci/pipeline_cancel_spec.rb
+++ b/spec/requests/api/graphql/mutations/ci/pipeline_cancel_spec.rb
@@ -47,5 +47,6 @@ RSpec.describe 'PipelineCancel' do
expect(response).to have_gitlab_http_status(:success)
expect(build.reload).to be_canceled
+ expect(pipeline.reload).to be_canceled
end
end
diff --git a/spec/requests/api/graphql/mutations/issues/update_spec.rb b/spec/requests/api/graphql/mutations/issues/update_spec.rb
index 0f2eeb90894..f38deb426b1 100644
--- a/spec/requests/api/graphql/mutations/issues/update_spec.rb
+++ b/spec/requests/api/graphql/mutations/issues/update_spec.rb
@@ -8,8 +8,8 @@ RSpec.describe 'Update of an existing issue' do
let_it_be(:current_user) { create(:user) }
let_it_be(:project) { create(:project, :public) }
let_it_be(:issue) { create(:issue, project: project) }
- let_it_be(:label1) { create(:label, project: project) }
- let_it_be(:label2) { create(:label, project: project) }
+ let_it_be(:label1) { create(:label, title: "a", project: project) }
+ let_it_be(:label2) { create(:label, title: "b", project: project) }
let(:input) do
{
@@ -124,7 +124,7 @@ RSpec.describe 'Update of an existing issue' do
context 'add and remove labels' do
let(:input_params) { input.merge(extra_params).merge({ addLabelIds: [label1.id], removeLabelIds: [label2.id] }) }
- it 'returns error for mutually exclusive arguments' do
+ it 'returns correct labels' do
post_graphql_mutation(mutation, current_user: current_user)
expect(response).to have_gitlab_http_status(:success)
@@ -132,6 +132,22 @@ RSpec.describe 'Update of an existing issue' do
expect(mutation_response['issue']['labels']).to include({ "nodes" => [{ "id" => label1.to_global_id.to_s }] })
end
end
+
+ context 'add labels' do
+ let(:input_params) { input.merge(extra_params).merge({ addLabelIds: [label1.id] }) }
+
+ before do
+ issue.update!({ labels: [label2] })
+ end
+
+ it 'adds labels and keeps the title ordering' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(json_response['errors']).to be_nil
+ expect(mutation_response['issue']['labels']['nodes']).to eq([{ "id" => label1.to_global_id.to_s }, { "id" => label2.to_global_id.to_s }])
+ end
+ end
end
end
end
diff --git a/spec/requests/api/graphql/mutations/merge_requests/set_labels_spec.rb b/spec/requests/api/graphql/mutations/merge_requests/set_labels_spec.rb
index 0d0cc66c52a..e40a3cf7ce9 100644
--- a/spec/requests/api/graphql/mutations/merge_requests/set_labels_spec.rb
+++ b/spec/requests/api/graphql/mutations/merge_requests/set_labels_spec.rb
@@ -8,8 +8,8 @@ RSpec.describe 'Setting labels of a merge request' do
let(:current_user) { create(:user) }
let(:merge_request) { create(:merge_request) }
let(:project) { merge_request.project }
- let(:label) { create(:label, project: project) }
- let(:label2) { create(:label, project: project) }
+ let(:label) { create(:label, title: "a", project: project) }
+ let(:label2) { create(:label, title: "b", project: project) }
let(:input) { { label_ids: [GitlabSchema.id_from_object(label).to_s] } }
let(:mutation) do
@@ -81,12 +81,12 @@ RSpec.describe 'Setting labels of a merge request' do
merge_request.update!(labels: [label2])
end
- it 'sets the labels, without removing others' do
+ it 'sets the labels and resets labels to keep the title ordering, without removing others' do
post_graphql_mutation(mutation, current_user: current_user)
expect(response).to have_gitlab_http_status(:success)
expect(mutation_label_nodes.count).to eq(2)
- expect(mutation_label_nodes).to contain_exactly({ 'id' => label.to_global_id.to_s }, { 'id' => label2.to_global_id.to_s })
+ expect(mutation_label_nodes).to eq([{ 'id' => label.to_global_id.to_s }, { 'id' => label2.to_global_id.to_s }])
end
end
diff --git a/spec/requests/api/graphql/mutations/notes/create/note_spec.rb b/spec/requests/api/graphql/mutations/notes/create/note_spec.rb
index 2bc671e4ca5..63b94dccca0 100644
--- a/spec/requests/api/graphql/mutations/notes/create/note_spec.rb
+++ b/spec/requests/api/graphql/mutations/notes/create/note_spec.rb
@@ -17,8 +17,7 @@ RSpec.describe 'Adding a Note' do
noteable_id: GitlabSchema.id_from_object(noteable).to_s,
discussion_id: (GitlabSchema.id_from_object(discussion).to_s if discussion),
merge_request_diff_head_sha: head_sha.presence,
- body: body,
- confidential: true
+ body: body
}
graphql_mutation(:create_note, variables)
@@ -49,7 +48,6 @@ RSpec.describe 'Adding a Note' do
post_graphql_mutation(mutation, current_user: current_user)
expect(mutation_response['note']['body']).to eq('Body text')
- expect(mutation_response['note']['confidential']).to eq(true)
end
describe 'creating Notes in reply to a discussion' do
@@ -79,6 +77,25 @@ RSpec.describe 'Adding a Note' do
end
end
+ context 'for an issue' do
+ let(:noteable) { create(:issue, project: project) }
+ let(:mutation) do
+ variables = {
+ noteable_id: GitlabSchema.id_from_object(noteable).to_s,
+ body: body,
+ confidential: true
+ }
+
+ graphql_mutation(:create_note, variables)
+ end
+
+ before do
+ project.add_developer(current_user)
+ end
+
+ it_behaves_like 'a Note mutation with confidential notes'
+ end
+
context 'when body only contains quick actions' do
let(:head_sha) { noteable.diff_head_sha }
let(:body) { '/merge' }
diff --git a/spec/requests/api/graphql/mutations/notes/update/note_spec.rb b/spec/requests/api/graphql/mutations/notes/update/note_spec.rb
index 5a92ffe61b8..bae5c58abff 100644
--- a/spec/requests/api/graphql/mutations/notes/update/note_spec.rb
+++ b/spec/requests/api/graphql/mutations/notes/update/note_spec.rb
@@ -8,7 +8,7 @@ RSpec.describe 'Updating a Note' do
let!(:note) { create(:note, note: original_body) }
let(:original_body) { 'Initial body text' }
let(:updated_body) { 'Updated body text' }
- let(:params) { { body: updated_body, confidential: true } }
+ let(:params) { { body: updated_body } }
let(:mutation) do
variables = params.merge(id: GitlabSchema.id_from_object(note).to_s)
@@ -28,7 +28,6 @@ RSpec.describe 'Updating a Note' do
post_graphql_mutation(mutation, current_user: current_user)
expect(note.reload.note).to eq(original_body)
- expect(note.confidential).to be_falsey
end
end
@@ -41,46 +40,19 @@ RSpec.describe 'Updating a Note' do
post_graphql_mutation(mutation, current_user: current_user)
expect(note.reload.note).to eq(updated_body)
- expect(note.confidential).to be_truthy
end
it 'returns the updated Note' do
post_graphql_mutation(mutation, current_user: current_user)
expect(mutation_response['note']['body']).to eq(updated_body)
- expect(mutation_response['note']['confidential']).to be_truthy
- end
-
- context 'when only confidential param is present' do
- let(:params) { { confidential: true } }
-
- it 'updates only the note confidentiality' do
- post_graphql_mutation(mutation, current_user: current_user)
-
- expect(note.reload.note).to eq(original_body)
- expect(note.confidential).to be_truthy
- end
- end
-
- context 'when only body param is present' do
- let(:params) { { body: updated_body } }
-
- before do
- note.update_column(:confidential, true)
- end
-
- it 'updates only the note body' do
- post_graphql_mutation(mutation, current_user: current_user)
-
- expect(note.reload.note).to eq(updated_body)
- expect(note.confidential).to be_truthy
- end
end
context 'when there are ActiveRecord validation errors' do
- let(:updated_body) { '' }
+ let(:params) { { body: '', confidential: true } }
- it_behaves_like 'a mutation that returns errors in the response', errors: ["Note can't be blank"]
+ it_behaves_like 'a mutation that returns errors in the response',
+ errors: ["Note can't be blank", 'Confidential can not be changed for existing notes']
it 'does not update the Note' do
post_graphql_mutation(mutation, current_user: current_user)
diff --git a/spec/requests/api/graphql/mutations/todos/mark_all_done_spec.rb b/spec/requests/api/graphql/mutations/todos/mark_all_done_spec.rb
index 9ac98db91e2..c5c34e16717 100644
--- a/spec/requests/api/graphql/mutations/todos/mark_all_done_spec.rb
+++ b/spec/requests/api/graphql/mutations/todos/mark_all_done_spec.rb
@@ -50,6 +50,37 @@ RSpec.describe 'Marking all todos done' do
expect(updated_todo_ids).to contain_exactly(global_id_of(todo1), global_id_of(todo3))
end
+ context 'when target_id is given', :aggregate_failures do
+ let_it_be(:target) { create(:issue, project: project) }
+ let_it_be(:target_todo1) { create(:todo, user: current_user, author: author, state: :pending, target: target) }
+ let_it_be(:target_todo2) { create(:todo, user: current_user, author: author, state: :pending, target: target) }
+
+ let(:input) { { 'targetId' => target.to_global_id.to_s } }
+
+ it 'marks all pending todos for the target as done' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(target_todo1.reload.state).to eq('done')
+ expect(target_todo2.reload.state).to eq('done')
+
+ expect(todo1.reload.state).to eq('pending')
+ expect(todo3.reload.state).to eq('pending')
+
+ updated_todo_ids = mutation_response['todos'].map { |todo| todo['id'] }
+ expect(updated_todo_ids).to contain_exactly(global_id_of(target_todo1), global_id_of(target_todo2))
+ end
+
+ context 'when target does not exist' do
+ let(:input) { { 'targetId' => "gid://gitlab/Issue/#{non_existing_record_id}" } }
+
+ it 'returns an error' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(graphql_errors).to include(a_hash_including('message' => include('Resource not available')))
+ end
+ end
+ end
+
it 'behaves as expected if there are no todos for the requesting user' do
post_graphql_mutation(mutation, current_user: other_user2)
diff --git a/spec/requests/api/graphql/mutations/user_preferences/update_spec.rb b/spec/requests/api/graphql/mutations/user_preferences/update_spec.rb
index e1c7fd9d60d..85194e6eb20 100644
--- a/spec/requests/api/graphql/mutations/user_preferences/update_spec.rb
+++ b/spec/requests/api/graphql/mutations/user_preferences/update_spec.rb
@@ -28,6 +28,17 @@ RSpec.describe Mutations::UserPreferences::Update do
expect(current_user.user_preference.persisted?).to eq(true)
expect(current_user.user_preference.issues_sort).to eq(Types::IssueSortEnum.values[sort_value].value.to_s)
end
+
+ context 'when incident_escalations feature flag is disabled' do
+ let(:sort_value) { 'ESCALATION_STATUS_ASC' }
+
+ before do
+ stub_feature_flags(incident_escalations: false)
+ end
+
+ it_behaves_like 'a mutation that returns top-level errors',
+ errors: ['Feature flag `incident_escalations` must be enabled to use this sort order.']
+ end
end
context 'when user has existing preference' do
@@ -45,5 +56,16 @@ RSpec.describe Mutations::UserPreferences::Update do
expect(current_user.user_preference.issues_sort).to eq(Types::IssueSortEnum.values[sort_value].value.to_s)
end
+
+ context 'when incident_escalations feature flag is disabled' do
+ let(:sort_value) { 'ESCALATION_STATUS_DESC' }
+
+ before do
+ stub_feature_flags(incident_escalations: false)
+ end
+
+ it_behaves_like 'a mutation that returns top-level errors',
+ errors: ['Feature flag `incident_escalations` must be enabled to use this sort order.']
+ end
end
end
diff --git a/spec/requests/api/graphql_spec.rb b/spec/requests/api/graphql_spec.rb
index da0c87fcefe..3bd59450d49 100644
--- a/spec/requests/api/graphql_spec.rb
+++ b/spec/requests/api/graphql_spec.rb
@@ -15,7 +15,7 @@ RSpec.describe 'GraphQL' do
let(:expected_execute_query_log) do
{
"correlation_id" => kind_of(String),
- "meta.caller_id" => "graphql:anonymous",
+ "meta.caller_id" => "graphql:unknown",
"meta.client_id" => kind_of(String),
"meta.feature_category" => "not_owned",
"meta.remote_ip" => kind_of(String),
diff --git a/spec/requests/api/group_export_spec.rb b/spec/requests/api/group_export_spec.rb
index 31eef21654a..ffa313d4464 100644
--- a/spec/requests/api/group_export_spec.rb
+++ b/spec/requests/api/group_export_spec.rb
@@ -30,76 +30,62 @@ RSpec.describe API::GroupExport do
group.add_owner(user)
end
- context 'group_import_export feature flag enabled' do
+ context 'when export file exists' do
before do
- stub_feature_flags(group_import_export: true)
-
allow(Gitlab::ApplicationRateLimiter)
.to receive(:increment)
.and_return(0)
- end
-
- context 'when export file exists' do
- before do
- upload.export_file = fixture_file_upload('spec/fixtures/group_export.tar.gz', "`/tar.gz")
- upload.save!
- end
- it 'downloads exported group archive' do
- get api(download_path, user)
-
- expect(response).to have_gitlab_http_status(:ok)
- end
+ upload.export_file = fixture_file_upload('spec/fixtures/group_export.tar.gz', "`/tar.gz")
+ upload.save!
+ end
- context 'when export_file.file does not exist' do
- before do
- expect_next_instance_of(ImportExportUploader) do |uploader|
- expect(uploader).to receive(:file).and_return(nil)
- end
- end
+ it 'downloads exported group archive' do
+ get api(download_path, user)
- it 'returns 404' do
- get api(download_path, user)
+ expect(response).to have_gitlab_http_status(:ok)
+ end
- expect(response).to have_gitlab_http_status(:not_found)
+ context 'when export_file.file does not exist' do
+ before do
+ expect_next_instance_of(ImportExportUploader) do |uploader|
+ expect(uploader).to receive(:file).and_return(nil)
end
end
- context 'when object is not present' do
- let(:other_group) { create(:group, :with_export) }
- let(:other_download_path) { "/groups/#{other_group.id}/export/download" }
+ it 'returns 404' do
+ get api(download_path, user)
- before do
- other_group.add_owner(user)
- other_group.export_file.file.delete
- end
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
- it 'returns 404' do
- get api(other_download_path, user)
+ context 'when object is not present' do
+ let(:other_group) { create(:group, :with_export) }
+ let(:other_download_path) { "/groups/#{other_group.id}/export/download" }
- expect(response).to have_gitlab_http_status(:not_found)
- expect(json_response['message']).to eq('The group export file is not available yet')
- end
+ before do
+ other_group.add_owner(user)
+ other_group.export_file.file.delete
end
- end
- context 'when export file does not exist' do
it 'returns 404' do
- get api(download_path, user)
+ get api(other_download_path, user)
expect(response).to have_gitlab_http_status(:not_found)
+ expect(json_response['message']).to eq('The group export file is not available yet')
end
end
end
- context 'group_import_export feature flag disabled' do
- before do
- stub_feature_flags(group_import_export: false)
- end
-
- it 'responds with 404 Not Found' do
+ context 'when export file does not exist' do
+ it 'returns 404' do
get api(download_path, user)
+ allow(Gitlab::ApplicationRateLimiter)
+ .to receive(:increment)
+ .and_return(0)
+
expect(response).to have_gitlab_http_status(:not_found)
end
end
@@ -122,58 +108,40 @@ RSpec.describe API::GroupExport do
end
describe 'POST /groups/:group_id/export' do
- context 'group_import_export feature flag enabled' do
+ context 'when user is a group owner' do
before do
- stub_feature_flags(group_import_export: true)
+ group.add_owner(user)
end
- context 'when user is a group owner' do
- before do
- group.add_owner(user)
- end
-
- it 'accepts download' do
- post api(path, user)
+ it 'accepts download' do
+ post api(path, user)
- expect(response).to have_gitlab_http_status(:accepted)
- end
+ expect(response).to have_gitlab_http_status(:accepted)
end
+ end
- context 'when the export cannot be started' do
- before do
- group.add_owner(user)
- allow(GroupExportWorker).to receive(:perform_async).and_return(nil)
- end
-
- it 'returns an error' do
- post api(path, user)
-
- expect(response).to have_gitlab_http_status(:error)
- end
+ context 'when the export cannot be started' do
+ before do
+ group.add_owner(user)
+ allow(GroupExportWorker).to receive(:perform_async).and_return(nil)
end
- context 'when user is not a group owner' do
- before do
- group.add_developer(user)
- end
-
- it 'forbids the request' do
- post api(path, user)
+ it 'returns an error' do
+ post api(path, user)
- expect(response).to have_gitlab_http_status(:forbidden)
- end
+ expect(response).to have_gitlab_http_status(:error)
end
end
- context 'group_import_export feature flag disabled' do
+ context 'when user is not a group owner' do
before do
- stub_feature_flags(group_import_export: false)
+ group.add_developer(user)
end
- it 'responds with 404 Not Found' do
+ it 'forbids the request' do
post api(path, user)
- expect(response).to have_gitlab_http_status(:not_found)
+ expect(response).to have_gitlab_http_status(:forbidden)
end
end
@@ -202,7 +170,6 @@ RSpec.describe API::GroupExport do
let(:status_path) { "/groups/#{group.id}/export_relations/status" }
before do
- stub_feature_flags(group_import_export: true)
group.add_owner(user)
end
diff --git a/spec/requests/api/groups_spec.rb b/spec/requests/api/groups_spec.rb
index 7de3567dcdd..ffc5d353958 100644
--- a/spec/requests/api/groups_spec.rb
+++ b/spec/requests/api/groups_spec.rb
@@ -1164,17 +1164,47 @@ RSpec.describe API::Groups do
end
context 'when include_subgroups is true' do
- it "returns projects including those in subgroups" do
+ before do
subgroup = create(:group, parent: group1)
+ subgroup2 = create(:group, parent: subgroup)
+
create(:project, group: subgroup)
create(:project, group: subgroup)
+ create(:project, group: subgroup2)
+
+ group1.reload
+ end
+
+ it "only looks up root ancestor once and returns projects including those in subgroups" do
+ expect(Namespace).to receive(:find_by).with(id: group1.id.to_s).once.and_call_original # For the group sent in the API call
+ expect(Namespace).to receive(:find_by).with(id: group1.traversal_ids.first).once.and_call_original # root_ancestor direct lookup
+ expect(Namespace).to receive(:joins).with(start_with('INNER JOIN (SELECT id, traversal_ids[1]')).once.and_call_original # All-in-one root_ancestor query
get api("/groups/#{group1.id}/projects", user1), params: { include_subgroups: true }
expect(response).to have_gitlab_http_status(:ok)
expect(response).to include_pagination_headers
expect(json_response).to be_an(Array)
- expect(json_response.length).to eq(5)
+ expect(json_response.length).to eq(6)
+ end
+
+ context 'when group_projects_api_preload_groups feature is disabled' do
+ before do
+ stub_feature_flags(group_projects_api_preload_groups: false)
+ end
+
+ it 'looks up the root ancestor multiple times' do
+ expect(Namespace).to receive(:find_by).with(id: group1.id.to_s).once.and_call_original
+ expect(Namespace).to receive(:find_by).with(id: group1.traversal_ids.first).at_least(:twice).and_call_original
+ expect(Namespace).not_to receive(:joins).with(start_with('INNER JOIN (SELECT id, traversal_ids[1]'))
+
+ get api("/groups/#{group1.id}/projects", user1), params: { include_subgroups: true }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an(Array)
+ expect(json_response.length).to eq(6)
+ end
end
end
diff --git a/spec/requests/api/integrations_spec.rb b/spec/requests/api/integrations_spec.rb
index 220c58afbe9..96cc101e73a 100644
--- a/spec/requests/api/integrations_spec.rb
+++ b/spec/requests/api/integrations_spec.rb
@@ -55,7 +55,7 @@ RSpec.describe API::Integrations do
describe "PUT /projects/:id/#{endpoint}/#{integration.dasherize}" do
include_context integration
- it "updates #{integration} settings" do
+ it "updates #{integration} settings and returns the correct fields" do
put api("/projects/#{project.id}/#{endpoint}/#{dashed_integration}", user), params: integration_attrs
expect(response).to have_gitlab_http_status(:ok)
@@ -80,6 +80,8 @@ RSpec.describe API::Integrations do
expect(project.integrations.first[event]).not_to eq(current_integration[event]),
"expected #{!current_integration[event]} for event #{event} for #{endpoint} #{current_integration.title}, got #{current_integration[event]}"
end
+
+ assert_correct_response_fields(json_response['properties'].keys, current_integration)
end
it "returns if required fields missing" do
@@ -142,22 +144,24 @@ RSpec.describe API::Integrations do
expect(response).to have_gitlab_http_status(:unauthorized)
end
- it "returns all properties of active integration #{integration}" do
+ it "returns all properties of active integration #{integration}, except password fields" do
get api("/projects/#{project.id}/#{endpoint}/#{dashed_integration}", user)
expect(initialized_integration).to be_active
expect(response).to have_gitlab_http_status(:ok)
- expect(json_response['properties'].keys).to match_array(integration_instance.api_field_names)
+
+ assert_correct_response_fields(json_response['properties'].keys, integration_instance)
end
- it "returns all properties of inactive integration #{integration}" do
+ it "returns all properties of inactive integration #{integration}, except password fields" do
deactive_integration!
get api("/projects/#{project.id}/#{endpoint}/#{dashed_integration}", user)
expect(initialized_integration).not_to be_active
expect(response).to have_gitlab_http_status(:ok)
- expect(json_response['properties'].keys).to match_array(integration_instance.api_field_names)
+
+ assert_correct_response_fields(json_response['properties'].keys, integration_instance)
end
it "returns not found if integration does not exist" do
@@ -369,5 +373,20 @@ RSpec.describe API::Integrations do
end
end
end
+
+ private
+
+ def assert_correct_response_fields(response_keys, integration)
+ assert_fields_match_integration(response_keys, integration)
+ assert_secret_fields_filtered(response_keys, integration)
+ end
+
+ def assert_fields_match_integration(response_keys, integration)
+ expect(response_keys).to match_array(integration.api_field_names)
+ end
+
+ def assert_secret_fields_filtered(response_keys, integration)
+ expect(response_keys).not_to include(*integration.secret_fields)
+ end
end
end
diff --git a/spec/requests/api/internal/container_registry/migration_spec.rb b/spec/requests/api/internal/container_registry/migration_spec.rb
index 27e99a21c65..35113c66f11 100644
--- a/spec/requests/api/internal/container_registry/migration_spec.rb
+++ b/spec/requests/api/internal/container_registry/migration_spec.rb
@@ -67,12 +67,17 @@ RSpec.describe API::Internal::ContainerRegistry::Migration do
it_behaves_like 'returning an error', with_message: "Couldn't transition from pre_importing to importing"
end
- end
- context 'with repository in importing migration state' do
- let(:repository) { create(:container_repository, :importing) }
+ context 'with repository in importing migration state' do
+ let(:repository) { create(:container_repository, :importing) }
+
+ it 'returns ok and does not update the migration state' do
+ expect { subject }
+ .not_to change { repository.reload.migration_state }
- it_behaves_like 'returning an error', with_message: "Couldn't transition from pre_importing to importing"
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
end
end
@@ -101,7 +106,7 @@ RSpec.describe API::Internal::ContainerRegistry::Migration do
context 'with repository in pre_importing migration state' do
let(:repository) { create(:container_repository, :pre_importing) }
- it_behaves_like 'returning an error', with_message: "Couldn't transition from importing to import_done"
+ it_behaves_like 'updating the repository migration status', from: 'pre_importing', to: 'import_done'
end
end
diff --git a/spec/requests/api/invitations_spec.rb b/spec/requests/api/invitations_spec.rb
index 741cf793a77..d093894720e 100644
--- a/spec/requests/api/invitations_spec.rb
+++ b/spec/requests/api/invitations_spec.rb
@@ -69,7 +69,7 @@ RSpec.describe API::Invitations do
end
end
- it 'invites a new member' do
+ it 'adds a new member by email' do
expect do
post invitations_url(source, maintainer),
params: { email: email, access_level: Member::DEVELOPER }
@@ -78,6 +78,24 @@ RSpec.describe API::Invitations do
end.to change { source.members.invite.count }.by(1)
end
+ it 'adds a new member by user_id' do
+ expect do
+ post invitations_url(source, maintainer),
+ params: { user_id: stranger.id, access_level: Member::DEVELOPER }
+
+ expect(response).to have_gitlab_http_status(:created)
+ end.to change { source.members.non_invite.count }.by(1)
+ end
+
+ it 'adds new members with email and user_id' do
+ expect do
+ post invitations_url(source, maintainer),
+ params: { email: email, user_id: stranger.id, access_level: Member::DEVELOPER }
+
+ expect(response).to have_gitlab_http_status(:created)
+ end.to change { source.members.invite.count }.by(1).and change { source.members.non_invite.count }.by(1)
+ end
+
it 'invites a list of new email addresses' do
expect do
email_list = [email, email2].join(',')
@@ -88,6 +106,19 @@ RSpec.describe API::Invitations do
expect(response).to have_gitlab_http_status(:created)
end.to change { source.members.invite.count }.by(2)
end
+
+ it 'invites a list of new email addresses and user ids' do
+ expect do
+ stranger2 = create(:user)
+ email_list = [email, email2].join(',')
+ user_id_list = "#{stranger.id},#{stranger2.id}"
+
+ post invitations_url(source, maintainer),
+ params: { email: email_list, user_id: user_id_list, access_level: Member::DEVELOPER }
+
+ expect(response).to have_gitlab_http_status(:created)
+ end.to change { source.members.invite.count }.by(2).and change { source.members.non_invite.count }.by(2)
+ end
end
context 'access levels' do
@@ -235,27 +266,36 @@ RSpec.describe API::Invitations do
expect(json_response['message'][developer.email]).to eq("User already exists in source")
end
- it 'returns 404 when the email is not valid' do
+ it 'returns 400 when the invite params of email and user_id are not sent' do
+ post invitations_url(source, maintainer),
+ params: { access_level: Member::MAINTAINER }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['message']).to eq('400 Bad request - Must provide either email or user_id as a parameter')
+ end
+
+ it 'returns 400 when the email is blank' do
post invitations_url(source, maintainer),
params: { email: '', access_level: Member::MAINTAINER }
- expect(response).to have_gitlab_http_status(:created)
- expect(json_response['message']).to eq('Emails cannot be blank')
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['message']).to eq('400 Bad request - Must provide either email or user_id as a parameter')
end
- it 'returns 404 when the email list is not a valid format' do
+ it 'returns 400 when the user_id is blank' do
post invitations_url(source, maintainer),
- params: { email: 'email1@example.com,not-an-email', access_level: Member::MAINTAINER }
+ params: { user_id: '', access_level: Member::MAINTAINER }
expect(response).to have_gitlab_http_status(:bad_request)
- expect(json_response['error']).to eq('email contains an invalid email address')
+ expect(json_response['message']).to eq('400 Bad request - Must provide either email or user_id as a parameter')
end
- it 'returns 400 when email is not given' do
+ it 'returns 400 when the email list is not a valid format' do
post invitations_url(source, maintainer),
- params: { access_level: Member::MAINTAINER }
+ params: { email: 'email1@example.com,not-an-email', access_level: Member::MAINTAINER }
expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['error']).to eq('email contains an invalid email address')
end
it 'returns 400 when access_level is not given' do
@@ -278,12 +318,90 @@ RSpec.describe API::Invitations do
it_behaves_like 'POST /:source_type/:id/invitations', 'project' do
let(:source) { project }
end
+
+ it 'records queries', :request_store, :use_sql_query_cache do
+ post invitations_url(project, maintainer), params: { email: email, access_level: Member::DEVELOPER }
+
+ control = ActiveRecord::QueryRecorder.new(skip_cached: false) do
+ post invitations_url(project, maintainer), params: { email: email2, access_level: Member::DEVELOPER }
+ end
+
+ emails = 'email3@example.com,email4@example.com,email5@example.com,email6@example.com,email7@example.com'
+
+ unresolved_n_plus_ones = 44 # old 48 with 12 per new email, currently there are 11 queries added per email
+
+ expect do
+ post invitations_url(project, maintainer), params: { email: emails, access_level: Member::DEVELOPER }
+ end.not_to exceed_all_query_limit(control.count).with_threshold(unresolved_n_plus_ones)
+ end
+
+ it 'records queries with secondary emails', :request_store, :use_sql_query_cache do
+ create(:email, email: email, user: create(:user))
+
+ post invitations_url(project, maintainer), params: { email: email, access_level: Member::DEVELOPER }
+
+ create(:email, email: email2, user: create(:user))
+
+ control = ActiveRecord::QueryRecorder.new(skip_cached: false) do
+ post invitations_url(project, maintainer), params: { email: email2, access_level: Member::DEVELOPER }
+ end
+
+ create(:email, email: 'email4@example.com', user: create(:user))
+ create(:email, email: 'email6@example.com', user: create(:user))
+
+ emails = 'email3@example.com,email4@example.com,email5@example.com,email6@example.com,email7@example.com'
+
+ unresolved_n_plus_ones = 67 # currently there are 11 queries added per email
+
+ expect do
+ post invitations_url(project, maintainer), params: { email: emails, access_level: Member::DEVELOPER }
+ end.not_to exceed_all_query_limit(control.count).with_threshold(unresolved_n_plus_ones)
+ end
end
describe 'POST /groups/:id/invitations' do
it_behaves_like 'POST /:source_type/:id/invitations', 'group' do
let(:source) { group }
end
+
+ it 'records queries', :request_store, :use_sql_query_cache do
+ post invitations_url(group, maintainer), params: { email: email, access_level: Member::DEVELOPER }
+
+ control = ActiveRecord::QueryRecorder.new(skip_cached: false) do
+ post invitations_url(group, maintainer), params: { email: email2, access_level: Member::DEVELOPER }
+ end
+
+ emails = 'email3@example.com,email4@example.com,email5@example.com,email6@example.com,email7@example.com'
+
+ unresolved_n_plus_ones = 36 # old 40 with 10 per new email, currently there are 9 queries added per email
+
+ expect do
+ post invitations_url(group, maintainer), params: { email: emails, access_level: Member::DEVELOPER }
+ end.not_to exceed_all_query_limit(control.count).with_threshold(unresolved_n_plus_ones)
+ end
+
+ it 'records queries with secondary emails', :request_store, :use_sql_query_cache do
+ create(:email, email: email, user: create(:user))
+
+ post invitations_url(group, maintainer), params: { email: email, access_level: Member::DEVELOPER }
+
+ create(:email, email: email2, user: create(:user))
+
+ control = ActiveRecord::QueryRecorder.new(skip_cached: false) do
+ post invitations_url(group, maintainer), params: { email: email2, access_level: Member::DEVELOPER }
+ end
+
+ create(:email, email: 'email4@example.com', user: create(:user))
+ create(:email, email: 'email6@example.com', user: create(:user))
+
+ emails = 'email3@example.com,email4@example.com,email5@example.com,email6@example.com,email7@example.com'
+
+ unresolved_n_plus_ones = 62 # currently there are 9 queries added per email
+
+ expect do
+ post invitations_url(group, maintainer), params: { email: emails, access_level: Member::DEVELOPER }
+ end.not_to exceed_all_query_limit(control.count).with_threshold(unresolved_n_plus_ones)
+ end
end
shared_examples 'GET /:source_type/:id/invitations' do |source_type|
@@ -315,23 +433,6 @@ RSpec.describe API::Invitations do
end
end
- it 'avoids N+1 queries' do
- invite_member_by_email(source, source_type, email, maintainer)
-
- # Establish baseline
- get invitations_url(source, maintainer)
-
- control = ActiveRecord::QueryRecorder.new do
- get invitations_url(source, maintainer)
- end
-
- invite_member_by_email(source, source_type, email2, maintainer)
-
- expect do
- get invitations_url(source, maintainer)
- end.not_to exceed_query_limit(control)
- end
-
it 'does not find confirmed members' do
get invitations_url(source, maintainer)
diff --git a/spec/requests/api/issue_links_spec.rb b/spec/requests/api/issue_links_spec.rb
index 45583f5c7dc..81dd4c3dfa0 100644
--- a/spec/requests/api/issue_links_spec.rb
+++ b/spec/requests/api/issue_links_spec.rb
@@ -34,7 +34,7 @@ RSpec.describe API::IssueLinks do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_an Array
expect(json_response.length).to eq(2)
- expect(response).to match_response_schema('public_api/v4/issue_links')
+ expect(response).to match_response_schema('public_api/v4/related_issues')
end
it 'returns multiple links without N + 1' do
@@ -205,16 +205,30 @@ RSpec.describe API::IssueLinks do
end
context 'when user has ability to delete the issue link' do
+ let_it_be(:target_issue) { create(:issue, project: project) }
+
+ before do
+ project.add_reporter(user)
+ end
+
it 'returns 200' do
- target_issue = create(:issue, project: project)
issue_link = create(:issue_link, source: issue, target: target_issue)
- project.add_reporter(user)
delete api("/projects/#{project.id}/issues/#{issue.iid}/links/#{issue_link.id}", user)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('public_api/v4/issue_link')
end
+
+ it 'returns 404 when the issue link does not belong to the specified issue' do
+ other_issue = create(:issue, project: project)
+ issue_link = create(:issue_link, source: other_issue, target: target_issue)
+
+ delete api("/projects/#{project.id}/issues/#{issue.iid}/links/#{issue_link.id}", user)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(json_response['message']).to eq('404 Not found')
+ end
end
end
end
diff --git a/spec/requests/api/issues/get_project_issues_spec.rb b/spec/requests/api/issues/get_project_issues_spec.rb
index 9948e13e9ae..346f8975835 100644
--- a/spec/requests/api/issues/get_project_issues_spec.rb
+++ b/spec/requests/api/issues/get_project_issues_spec.rb
@@ -877,5 +877,35 @@ RSpec.describe API::Issues do
expect(response).to have_gitlab_http_status(:not_found)
end
+
+ context 'with a confidential note' do
+ let!(:note) do
+ create(
+ :note,
+ :confidential,
+ project: project,
+ noteable: issue,
+ author: create(:user)
+ )
+ end
+
+ it 'returns a full list of participants' do
+ get api("/projects/#{project.id}/issues/#{issue.iid}/participants", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ participant_ids = json_response.map { |el| el['id'] }
+ expect(participant_ids).to match_array([issue.author_id, note.author_id])
+ end
+
+ context 'when user cannot see a confidential note' do
+ it 'returns a limited list of participants' do
+ get api("/projects/#{project.id}/issues/#{issue.iid}/participants", create(:user))
+
+ expect(response).to have_gitlab_http_status(:ok)
+ participant_ids = json_response.map { |el| el['id'] }
+ expect(participant_ids).to match_array([issue.author_id])
+ end
+ end
+ end
end
end
diff --git a/spec/requests/api/issues/issues_spec.rb b/spec/requests/api/issues/issues_spec.rb
index c5e57b5b18b..1419d39981a 100644
--- a/spec/requests/api/issues/issues_spec.rb
+++ b/spec/requests/api/issues/issues_spec.rb
@@ -554,6 +554,27 @@ RSpec.describe API::Issues do
end
end
+ context 'with incident issues' do
+ let_it_be(:incident) { create(:incident, project: project) }
+
+ it 'avoids N+1 queries' do
+ get api('/issues', user) # warm up
+
+ control = ActiveRecord::QueryRecorder.new do
+ get api('/issues', user)
+ end
+
+ create(:incident, project: project)
+ create(:incident, project: project)
+
+ expect do
+ get api('/issues', user)
+ end.not_to exceed_query_limit(control)
+ # 2 pre-existed issues + 3 incidents
+ expect(json_response.count).to eq(5)
+ end
+ end
+
context 'filter by labels or label_name param' do
context 'N+1' do
let(:label_b) { create(:label, title: 'foo', project: project) }
diff --git a/spec/requests/api/keys_spec.rb b/spec/requests/api/keys_spec.rb
index 49b8f4a8520..67c3de324dc 100644
--- a/spec/requests/api/keys_spec.rb
+++ b/spec/requests/api/keys_spec.rb
@@ -3,10 +3,11 @@
require 'spec_helper'
RSpec.describe API::Keys do
- let(:user) { create(:user) }
- let(:admin) { create(:admin) }
- let(:key) { create(:key, user: user, expires_at: 1.day.from_now) }
- let(:email) { create(:email, user: user) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:admin) { create(:admin) }
+ let_it_be(:email) { create(:email, user: user) }
+ let_it_be(:key) { create(:rsa_key_4096, user: user, expires_at: 1.day.from_now) }
+ let_it_be(:fingerprint_md5) { 'df:73:db:29:3c:a5:32:cf:09:17:7e:8e:9d:de:d7:f7' }
describe 'GET /keys/:uid' do
context 'when unauthenticated' do
@@ -24,7 +25,6 @@ RSpec.describe API::Keys do
end
it 'returns single ssh key with user information' do
- user.keys << key
get api("/keys/#{key.id}", admin)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['title']).to eq(key.title)
@@ -43,23 +43,50 @@ RSpec.describe API::Keys do
describe 'GET /keys?fingerprint=' do
it 'returns authentication error' do
- get api("/keys?fingerprint=#{key.fingerprint}")
+ get api("/keys?fingerprint=#{fingerprint_md5}")
expect(response).to have_gitlab_http_status(:unauthorized)
end
it 'returns authentication error when authenticated as user' do
- get api("/keys?fingerprint=#{key.fingerprint}", user)
+ get api("/keys?fingerprint=#{fingerprint_md5}", user)
expect(response).to have_gitlab_http_status(:forbidden)
end
context 'when authenticated as admin' do
- it 'returns 404 for non-existing SSH md5 fingerprint' do
- get api("/keys?fingerprint=11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11", admin)
+ context 'MD5 fingerprint' do
+ it 'returns 404 for non-existing SSH md5 fingerprint' do
+ get api("/keys?fingerprint=11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11", admin)
- expect(response).to have_gitlab_http_status(:not_found)
- expect(json_response['message']).to eq('404 Key Not Found')
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(json_response['message']).to eq('404 Key Not Found')
+ end
+
+ it 'returns user if SSH md5 fingerprint found' do
+ get api("/keys?fingerprint=#{fingerprint_md5}", admin)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['title']).to eq(key.title)
+ expect(json_response['user']['id']).to eq(user.id)
+ expect(json_response['user']['username']).to eq(user.username)
+ end
+
+ context 'with FIPS mode', :fips_mode do
+ it 'returns 404 for non-existing SSH md5 fingerprint' do
+ get api("/keys?fingerprint=11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11", admin)
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['message']).to eq('Failed to return the key')
+ end
+
+ it 'returns 404 for existing SSH md5 fingerprint' do
+ get api("/keys?fingerprint=#{fingerprint_md5}", admin)
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['message']).to eq('Failed to return the key')
+ end
+ end
end
it 'returns 404 for non-existing SSH sha256 fingerprint' do
@@ -69,20 +96,7 @@ RSpec.describe API::Keys do
expect(json_response['message']).to eq('404 Key Not Found')
end
- it 'returns user if SSH md5 fingerprint found' do
- user.keys << key
-
- get api("/keys?fingerprint=#{key.fingerprint}", admin)
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response['title']).to eq(key.title)
- expect(json_response['user']['id']).to eq(user.id)
- expect(json_response['user']['username']).to eq(user.username)
- end
-
it 'returns user if SSH sha256 fingerprint found' do
- user.keys << key
-
get api("/keys?fingerprint=#{URI.encode_www_form_component("SHA256:" + key.fingerprint_sha256)}", admin)
expect(response).to have_gitlab_http_status(:ok)
@@ -92,8 +106,6 @@ RSpec.describe API::Keys do
end
it 'returns user if SSH sha256 fingerprint found' do
- user.keys << key
-
get api("/keys?fingerprint=#{URI.encode_www_form_component("sha256:" + key.fingerprint_sha256)}", admin)
expect(response).to have_gitlab_http_status(:ok)
@@ -103,7 +115,7 @@ RSpec.describe API::Keys do
end
it "does not include the user's `is_admin` flag" do
- get api("/keys?fingerprint=#{key.fingerprint}", admin)
+ get api("/keys?fingerprint=#{URI.encode_www_form_component("sha256:" + key.fingerprint_sha256)}", admin)
expect(json_response['user']['is_admin']).to be_nil
end
diff --git a/spec/requests/api/lint_spec.rb b/spec/requests/api/lint_spec.rb
index 73bc4a5d1f3..ef7f5ee87dc 100644
--- a/spec/requests/api/lint_spec.rb
+++ b/spec/requests/api/lint_spec.rb
@@ -195,7 +195,7 @@ RSpec.describe API::Lint do
end
context 'with invalid configuration' do
- let(:yaml_content) { '{ image: "ruby:2.7", services: ["postgres"] }' }
+ let(:yaml_content) { '{ image: "image:1.0", services: ["postgres"] }' }
it 'responds with errors about invalid configuration' do
post api('/ci/lint', api_user), params: { content: yaml_content }
@@ -465,7 +465,7 @@ RSpec.describe API::Lint do
context 'with invalid .gitlab-ci.yml content' do
let(:yaml_content) do
- { image: 'ruby:2.7', services: ['postgres'] }.deep_stringify_keys.to_yaml
+ { image: 'image:1.0', services: ['postgres'] }.deep_stringify_keys.to_yaml
end
before do
@@ -712,7 +712,7 @@ RSpec.describe API::Lint do
context 'with invalid .gitlab-ci.yml content' do
let(:yaml_content) do
- { image: 'ruby:2.7', services: ['postgres'] }.deep_stringify_keys.to_yaml
+ { image: 'image:1.0', services: ['postgres'] }.deep_stringify_keys.to_yaml
end
context 'when running as dry run' do
diff --git a/spec/requests/api/members_spec.rb b/spec/requests/api/members_spec.rb
index 561d81f9860..6bacb3a59b2 100644
--- a/spec/requests/api/members_spec.rb
+++ b/spec/requests/api/members_spec.rb
@@ -11,16 +11,16 @@ RSpec.describe API::Members do
let(:project) do
create(:project, :public, creator_id: maintainer.id, namespace: maintainer.namespace) do |project|
- project.add_developer(developer)
project.add_maintainer(maintainer)
+ project.add_developer(developer, current_user: maintainer)
project.request_access(access_requester)
end
end
let!(:group) do
create(:group, :public) do |group|
- group.add_developer(developer)
group.add_owner(maintainer)
+ group.add_developer(developer, maintainer)
create(:group_member, :minimal_access, source: group, user: user_with_minimal_access)
group.request_access(access_requester)
end
@@ -50,6 +50,10 @@ RSpec.describe API::Members do
expect(json_response).to be_an Array
expect(json_response.size).to eq(2)
expect(json_response.map { |u| u['id'] }).to match_array [maintainer.id, developer.id]
+ expect(json_response).to contain_exactly(
+ a_hash_including('created_by' => a_hash_including('id' => maintainer.id)),
+ hash_not_including('created_by')
+ )
end
end
end
diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb
index 9e6fea9e5b4..b1183bb10fa 100644
--- a/spec/requests/api/merge_requests_spec.rb
+++ b/spec/requests/api/merge_requests_spec.rb
@@ -1605,11 +1605,7 @@ RSpec.describe API::MergeRequests do
expect(json_response['overflow']).to be_falsy
end
- context 'when using DB-backed diffs via feature flag' do
- before do
- stub_feature_flags(mrc_api_use_raw_diffs_from_gitaly: false)
- end
-
+ context 'when using DB-backed diffs' do
it_behaves_like 'find an existing merge request'
it 'accesses diffs via DB-backed diffs.diffs' do
diff --git a/spec/requests/api/notes_spec.rb b/spec/requests/api/notes_spec.rb
index 455400072bf..f6a65274ca2 100644
--- a/spec/requests/api/notes_spec.rb
+++ b/spec/requests/api/notes_spec.rb
@@ -22,7 +22,7 @@ RSpec.describe API::Notes do
let!(:issue) { create(:issue, project: project, author: user) }
let!(:issue_note) { create(:note, noteable: issue, project: project, author: user) }
- it_behaves_like "noteable API", 'projects', 'issues', 'iid' do
+ it_behaves_like "noteable API with confidential notes", 'projects', 'issues', 'iid' do
let(:parent) { project }
let(:noteable) { issue }
let(:note) { issue_note }
diff --git a/spec/requests/api/project_attributes.yml b/spec/requests/api/project_attributes.yml
index 02d377efd95..fbcaa404edb 100644
--- a/spec/requests/api/project_attributes.yml
+++ b/spec/requests/api/project_attributes.yml
@@ -11,8 +11,6 @@ itself: # project
- has_external_wiki
- hidden
- import_source
- - import_type
- - import_url
- jobs_cache_index
- last_repository_check_at
- last_repository_check_failed
@@ -63,6 +61,8 @@ itself: # project
- empty_repo
- forks_count
- http_url_to_repo
+ - import_status
+ - import_url
- name_with_namespace
- open_issues_count
- owner
@@ -148,6 +148,7 @@ project_setting:
- updated_at
- cve_id_request_enabled
- mr_default_target_self
+ - target_platforms
build_service_desk_setting: # service_desk_setting
unexposed_attributes:
diff --git a/spec/requests/api/project_export_spec.rb b/spec/requests/api/project_export_spec.rb
index 2bc31153f2c..07efd56fef4 100644
--- a/spec/requests/api/project_export_spec.rb
+++ b/spec/requests/api/project_export_spec.rb
@@ -260,6 +260,29 @@ RSpec.describe API::ProjectExport, :clean_gitlab_redis_cache do
expect(json_response['message']['error']).to eq('This endpoint has been requested too many times. Try again later.')
end
end
+
+ context 'applies correct scope when throttling' do
+ before do
+ stub_application_setting(project_download_export_limit: 1)
+ end
+
+ it 'throttles downloads within same namespaces' do
+ # simulate prior request to the same namespace, which increments the rate limit counter for that scope
+ Gitlab::ApplicationRateLimiter.throttled?(:project_download_export, scope: [user, project_finished.namespace])
+
+ get api(download_path_finished, user)
+ expect(response).to have_gitlab_http_status(:too_many_requests)
+ end
+
+ it 'allows downloads from different namespaces' do
+ # simulate prior request to a different namespace, which increments the rate limit counter for that scope
+ Gitlab::ApplicationRateLimiter.throttled?(:project_download_export,
+ scope: [user, create(:project, :with_export).namespace])
+
+ get api(download_path_finished, user)
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
end
context 'when user is a maintainer' do
diff --git a/spec/requests/api/project_import_spec.rb b/spec/requests/api/project_import_spec.rb
index a0f6d3d0081..7e6d80c047c 100644
--- a/spec/requests/api/project_import_spec.rb
+++ b/spec/requests/api/project_import_spec.rb
@@ -13,7 +13,7 @@ RSpec.describe API::ProjectImport, :aggregate_failures do
let(:namespace) { create(:group) }
before do
- namespace.add_owner(user)
+ namespace.add_owner(user) if user
end
shared_examples 'requires authentication' do
@@ -47,7 +47,7 @@ RSpec.describe API::ProjectImport, :aggregate_failures do
it 'executes a limited number of queries' do
control_count = ActiveRecord::QueryRecorder.new { subject }.count
- expect(control_count).to be <= 105
+ expect(control_count).to be <= 108
end
it 'schedules an import using a namespace' do
@@ -306,63 +306,49 @@ RSpec.describe API::ProjectImport, :aggregate_failures do
it_behaves_like 'requires authentication'
- it 'returns NOT FOUND when the feature is disabled' do
- stub_feature_flags(import_project_from_remote_file: false)
-
- subject
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
-
- context 'when the feature flag is enabled' do
- before do
- stub_feature_flags(import_project_from_remote_file: true)
- end
-
- context 'when the response is successful' do
- it 'schedules the import successfully' do
- project = create(
- :project,
- namespace: user.namespace,
- name: 'test-import',
- path: 'test-import'
- )
+ context 'when the response is successful' do
+ it 'schedules the import successfully' do
+ project = create(
+ :project,
+ namespace: user.namespace,
+ name: 'test-import',
+ path: 'test-import'
+ )
- service_response = ServiceResponse.success(payload: project)
- expect_next(::Import::GitlabProjects::CreateProjectService)
- .to receive(:execute)
- .and_return(service_response)
+ service_response = ServiceResponse.success(payload: project)
+ expect_next(::Import::GitlabProjects::CreateProjectService)
+ .to receive(:execute)
+ .and_return(service_response)
- subject
+ subject
- expect(response).to have_gitlab_http_status(:created)
- expect(json_response).to include({
- 'id' => project.id,
- 'name' => 'test-import',
- 'name_with_namespace' => "#{user.namespace.name} / test-import",
- 'path' => 'test-import',
- 'path_with_namespace' => "#{user.namespace.path}/test-import"
- })
- end
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response).to include({
+ 'id' => project.id,
+ 'name' => 'test-import',
+ 'name_with_namespace' => "#{user.namespace.name} / test-import",
+ 'path' => 'test-import',
+ 'path_with_namespace' => "#{user.namespace.path}/test-import"
+ })
end
+ end
- context 'when the service returns an error' do
- it 'fails to schedule the import' do
- service_response = ServiceResponse.error(
- message: 'Failed to import',
- http_status: :bad_request
- )
- expect_next(::Import::GitlabProjects::CreateProjectService)
- .to receive(:execute)
- .and_return(service_response)
+ context 'when the service returns an error' do
+ it 'fails to schedule the import' do
+ service_response = ServiceResponse.error(
+ message: 'Failed to import',
+ http_status: :bad_request
+ )
+ expect_next(::Import::GitlabProjects::CreateProjectService)
+ .to receive(:execute)
+ .and_return(service_response)
- subject
+ subject
- expect(response).to have_gitlab_http_status(:bad_request)
- expect(json_response).to eq({
- 'message' => 'Failed to import'
- })
- end
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response).to eq({
+ 'message' => 'Failed to import'
+ })
end
end
end
diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb
index fc1d815a64e..011300a038f 100644
--- a/spec/requests/api/projects_spec.rb
+++ b/spec/requests/api/projects_spec.rb
@@ -683,6 +683,33 @@ RSpec.describe API::Projects do
end
end
+ context 'and imported=true' do
+ before do
+ other_user = create(:user)
+ # imported project by other user
+ create(:project, creator: other_user, import_type: 'github', import_url: 'http://foo.com')
+ # project created by current user directly instead of importing
+ create(:project)
+ project.update_attribute(:import_url, 'http://user:password@host/path')
+ project.update_attribute(:import_type, 'github')
+ end
+
+ it 'returns only imported projects owned by current user' do
+ get api('/projects?imported=true', user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.map { |p| p['id'] }).to eq [project.id]
+ end
+
+ it 'does not expose import credentials' do
+ get api('/projects?imported=true', user)
+
+ expect(json_response.first['import_url']).to eq 'http://host/path'
+ end
+ end
+
context 'when authenticated as a different user' do
it_behaves_like 'projects response' do
let(:filter) { {} }
@@ -777,7 +804,7 @@ RSpec.describe API::Projects do
subject { get api('/projects', current_user), params: params }
before do
- group_with_projects.add_owner(current_user)
+ group_with_projects.add_owner(current_user) if current_user
end
it 'returns non-public items based ordered by similarity' do
@@ -3116,6 +3143,29 @@ RSpec.describe API::Projects do
project2.add_developer(project2_user)
end
+ it 'records the query', :request_store, :use_sql_query_cache do
+ post api("/projects/#{project.id}/import_project_members/#{project2.id}", user)
+
+ control_project = create(:project)
+ control_project.add_maintainer(user)
+ control_project.add_developer(create(:user))
+
+ control = ActiveRecord::QueryRecorder.new(skip_cached: false) do
+ post api("/projects/#{project.id}/import_project_members/#{control_project.id}", user)
+ end
+
+ measure_project = create(:project)
+ measure_project.add_maintainer(user)
+ measure_project.add_developer(create(:user))
+ measure_project.add_developer(create(:user)) # make this 2nd one to find any n+1
+
+ unresolved_n_plus_ones = 21 # 21 queries added per member
+
+ expect do
+ post api("/projects/#{project.id}/import_project_members/#{measure_project.id}", user)
+ end.not_to exceed_all_query_limit(control.count).with_threshold(unresolved_n_plus_ones)
+ end
+
it 'returns 200 when it successfully imports members from another project' do
expect do
post api("/projects/#{project.id}/import_project_members/#{project2.id}", user)
diff --git a/spec/requests/api/releases_spec.rb b/spec/requests/api/releases_spec.rb
index 6038682de1e..c6bf72176a8 100644
--- a/spec/requests/api/releases_spec.rb
+++ b/spec/requests/api/releases_spec.rb
@@ -1358,4 +1358,95 @@ RSpec.describe API::Releases do
release_cli: release_cli
)
end
+
+ describe 'GET /groups/:id/releases' do
+ let_it_be(:user1) { create(:user, can_create_group: false) }
+ let_it_be(:admin) { create(:admin) }
+ let_it_be(:group1) { create(:group) }
+ let_it_be(:group2) { create(:group, :private) }
+ let_it_be(:project1) { create(:project, namespace: group1) }
+ let_it_be(:project2) { create(:project, namespace: group2) }
+ let_it_be(:project3) { create(:project, namespace: group1, path: 'test', visibility_level: Gitlab::VisibilityLevel::PRIVATE) }
+ let_it_be(:release1) { create(:release, project: project1) }
+ let_it_be(:release2) { create(:release, project: project2) }
+ let_it_be(:release3) { create(:release, project: project3) }
+
+ context 'when authenticated as owner' do
+ it 'gets releases from all projects in the group' do
+ get api("/groups/#{group1.id}/releases", admin)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response.length).to eq(2)
+ expect(json_response.pluck('name')).to match_array([release1.name, release3.name])
+ end
+
+ it 'respects order by parameters' do
+ create(:release, project: project1, released_at: DateTime.now + 1.day)
+ get api("/groups/#{group1.id}/releases", admin), params: { sort: 'desc' }
+
+ expect(DateTime.parse(json_response[0]["released_at"]))
+ .to be > (DateTime.parse(json_response[1]["released_at"]))
+ end
+
+ it 'respects the simple parameter' do
+ get api("/groups/#{group1.id}/releases", admin), params: { simple: true }
+
+ expect(json_response[0].keys).not_to include("assets")
+ end
+
+ it 'denies access to private groups' do
+ get api("/groups/#{group2.id}/releases", user1), params: { simple: true }
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+
+ it 'returns not found unless :group_releases_finder_inoperator feature flag enabled' do
+ stub_feature_flags(group_releases_finder_inoperator: false)
+
+ get api("/groups/#{group1.id}/releases", admin)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'when authenticated as guest' do
+ before do
+ group1.add_guest(guest)
+ end
+
+ it "does not expose tag, commit, source code or helper paths" do
+ get api("/groups/#{group1.id}/releases", guest)
+
+ expect(response).to match_response_schema('public_api/v4/release/releases_for_guest')
+ expect(json_response[0]['assets']['count']).to eq(release1.links.count)
+ expect(json_response[0]['commit_path']).to be_nil
+ expect(json_response[0]['tag_path']).to be_nil
+ end
+ end
+
+ context 'performance testing' do
+ shared_examples 'avoids N+1 queries' do |query_params = {}|
+ context 'with subgroups' do
+ let(:group) { create(:group) }
+
+ it 'include_subgroups avoids N+1 queries' do
+ control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do
+ get api("/groups/#{group.id}/releases", admin), params: query_params.merge({ include_subgroups: true })
+ end.count
+
+ subgroups = create_list(:group, 10, parent: group1)
+ projects = create_list(:project, 10, namespace: subgroups[0])
+ create_list(:release, 10, project: projects[0], author: admin)
+
+ expect do
+ get api("/groups/#{group.id}/releases", admin), params: query_params.merge({ include_subgroups: true })
+ end.not_to exceed_all_query_limit(control_count)
+ end
+ end
+ end
+
+ it_behaves_like 'avoids N+1 queries'
+ it_behaves_like 'avoids N+1 queries', { simple: true }
+ end
+ end
end
diff --git a/spec/requests/api/remote_mirrors_spec.rb b/spec/requests/api/remote_mirrors_spec.rb
index 436efb708fd..338647224e0 100644
--- a/spec/requests/api/remote_mirrors_spec.rb
+++ b/spec/requests/api/remote_mirrors_spec.rb
@@ -26,6 +26,26 @@ RSpec.describe API::RemoteMirrors do
end
end
+ describe 'GET /projects/:id/remote_mirrors/:mirror_id' do
+ let(:route) { "/projects/#{project.id}/remote_mirrors/#{mirror.id}" }
+ let(:mirror) { project.remote_mirrors.first }
+
+ it 'requires `admin_remote_mirror` permission' do
+ get api(route, developer)
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+
+ it 'returns at remote mirror' do
+ project.add_maintainer(user)
+
+ get api(route, user)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(response).to match_response_schema('remote_mirror')
+ end
+ end
+
describe 'POST /projects/:id/remote_mirrors' do
let(:route) { "/projects/#{project.id}/remote_mirrors" }
@@ -75,11 +95,11 @@ RSpec.describe API::RemoteMirrors do
end
describe 'PUT /projects/:id/remote_mirrors/:mirror_id' do
- let(:route) { ->(id) { "/projects/#{project.id}/remote_mirrors/#{id}" } }
+ let(:route) { "/projects/#{project.id}/remote_mirrors/#{mirror.id}" }
let(:mirror) { project.remote_mirrors.first }
it 'requires `admin_remote_mirror` permission' do
- put api(route[mirror.id], developer)
+ put api(route, developer)
expect(response).to have_gitlab_http_status(:unauthorized)
end
@@ -87,7 +107,7 @@ RSpec.describe API::RemoteMirrors do
it 'updates a remote mirror' do
project.add_maintainer(user)
- put api(route[mirror.id], user), params: {
+ put api(route, user), params: {
enabled: '0',
only_protected_branches: 'true',
keep_divergent_refs: 'true'
@@ -99,4 +119,44 @@ RSpec.describe API::RemoteMirrors do
expect(json_response['keep_divergent_refs']).to eq(true)
end
end
+
+ describe 'DELETE /projects/:id/remote_mirrors/:mirror_id' do
+ let(:route) { ->(id) { "/projects/#{project.id}/remote_mirrors/#{id}" } }
+ let(:mirror) { project.remote_mirrors.first }
+
+ it 'requires `admin_remote_mirror` permission' do
+ expect { delete api(route[mirror.id], developer) }.not_to change { project.remote_mirrors.count }
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+
+ context 'when the user is a maintainer' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ it 'returns 404 for non existing id' do
+ delete api(route[non_existing_record_id], user)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+
+ it 'returns bad request if the update service fails' do
+ expect_next_instance_of(Projects::UpdateService) do |service|
+ expect(service).to receive(:execute).and_return(status: :error, message: 'message')
+ end
+
+ expect { delete api(route[mirror.id], user) }.not_to change { project.remote_mirrors.count }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response).to eq({ 'message' => 'message' })
+ end
+
+ it 'deletes a remote mirror' do
+ expect { delete api(route[mirror.id], user) }.to change { project.remote_mirrors.count }.from(1).to(0)
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
+ end
+ end
end
diff --git a/spec/requests/api/repositories_spec.rb b/spec/requests/api/repositories_spec.rb
index 1d199a72d1d..d6d2bd5baf2 100644
--- a/spec/requests/api/repositories_spec.rb
+++ b/spec/requests/api/repositories_spec.rb
@@ -452,7 +452,7 @@ RSpec.describe API::Repositories do
it "compare commits between different projects" do
group = create(:group)
- group.add_owner(current_user)
+ group.add_owner(current_user) if current_user
forked_project = fork_project(project, current_user, repository: true, namespace: group)
forked_project.repository.create_ref('refs/heads/improve/awesome', 'refs/heads/improve/more-awesome')
diff --git a/spec/requests/api/resource_access_tokens_spec.rb b/spec/requests/api/resource_access_tokens_spec.rb
index 7e3e682767f..369a8c1b0ab 100644
--- a/spec/requests/api/resource_access_tokens_spec.rb
+++ b/spec/requests/api/resource_access_tokens_spec.rb
@@ -29,6 +29,8 @@ RSpec.describe API::ResourceAccessTokens do
token_ids = json_response.map { |token| token['id'] }
expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(response).to match_response_schema('public_api/v4/resource_access_tokens')
expect(token_ids).to match_array(access_tokens.pluck(:id))
end
@@ -131,6 +133,103 @@ RSpec.describe API::ResourceAccessTokens do
end
end
+ context "GET #{source_type}s/:id/access_tokens/:token_id" do
+ subject(:get_token) { get api("/#{source_type}s/#{resource_id}/access_tokens/#{token_id}", user) }
+
+ let_it_be(:project_bot) { create(:user, :project_bot) }
+ let_it_be(:token) { create(:personal_access_token, user: project_bot) }
+ let_it_be(:resource_id) { resource.id }
+ let_it_be(:token_id) { token.id }
+
+ before do
+ if source_type == 'project'
+ resource.add_maintainer(project_bot)
+ else
+ resource.add_owner(project_bot)
+ end
+ end
+
+ context "when the user has valid permissions" do
+ it "gets the #{source_type} access token from the #{source_type}" do
+ get_token
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/resource_access_token')
+
+ expect(json_response["name"]).to eq(token.name)
+ expect(json_response["scopes"]).to eq(token.scopes)
+
+ if source_type == 'project'
+ expect(json_response["access_level"]).to eq(resource.team.max_member_access(token.user.id))
+ else
+ expect(json_response["access_level"]).to eq(resource.max_member_access_for_user(token.user))
+ end
+
+ expect(json_response["expires_at"]).to eq(token.expires_at.to_date.iso8601)
+ end
+
+ context "when using #{source_type} access token to GET other #{source_type} access token" do
+ let_it_be(:other_project_bot) { create(:user, :project_bot) }
+ let_it_be(:other_token) { create(:personal_access_token, user: other_project_bot) }
+ let_it_be(:token_id) { other_token.id }
+
+ before do
+ resource.add_maintainer(other_project_bot)
+ end
+
+ it "gets the #{source_type} access token from the #{source_type}" do
+ get_token
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/resource_access_token')
+
+ expect(json_response["name"]).to eq(other_token.name)
+ expect(json_response["scopes"]).to eq(other_token.scopes)
+
+ if source_type == 'project'
+ expect(json_response["access_level"]).to eq(resource.team.max_member_access(other_token.user.id))
+ else
+ expect(json_response["access_level"]).to eq(resource.max_member_access_for_user(other_token.user))
+ end
+
+ expect(json_response["expires_at"]).to eq(other_token.expires_at.to_date.iso8601)
+ end
+ end
+
+ context "when attempting to get a non-existent #{source_type} access token" do
+ let_it_be(:token_id) { non_existing_record_id }
+
+ it "does not get the token, and returns 404" do
+ get_token
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(response.body).to include("Could not find #{source_type} access token with token_id: #{token_id}")
+ end
+ end
+
+ context "when attempting to get a token that does not belong to the specified #{source_type}" do
+ let_it_be(:resource_id) { other_resource.id }
+
+ it "does not get the token, and returns 404" do
+ get_token
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(response.body).to include("Could not find #{source_type} access token with token_id: #{token_id}")
+ end
+ end
+ end
+
+ context "when the user does not have valid permissions" do
+ let_it_be(:user) { user_non_priviledged }
+
+ it "returns 401" do
+ get_token
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+ end
+
context "DELETE #{source_type}s/:id/access_tokens/:token_id", :sidekiq_inline do
subject(:delete_token) { delete api("/#{source_type}s/#{resource_id}/access_tokens/#{token_id}", user) }
diff --git a/spec/requests/api/settings_spec.rb b/spec/requests/api/settings_spec.rb
index f7048a1ca6b..c724c69045e 100644
--- a/spec/requests/api/settings_spec.rb
+++ b/spec/requests/api/settings_spec.rb
@@ -91,7 +91,7 @@ RSpec.describe API::Settings, 'Settings', :do_not_mock_admin_mode_setting do
end
end
- it "updates application settings" do
+ it "updates application settings", fips_mode: false do
put api("/application/settings", admin),
params: {
default_ci_config_path: 'debian/salsa-ci.yml',
@@ -286,6 +286,55 @@ RSpec.describe API::Settings, 'Settings', :do_not_mock_admin_mode_setting do
expect(json_response['hashed_storage_enabled']).to eq(true)
end
+ context 'SSH key restriction settings', :fips_mode do
+ let(:settings) do
+ {
+ dsa_key_restriction: -1,
+ ecdsa_key_restriction: 256,
+ ecdsa_sk_key_restriction: 256,
+ ed25519_key_restriction: 256,
+ ed25519_sk_key_restriction: 256,
+ rsa_key_restriction: 3072
+ }
+ end
+
+ it 'allows updating the settings' do
+ put api("/application/settings", admin), params: settings
+
+ expect(response).to have_gitlab_http_status(:ok)
+ settings.each do |attribute, value|
+ expect(ApplicationSetting.current.public_send(attribute)).to eq(value)
+ end
+ end
+
+ it 'does not allow DSA keys' do
+ put api("/application/settings", admin), params: { dsa_key_restriction: 1024 }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+
+ it 'does not allow short RSA key values' do
+ put api("/application/settings", admin), params: { rsa_key_restriction: 2048 }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+
+ it 'does not allow unrestricted key lengths' do
+ types = %w(dsa_key_restriction
+ ecdsa_key_restriction
+ ecdsa_sk_key_restriction
+ ed25519_key_restriction
+ ed25519_sk_key_restriction
+ rsa_key_restriction)
+
+ types.each do |type|
+ put api("/application/settings", admin), params: { type => 0 }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+ end
+
context 'external policy classification settings' do
let(:settings) do
{
diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb
index eadceeba03b..c554463df76 100644
--- a/spec/requests/api/users_spec.rb
+++ b/spec/requests/api/users_spec.rb
@@ -83,19 +83,21 @@ RSpec.describe API::Users do
describe 'GET /users/' do
context 'when unauthenticated' do
- it "does not contain the note of users" do
+ it "does not contain certain fields" do
get api("/users"), params: { username: user.username }
expect(json_response.first).not_to have_key('note')
+ expect(json_response.first).not_to have_key('namespace_id')
end
end
context 'when authenticated' do
context 'as a regular user' do
- it 'does not contain the note of users' do
+ it 'does not contain certain fields' do
get api("/users", user), params: { username: user.username }
expect(json_response.first).not_to have_key('note')
+ expect(json_response.first).not_to have_key('namespace_id')
end
end
@@ -154,6 +156,7 @@ RSpec.describe API::Users do
get api("/user", user)
expect(json_response).not_to have_key('note')
+ expect(json_response).not_to have_key('namespace_id')
end
end
end
@@ -335,12 +338,14 @@ RSpec.describe API::Users do
expect(response).to match_response_schema('public_api/v4/user/basics')
expect(json_response.first.keys).not_to include 'is_admin'
end
+ end
+ context "when admin" do
context 'exclude_internal param' do
let_it_be(:internal_user) { User.alert_bot }
it 'returns all users when it is not set' do
- get api("/users?exclude_internal=false", user)
+ get api("/users?exclude_internal=false", admin)
expect(response).to match_response_schema('public_api/v4/user/basics')
expect(response).to include_pagination_headers
@@ -356,6 +361,26 @@ RSpec.describe API::Users do
end
end
+ context 'without_project_bots param' do
+ let_it_be(:project_bot) { create(:user, :project_bot) }
+
+ it 'returns all users when it is not set' do
+ get api("/users?without_project_bots=false", user)
+
+ expect(response).to match_response_schema('public_api/v4/user/basics')
+ expect(response).to include_pagination_headers
+ expect(json_response.map { |u| u['id'] }).to include(project_bot.id)
+ end
+
+ it 'returns all non project_bot users when it is set' do
+ get api("/users?without_project_bots=true", user)
+
+ expect(response).to match_response_schema('public_api/v4/user/basics')
+ expect(response).to include_pagination_headers
+ expect(json_response.map { |u| u['id'] }).not_to include(project_bot.id)
+ end
+ end
+
context 'admins param' do
it 'returns all users' do
get api("/users?admins=true", user)
@@ -384,6 +409,15 @@ RSpec.describe API::Users do
expect(response).to include_pagination_headers
end
+ it "users contain the `namespace_id` field" do
+ get api("/users", admin)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(response).to match_response_schema('public_api/v4/user/admins')
+ expect(json_response.size).to eq(2)
+ expect(json_response.map { |u| u['namespace_id'] }).to include(user.namespace_id, admin.namespace_id)
+ end
+
it "returns an array of external users" do
create(:user, external: true)
@@ -697,6 +731,14 @@ RSpec.describe API::Users do
expect(json_response['highest_role']).to be(0)
end
+ it 'includes the `namespace_id` field' do
+ get api("/users/#{user.id}", admin)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(response).to match_response_schema('public_api/v4/user/admin')
+ expect(json_response['namespace_id']).to eq(user.namespace_id)
+ end
+
if Gitlab.ee?
it 'does not include values for plan or trial' do
get api("/users/#{user.id}", admin)
@@ -1934,7 +1976,7 @@ RSpec.describe API::Users do
end
end
- describe "POST /users/:id/emails" do
+ describe "POST /users/:id/emails", :mailer do
it "does not create invalid email" do
post api("/users/#{user.id}/emails", admin), params: {}
@@ -1944,11 +1986,15 @@ RSpec.describe API::Users do
it "creates unverified email" do
email_attrs = attributes_for :email
- expect do
- post api("/users/#{user.id}/emails", admin), params: email_attrs
- end.to change { user.emails.count }.by(1)
+
+ perform_enqueued_jobs do
+ expect do
+ post api("/users/#{user.id}/emails", admin), params: email_attrs
+ end.to change { user.emails.count }.by(1)
+ end
expect(json_response['confirmed_at']).to be_nil
+ should_email(user)
end
it "returns a 400 for invalid ID" do
diff --git a/spec/requests/api/v3/github_spec.rb b/spec/requests/api/v3/github_spec.rb
index 838948132dd..5bfea15f0ca 100644
--- a/spec/requests/api/v3/github_spec.rb
+++ b/spec/requests/api/v3/github_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe API::V3::Github do
let_it_be_with_reload(:project) { create(:project, :repository, creator: user) }
before do
- project.add_maintainer(user)
+ project.add_maintainer(user) if user
end
describe 'GET /orgs/:namespace/repos' do
diff --git a/spec/requests/groups/crm/contacts_controller_spec.rb b/spec/requests/groups/crm/contacts_controller_spec.rb
index 4d8ca0fcd60..0ee72233418 100644
--- a/spec/requests/groups/crm/contacts_controller_spec.rb
+++ b/spec/requests/groups/crm/contacts_controller_spec.rb
@@ -85,28 +85,19 @@ RSpec.describe Groups::Crm::ContactsController do
end
describe 'GET #index' do
- subject do
- get group_crm_contacts_path(group)
- response
- end
+ subject { get group_crm_contacts_path(group) }
it_behaves_like 'ok response with index template if authorized'
end
describe 'GET #new' do
- subject do
- get new_group_crm_contact_path(group)
- response
- end
+ subject { get new_group_crm_contact_path(group) }
it_behaves_like 'ok response with index template if authorized'
end
describe 'GET #edit' do
- subject do
- get edit_group_crm_contact_path(group, id: 1)
- response
- end
+ subject { get edit_group_crm_contact_path(group, id: 1) }
it_behaves_like 'ok response with index template if authorized'
end
diff --git a/spec/requests/groups/crm/organizations_controller_spec.rb b/spec/requests/groups/crm/organizations_controller_spec.rb
index 37ffac71772..410fc979262 100644
--- a/spec/requests/groups/crm/organizations_controller_spec.rb
+++ b/spec/requests/groups/crm/organizations_controller_spec.rb
@@ -85,18 +85,19 @@ RSpec.describe Groups::Crm::OrganizationsController do
end
describe 'GET #index' do
- subject do
- get group_crm_organizations_path(group)
- response
- end
+ subject { get group_crm_organizations_path(group) }
it_behaves_like 'ok response with index template if authorized'
end
describe 'GET #new' do
- subject do
- get new_group_crm_organization_path(group)
- end
+ subject { get new_group_crm_organization_path(group) }
+
+ it_behaves_like 'ok response with index template if authorized'
+ end
+
+ describe 'GET #edit' do
+ subject { get edit_group_crm_organization_path(group, id: 1) }
it_behaves_like 'ok response with index template if authorized'
end
diff --git a/spec/requests/groups/email_campaigns_controller_spec.rb b/spec/requests/groups/email_campaigns_controller_spec.rb
index 9ed828d1a9a..4d630ef6710 100644
--- a/spec/requests/groups/email_campaigns_controller_spec.rb
+++ b/spec/requests/groups/email_campaigns_controller_spec.rb
@@ -94,7 +94,7 @@ RSpec.describe Groups::EmailCampaignsController do
describe 'track parameter' do
context 'when valid' do
- where(track: [Namespaces::InProductMarketingEmailsService::TRACKS.keys.without(:experience), Namespaces::InviteTeamEmailService::TRACK].flatten)
+ where(track: Namespaces::InProductMarketingEmailsService::TRACKS.keys.without(:experience))
with_them do
it_behaves_like 'track and redirect'
@@ -117,10 +117,6 @@ RSpec.describe Groups::EmailCampaignsController do
with_them do
it_behaves_like 'track and redirect'
end
-
- it_behaves_like 'track and redirect' do
- let(:track) { Namespaces::InviteTeamEmailService::TRACK.to_s }
- end
end
context 'when invalid' do
@@ -128,10 +124,6 @@ RSpec.describe Groups::EmailCampaignsController do
with_them do
it_behaves_like 'no track and 404'
-
- it_behaves_like 'no track and 404' do
- let(:track) { Namespaces::InviteTeamEmailService::TRACK.to_s }
- end
end
end
end
diff --git a/spec/requests/import/gitlab_groups_controller_spec.rb b/spec/requests/import/gitlab_groups_controller_spec.rb
index 4abf99cf994..8d5c1e3ebab 100644
--- a/spec/requests/import/gitlab_groups_controller_spec.rb
+++ b/spec/requests/import/gitlab_groups_controller_spec.rb
@@ -155,20 +155,6 @@ RSpec.describe Import::GitlabGroupsController do
end
end
- context 'when group import FF is disabled' do
- let(:request_params) { { path: 'test-group-import', name: 'test-group-import' } }
-
- before do
- stub_feature_flags(group_import_export: false)
- end
-
- it 'returns an error' do
- expect { import_request }.not_to change { Group.count }
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
-
context 'when the parent group is invalid' do
let(:request_params) { { path: 'test-group-import', name: 'test-group-import', parent_id: -1 } }
diff --git a/spec/requests/jira_authorizations_spec.rb b/spec/requests/jira_authorizations_spec.rb
index 24c6001814c..b43d36e94f4 100644
--- a/spec/requests/jira_authorizations_spec.rb
+++ b/spec/requests/jira_authorizations_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
RSpec.describe 'Jira authorization requests' do
let(:user) { create :user }
let(:application) { create :oauth_application, scopes: 'api' }
- let(:redirect_uri) { oauth_jira_callback_url(host: "http://www.example.com") }
+ let(:redirect_uri) { oauth_jira_dvcs_callback_url(host: "http://www.example.com") }
def generate_access_grant
create :oauth_access_grant, application: application, resource_owner_id: user.id, redirect_uri: redirect_uri
diff --git a/spec/requests/projects/work_items_spec.rb b/spec/requests/projects/work_items_spec.rb
new file mode 100644
index 00000000000..e6365a3824a
--- /dev/null
+++ b/spec/requests/projects/work_items_spec.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Work Items' do
+ let_it_be(:work_item) { create(:work_item) }
+ let_it_be(:developer) { create(:user) }
+
+ before_all do
+ work_item.project.add_developer(developer)
+ end
+
+ describe 'GET /:namespace/:project/work_items/:id' do
+ before do
+ sign_in(developer)
+ end
+
+ context 'when the work_items feature flag is enabled' do
+ it 'renders index' do
+ get project_work_items_url(work_item.project, work_items_path: work_item.id)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
+ context 'when the work_items feature flag is disabled' do
+ before do
+ stub_feature_flags(work_items: false)
+ end
+
+ it 'returns 404' do
+ get project_work_items_url(work_item.project, work_items_path: work_item.id)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+end
diff --git a/spec/routing/admin_routing_spec.rb b/spec/routing/admin_routing_spec.rb
index 8c36d7d4668..f48b4de23a2 100644
--- a/spec/routing/admin_routing_spec.rb
+++ b/spec/routing/admin_routing_spec.rb
@@ -134,10 +134,17 @@ RSpec.describe Admin::HealthCheckController, "routing" do
end
end
-# admin_dev_ops_report GET /admin/dev_ops_report(.:format) admin/dev_ops_report#show
+# admin_dev_ops_reports GET /admin/dev_ops_reports(.:format) admin/dev_ops_report#show
RSpec.describe Admin::DevOpsReportController, "routing" do
it "to #show" do
- expect(get("/admin/dev_ops_report")).to route_to('admin/dev_ops_report#show')
+ expect(get("/admin/dev_ops_reports")).to route_to('admin/dev_ops_report#show')
+ end
+
+ describe 'admin devops reports' do
+ include RSpec::Rails::RequestExampleGroup
+ it 'redirects from /admin/dev_ops_report to /admin/dev_ops_reports' do
+ expect(get("/admin/dev_ops_report")).to redirect_to(admin_dev_ops_reports_path)
+ end
end
end
diff --git a/spec/routing/project_routing_spec.rb b/spec/routing/project_routing_spec.rb
index 65772895826..21012399edf 100644
--- a/spec/routing/project_routing_spec.rb
+++ b/spec/routing/project_routing_spec.rb
@@ -905,6 +905,13 @@ RSpec.describe 'project routing' do
)
end
+ it 'routes to 404 without format for invalid page' do
+ expect(get: "/gitlab/gitlabhq/-/metrics/invalid_page.md").to route_to(
+ 'application#route_not_found',
+ unmatched_route: 'gitlab/gitlabhq/-/metrics/invalid_page.md'
+ )
+ end
+
it 'routes to 404 with invalid dashboard_path' do
expect(get: "/gitlab/gitlabhq/-/metrics/invalid_dashboard").to route_to(
'application#route_not_found',
diff --git a/spec/routing/uploads_routing_spec.rb b/spec/routing/uploads_routing_spec.rb
index d1ddf8a6d6a..41646d1b515 100644
--- a/spec/routing/uploads_routing_spec.rb
+++ b/spec/routing/uploads_routing_spec.rb
@@ -21,6 +21,17 @@ RSpec.describe 'Uploads', 'routing' do
)
end
+ it 'allows fetching alert metric metric images' do
+ expect(get('/uploads/-/system/alert_management_metric_image/file/1/test.jpg')).to route_to(
+ controller: 'uploads',
+ action: 'show',
+ model: 'alert_management_metric_image',
+ id: '1',
+ filename: 'test.jpg',
+ mounted_as: 'file'
+ )
+ end
+
it 'does not allow creating uploads for other models' do
unroutable_models = UploadsController::MODEL_CLASSES.keys.compact - %w(personal_snippet user)
diff --git a/spec/rubocop/cop/database/disable_referential_integrity_spec.rb b/spec/rubocop/cop/database/disable_referential_integrity_spec.rb
new file mode 100644
index 00000000000..9ac67363cb6
--- /dev/null
+++ b/spec/rubocop/cop/database/disable_referential_integrity_spec.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+require_relative '../../../../rubocop/cop/database/disable_referential_integrity'
+
+RSpec.describe RuboCop::Cop::Database::DisableReferentialIntegrity do
+ subject(:cop) { described_class.new }
+
+ it 'does not flag the use of disable_referential_integrity with a send receiver' do
+ expect_offense(<<~SOURCE)
+ foo.disable_referential_integrity
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Do not use `disable_referential_integrity`, [...]
+ SOURCE
+ end
+
+ it 'flags the use of disable_referential_integrity with a full definition' do
+ expect_offense(<<~SOURCE)
+ ActiveRecord::Base.connection.disable_referential_integrity
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Do not use `disable_referential_integrity`, [...]
+ SOURCE
+ end
+
+ it 'flags the use of disable_referential_integrity with a nil receiver' do
+ expect_offense(<<~SOURCE)
+ class Foo ; disable_referential_integrity ; end
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Do not use `disable_referential_integrity`, [...]
+ SOURCE
+ end
+
+ it 'flags the use of disable_referential_integrity when passing a block' do
+ expect_offense(<<~SOURCE)
+ class Foo ; disable_referential_integrity { :foo } ; end
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Do not use `disable_referential_integrity`, [...]
+ SOURCE
+ end
+end
diff --git a/spec/rubocop/cop/gitlab/avoid_feature_category_not_owned_spec.rb b/spec/rubocop/cop/gitlab/avoid_feature_category_not_owned_spec.rb
new file mode 100644
index 00000000000..f6c6955f6bb
--- /dev/null
+++ b/spec/rubocop/cop/gitlab/avoid_feature_category_not_owned_spec.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+require_relative '../../../../rubocop/cop/gitlab/avoid_feature_category_not_owned'
+
+RSpec.describe RuboCop::Cop::Gitlab::AvoidFeatureCategoryNotOwned do
+ subject(:cop) { described_class.new }
+
+ shared_examples 'defining feature category on a class' do
+ it 'flags a method call on a class' do
+ expect_offense(<<~SOURCE)
+ feature_category :not_owned
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^ Avoid adding new endpoints with `feature_category :not_owned`. See https://docs.gitlab.com/ee/development/feature_categorization
+ SOURCE
+ end
+
+ it 'flags a method call on a class with an array passed' do
+ expect_offense(<<~SOURCE)
+ feature_category :not_owned, [:index, :edit]
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Avoid adding new endpoints with `feature_category :not_owned`. See https://docs.gitlab.com/ee/development/feature_categorization
+ SOURCE
+ end
+
+ it 'flags a method call on a class with an array passed' do
+ expect_offense(<<~SOURCE)
+ worker.feature_category :not_owned, [:index, :edit]
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Avoid adding new endpoints with `feature_category :not_owned`. See https://docs.gitlab.com/ee/development/feature_categorization
+ SOURCE
+ end
+ end
+
+ context 'in controllers' do
+ before do
+ allow(subject).to receive(:in_controller?).and_return(true)
+ end
+
+ it_behaves_like 'defining feature category on a class'
+ end
+
+ context 'in workers' do
+ before do
+ allow(subject).to receive(:in_worker?).and_return(true)
+ end
+
+ it_behaves_like 'defining feature category on a class'
+ end
+
+ context 'for grape endpoints' do
+ before do
+ allow(subject).to receive(:in_api?).and_return(true)
+ end
+
+ it_behaves_like 'defining feature category on a class'
+
+ it 'flags when passed as a hash for a Grape endpoint as keyword args' do
+ expect_offense(<<~SOURCE)
+ get :hello, feature_category: :not_owned
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Avoid adding new endpoints with `feature_category :not_owned`. See https://docs.gitlab.com/ee/development/feature_categorization
+ SOURCE
+ end
+
+ it 'flags when passed as a hash for a Grape endpoint in a hash' do
+ expect_offense(<<~SOURCE)
+ get :hello, { feature_category: :not_owned, urgency: :low}
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Avoid adding new endpoints with `feature_category :not_owned`. See https://docs.gitlab.com/ee/development/feature_categorization
+ SOURCE
+ end
+ end
+end
diff --git a/spec/rubocop/cop/qa/duplicate_testcase_link_spec.rb b/spec/rubocop/cop/qa/duplicate_testcase_link_spec.rb
deleted file mode 100644
index fb424da90e8..00000000000
--- a/spec/rubocop/cop/qa/duplicate_testcase_link_spec.rb
+++ /dev/null
@@ -1,36 +0,0 @@
-# frozen_string_literal: true
-
-require 'fast_spec_helper'
-
-require_relative '../../../../rubocop/cop/qa/duplicate_testcase_link'
-
-RSpec.describe RuboCop::Cop::QA::DuplicateTestcaseLink do
- let(:source_file) { 'qa/page.rb' }
-
- subject(:cop) { described_class.new }
-
- context 'in a QA file' do
- before do
- allow(cop).to receive(:in_qa_file?).and_return(true)
- end
-
- it "registers an offense for a duplicate testcase link" do
- expect_offense(<<-RUBY)
- it 'some test', testcase: '/quality/test_cases/1892' do
- end
- it 'another test', testcase: '/quality/test_cases/1892' do
- ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Don't reuse the same testcase link in different tests. Replace one of `/quality/test_cases/1892`.
- end
- RUBY
- end
-
- it "doesnt offend if testcase link is unique" do
- expect_no_offenses(<<-RUBY)
- it 'some test', testcase: '/quality/test_cases/1893' do
- end
- it 'another test', testcase: '/quality/test_cases/1894' do
- end
- RUBY
- end
- end
-end
diff --git a/spec/rubocop/cop/qa/testcase_link_format_spec.rb b/spec/rubocop/cop/qa/testcase_link_format_spec.rb
deleted file mode 100644
index f9b43f2a293..00000000000
--- a/spec/rubocop/cop/qa/testcase_link_format_spec.rb
+++ /dev/null
@@ -1,45 +0,0 @@
-# frozen_string_literal: true
-
-require 'fast_spec_helper'
-
-require_relative '../../../../rubocop/cop/qa/testcase_link_format'
-
-RSpec.describe RuboCop::Cop::QA::TestcaseLinkFormat do
- let(:source_file) { 'qa/page.rb' }
- let(:msg) { 'Testcase link format incorrect. Please link a test case from the GitLab project. See: https://docs.gitlab.com/ee/development/testing_guide/end_to_end/best_practices.html#link-a-test-to-its-test-case.' }
-
- subject(:cop) { described_class.new }
-
- context 'in a QA file' do
- before do
- allow(cop).to receive(:in_qa_file?).and_return(true)
- end
-
- it "registers an offense for a testcase link for an issue" do
- node = "it 'another test', testcase: 'https://gitlab.com/gitlab-org/gitlab/-/issues/557' do"
-
- expect_offense(<<-RUBY, node: node, msg: msg)
- %{node}
- ^{node} %{msg}
- end
- RUBY
- end
-
- it "registers an offense for a testcase link for the wrong project" do
- node = "it 'another test', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/quality/test_cases/2455' do"
-
- expect_offense(<<-RUBY, node: node, msg: msg)
- %{node}
- ^{node} %{msg}
- end
- RUBY
- end
-
- it "doesnt offend if testcase link is correct" do
- expect_no_offenses(<<-RUBY)
- it 'some test', testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/348312' do
- end
- RUBY
- end
- end
-end
diff --git a/spec/serializers/commit_entity_spec.rb b/spec/serializers/commit_entity_spec.rb
index 6b4346faf5b..ba2d7e17d1a 100644
--- a/spec/serializers/commit_entity_spec.rb
+++ b/spec/serializers/commit_entity_spec.rb
@@ -3,8 +3,6 @@
require 'spec_helper'
RSpec.describe CommitEntity do
- let(:signature_html) { 'TEST' }
-
let(:entity) do
described_class.new(commit, request: request)
end
@@ -16,11 +14,7 @@ RSpec.describe CommitEntity do
subject { entity.as_json }
before do
- render = double('render')
- allow(render).to receive(:call).and_return(signature_html)
-
allow(request).to receive(:project).and_return(project)
- allow(request).to receive(:render).and_return(render)
end
context 'when commit author is a user' do
@@ -83,8 +77,7 @@ RSpec.describe CommitEntity do
let(:commit) { project.commit(TestEnv::BRANCH_SHA['signed-commits']) }
it 'exposes "signature_html"' do
- expect(request.render).to receive(:call)
- expect(subject.fetch(:signature_html)).to be signature_html
+ expect(subject.fetch(:signature_html)).not_to be_nil
end
end
diff --git a/spec/serializers/deployment_entity_spec.rb b/spec/serializers/deployment_entity_spec.rb
index 1dacc9513ee..500d5718bf1 100644
--- a/spec/serializers/deployment_entity_spec.rb
+++ b/spec/serializers/deployment_entity_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe DeploymentEntity do
let(:project) { create(:project, :repository) }
let(:request) { double('request') }
let(:deployment) { create(:deployment, deployable: build, project: project) }
- let(:build) { create(:ci_build, :manual, pipeline: pipeline) }
+ let(:build) { create(:ci_build, :manual, :environment_with_deployment_tier, pipeline: pipeline) }
let(:pipeline) { create(:ci_pipeline, project: project, user: user) }
let(:entity) { described_class.new(deployment, request: request) }
@@ -46,6 +46,10 @@ RSpec.describe DeploymentEntity do
expect(subject).to include(:is_last)
end
+ it 'exposes deployment tier in yaml' do
+ expect(subject).to include(:tier_in_yaml)
+ end
+
context 'when deployable is nil' do
let(:entity) { described_class.new(deployment, request: request, deployment_details: false) }
let(:deployment) { create(:deployment, deployable: nil, project: project) }
diff --git a/spec/serializers/environment_serializer_spec.rb b/spec/serializers/environment_serializer_spec.rb
index ec0dd735755..fe6278084f9 100644
--- a/spec/serializers/environment_serializer_spec.rb
+++ b/spec/serializers/environment_serializer_spec.rb
@@ -50,7 +50,7 @@ RSpec.describe EnvironmentSerializer do
context 'when there is a single environment' do
before do
- create(:environment, name: 'staging')
+ create(:environment, project: project, name: 'staging')
end
it 'represents one standalone environment' do
@@ -63,8 +63,8 @@ RSpec.describe EnvironmentSerializer do
context 'when there are multiple environments in folder' do
before do
- create(:environment, name: 'staging/my-review-1')
- create(:environment, name: 'staging/my-review-2')
+ create(:environment, project: project, name: 'staging/my-review-1')
+ create(:environment, project: project, name: 'staging/my-review-2')
end
it 'represents one item that is a folder' do
@@ -78,10 +78,10 @@ RSpec.describe EnvironmentSerializer do
context 'when there are multiple folders and standalone environments' do
before do
- create(:environment, name: 'staging/my-review-1')
- create(:environment, name: 'staging/my-review-2')
- create(:environment, name: 'production/my-review-3')
- create(:environment, name: 'testing')
+ create(:environment, project: project, name: 'staging/my-review-1')
+ create(:environment, project: project, name: 'staging/my-review-2')
+ create(:environment, project: project, name: 'production/my-review-3')
+ create(:environment, project: project, name: 'testing')
end
it 'represents multiple items grouped within folders' do
@@ -124,7 +124,7 @@ RSpec.describe EnvironmentSerializer do
context 'when resource is paginatable relation' do
context 'when there is a single environment object in relation' do
before do
- create(:environment)
+ create(:environment, project: project)
end
it 'serializes environments' do
@@ -134,7 +134,7 @@ RSpec.describe EnvironmentSerializer do
context 'when multiple environment objects are serialized' do
before do
- create_list(:environment, 3)
+ create_list(:environment, 3, project: project)
end
it 'serializes appropriate number of objects' do
@@ -159,10 +159,10 @@ RSpec.describe EnvironmentSerializer do
end
before do
- create(:environment, name: 'staging/review-1')
- create(:environment, name: 'staging/review-2')
- create(:environment, name: 'production/deploy-3')
- create(:environment, name: 'testing')
+ create(:environment, project: project, name: 'staging/review-1')
+ create(:environment, project: project, name: 'staging/review-2')
+ create(:environment, project: project, name: 'production/deploy-3')
+ create(:environment, project: project, name: 'testing')
end
it 'paginates grouped items including ordering' do
@@ -189,7 +189,7 @@ RSpec.describe EnvironmentSerializer do
let(:resource) { Environment.all }
before do
- create(:environment, name: 'staging/review-1')
+ create(:environment, project: project, name: 'staging/review-1')
create_environment_with_associations(project)
end
diff --git a/spec/serializers/group_link/group_group_link_entity_spec.rb b/spec/serializers/group_link/group_group_link_entity_spec.rb
index 2821c433784..502cdc5c048 100644
--- a/spec/serializers/group_link/group_group_link_entity_spec.rb
+++ b/spec/serializers/group_link/group_group_link_entity_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe GroupLink::GroupGroupLinkEntity do
let_it_be(:current_user) { create(:user) }
- let(:entity) { described_class.new(group_group_link) }
+ let(:entity) { described_class.new(group_group_link, { current_user: current_user, source: shared_group }) }
before do
allow(entity).to receive(:current_user).and_return(current_user)
@@ -17,16 +17,56 @@ RSpec.describe GroupLink::GroupGroupLinkEntity do
expect(entity.to_json).to match_schema('group_link/group_group_link')
end
+ context 'source' do
+ it 'exposes `source`' do
+ expect(entity.as_json[:source]).to include(
+ id: shared_group.id,
+ full_name: shared_group.full_name,
+ web_url: shared_group.web_url
+ )
+ end
+ end
+
+ context 'is_direct_member' do
+ it 'exposes `is_direct_member` as true for corresponding group' do
+ expect(entity.as_json[:is_direct_member]).to be true
+ end
+
+ it 'exposes `is_direct_member` as false for other source' do
+ entity = described_class.new(group_group_link, { current_user: current_user, source: shared_with_group })
+ expect(entity.as_json[:is_direct_member]).to be false
+ end
+ end
+
context 'when current user has `:admin_group_member` permissions' do
before do
allow(entity).to receive(:can?).with(current_user, :admin_group_member, shared_group).and_return(true)
end
- it 'exposes `can_update` and `can_remove` as `true`' do
- json = entity.as_json
+ context 'when direct_member? is true' do
+ before do
+ allow(entity).to receive(:direct_member?).and_return(true)
+ end
+
+ it 'exposes `can_update` and `can_remove` as `true`' do
+ json = entity.as_json
+
+ expect(json[:can_update]).to be true
+ expect(json[:can_remove]).to be true
+ end
+ end
+
+ context 'when direct_member? is false' do
+ before do
+ allow(entity).to receive(:direct_member?).and_return(false)
+ end
+
+ it 'exposes `can_update` and `can_remove` as `true`' do
+ json = entity.as_json
- expect(json[:can_update]).to be true
- expect(json[:can_remove]).to be true
+ expect(json[:can_update]).to be false
+ expect(json[:can_remove]).to be false
+ end
end
end
end
diff --git a/spec/serializers/group_link/project_group_link_entity_spec.rb b/spec/serializers/group_link/project_group_link_entity_spec.rb
index e7e42d79b5e..f2a9f3a107a 100644
--- a/spec/serializers/group_link/project_group_link_entity_spec.rb
+++ b/spec/serializers/group_link/project_group_link_entity_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe GroupLink::ProjectGroupLinkEntity do
let_it_be(:current_user) { create(:user) }
let_it_be(:project_group_link) { create(:project_group_link) }
- let(:entity) { described_class.new(project_group_link) }
+ let(:entity) { described_class.new(project_group_link, { current_user: current_user, source: project_group_link.project }) }
before do
allow(entity).to receive(:current_user).and_return(current_user)
diff --git a/spec/serializers/member_user_entity_spec.rb b/spec/serializers/member_user_entity_spec.rb
index b505571cbf2..0e6d4bcc3fb 100644
--- a/spec/serializers/member_user_entity_spec.rb
+++ b/spec/serializers/member_user_entity_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe MemberUserEntity do
- let_it_be(:user) { create(:user) }
+ let_it_be(:user) { create(:user, last_activity_on: Date.today) }
let_it_be(:emoji) { 'slight_smile' }
let_it_be(:user_status) { create(:user_status, user: user, emoji: emoji) }
@@ -36,4 +36,12 @@ RSpec.describe MemberUserEntity do
it 'correctly exposes `status.emoji`' do
expect(entity_hash[:status][:emoji]).to match(emoji)
end
+
+ it 'correctly exposes `created_at`' do
+ expect(entity_hash[:created_at]).to be(user.created_at)
+ end
+
+ it 'correctly exposes `last_activity_on`' do
+ expect(entity_hash[:last_activity_on]).to be(user.last_activity_on)
+ end
end
diff --git a/spec/services/alert_management/metric_images/upload_service_spec.rb b/spec/services/alert_management/metric_images/upload_service_spec.rb
new file mode 100644
index 00000000000..527d9db0fd9
--- /dev/null
+++ b/spec/services/alert_management/metric_images/upload_service_spec.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe AlertManagement::MetricImages::UploadService do
+ subject(:service) { described_class.new(alert, current_user, params) }
+
+ let_it_be_with_refind(:project) { create(:project) }
+ let_it_be_with_refind(:alert) { create(:alert_management_alert, project: project) }
+ let_it_be_with_refind(:current_user) { create(:user) }
+
+ let(:params) do
+ {
+ file: fixture_file_upload('spec/fixtures/rails_sample.jpg', 'image/jpg'),
+ url: 'https://www.gitlab.com'
+ }
+ end
+
+ describe '#execute' do
+ subject { service.execute }
+
+ shared_examples 'uploads the metric' do
+ it 'uploads the metric and returns a success' do
+ expect { subject }.to change(AlertManagement::MetricImage, :count).by(1)
+ expect(subject.success?).to eq(true)
+ expect(subject.payload).to match({ metric: instance_of(AlertManagement::MetricImage), alert: alert })
+ end
+ end
+
+ shared_examples 'no metric saved, an error given' do |message|
+ it 'returns an error and does not upload', :aggregate_failures do
+ expect(subject.success?).to eq(false)
+ expect(subject.message).to match(a_string_matching(message))
+ expect(AlertManagement::MetricImage.count).to eq(0)
+ end
+ end
+
+ context 'user does not have permissions' do
+ it_behaves_like 'no metric saved, an error given', 'You are not authorized to upload metric images'
+ end
+
+ context 'user has permissions' do
+ before_all do
+ project.add_developer(current_user)
+ end
+
+ it_behaves_like 'uploads the metric'
+
+ context 'no url given' do
+ let(:params) do
+ {
+ file: fixture_file_upload('spec/fixtures/rails_sample.jpg', 'image/jpg')
+ }
+ end
+
+ it_behaves_like 'uploads the metric'
+ end
+
+ context 'record invalid' do
+ let(:params) do
+ {
+ file: fixture_file_upload('spec/fixtures/doc_sample.txt', 'text/plain'),
+ url: 'https://www.gitlab.com'
+ }
+ end
+
+ it_behaves_like 'no metric saved, an error given', /File does not have a supported extension. Only png, jpg, jpeg, gif, bmp, tiff, ico, and webp are supported/ # rubocop: disable Layout/LineLength
+ end
+
+ context 'user is guest' do
+ before_all do
+ project.add_guest(current_user)
+ end
+
+ it_behaves_like 'no metric saved, an error given', 'You are not authorized to upload metric images'
+ end
+ end
+ end
+end
diff --git a/spec/services/audit_event_service_spec.rb b/spec/services/audit_event_service_spec.rb
index 0379fd3f05c..6963515ba5c 100644
--- a/spec/services/audit_event_service_spec.rb
+++ b/spec/services/audit_event_service_spec.rb
@@ -17,7 +17,8 @@ RSpec.describe AuditEventService do
author_name: user.name,
entity_id: project.id,
entity_type: "Project",
- action: :destroy)
+ action: :destroy,
+ created_at: anything)
expect { service.security_event }.to change(AuditEvent, :count).by(1)
end
@@ -39,7 +40,8 @@ RSpec.describe AuditEventService do
from: 'true',
to: 'false',
action: :create,
- target_id: 1)
+ target_id: 1,
+ created_at: anything)
expect { service.security_event }.to change(AuditEvent, :count).by(1)
@@ -50,6 +52,25 @@ RSpec.describe AuditEventService do
expect(details[:target_id]).to eq(1)
end
+ context 'when defining created_at manually' do
+ let(:service) { described_class.new(user, project, { action: :destroy }, :database, 3.weeks.ago) }
+
+ it 'is overridden successfully' do
+ freeze_time do
+ expect(service).to receive(:file_logger).and_return(logger)
+ expect(logger).to receive(:info).with(author_id: user.id,
+ author_name: user.name,
+ entity_id: project.id,
+ entity_type: "Project",
+ action: :destroy,
+ created_at: 3.weeks.ago)
+
+ expect { service.security_event }.to change(AuditEvent, :count).by(1)
+ expect(AuditEvent.last.created_at).to eq(3.weeks.ago)
+ end
+ end
+ end
+
context 'authentication event' do
let(:audit_service) { described_class.new(user, user, with: 'standard') }
@@ -110,7 +131,8 @@ RSpec.describe AuditEventService do
author_name: user.name,
entity_type: 'Project',
entity_id: project.id,
- action: :destroy)
+ action: :destroy,
+ created_at: anything)
service.log_security_event_to_file
end
diff --git a/spec/services/bulk_imports/relation_export_service_spec.rb b/spec/services/bulk_imports/relation_export_service_spec.rb
index 27a6ca60515..f0f85217d2e 100644
--- a/spec/services/bulk_imports/relation_export_service_spec.rb
+++ b/spec/services/bulk_imports/relation_export_service_spec.rb
@@ -88,6 +88,18 @@ RSpec.describe BulkImports::RelationExportService do
subject.execute
end
+
+ context 'when export is recently finished' do
+ it 'returns recently finished export instead of re-exporting' do
+ updated_at = 5.seconds.ago
+ export.update!(status: 1, updated_at: updated_at)
+
+ expect { subject.execute }.not_to change { export.updated_at }
+
+ expect(export.status).to eq(1)
+ expect(export.updated_at).to eq(updated_at)
+ end
+ end
end
context 'when exception occurs during export' do
diff --git a/spec/services/bulk_update_integration_service_spec.rb b/spec/services/bulk_update_integration_service_spec.rb
index 5e521b98482..dcc8d2df36d 100644
--- a/spec/services/bulk_update_integration_service_spec.rb
+++ b/spec/services/bulk_update_integration_service_spec.rb
@@ -9,7 +9,13 @@ RSpec.describe BulkUpdateIntegrationService do
stub_jira_integration_test
end
- let(:excluded_attributes) { %w[id project_id group_id inherit_from_id instance template created_at updated_at] }
+ let(:excluded_attributes) do
+ %w[
+ id project_id group_id inherit_from_id instance template
+ created_at updated_at encrypted_properties encrypted_properties_iv
+ ]
+ end
+
let(:batch) do
Integration.inherited_descendants_from_self_or_ancestors_from(subgroup_integration).where(id: group_integration.id..integration.id)
end
@@ -50,7 +56,9 @@ RSpec.describe BulkUpdateIntegrationService do
end
context 'with integration with data fields' do
- let(:excluded_attributes) { %w[id service_id created_at updated_at] }
+ let(:excluded_attributes) do
+ %w[id service_id created_at updated_at encrypted_properties encrypted_properties_iv]
+ end
it 'updates the data fields from the integration', :aggregate_failures do
described_class.new(subgroup_integration, batch).execute
diff --git a/spec/services/ci/after_requeue_job_service_spec.rb b/spec/services/ci/after_requeue_job_service_spec.rb
index 2f2baa15945..c9bd44f78e2 100644
--- a/spec/services/ci/after_requeue_job_service_spec.rb
+++ b/spec/services/ci/after_requeue_job_service_spec.rb
@@ -85,7 +85,7 @@ RSpec.describe Ci::AfterRequeueJobService, :sidekiq_inline do
c2: 'skipped'
)
- new_a1 = Ci::RetryBuildService.new(project, user).clone!(a1)
+ new_a1 = Ci::RetryJobService.new(project, user).clone!(a1)
new_a1.enqueue!
check_jobs_statuses(
a1: 'pending',
@@ -172,7 +172,7 @@ RSpec.describe Ci::AfterRequeueJobService, :sidekiq_inline do
c2: 'skipped'
)
- new_a1 = Ci::RetryBuildService.new(project, user).clone!(a1)
+ new_a1 = Ci::RetryJobService.new(project, user).clone!(a1)
new_a1.enqueue!
check_jobs_statuses(
a1: 'pending',
@@ -196,25 +196,6 @@ RSpec.describe Ci::AfterRequeueJobService, :sidekiq_inline do
c2: 'created'
)
end
-
- context 'when the FF ci_fix_order_of_subsequent_jobs is disabled' do
- before do
- stub_feature_flags(ci_fix_order_of_subsequent_jobs: false)
- end
-
- it 'does not mark b1 as processable' do
- execute_after_requeue_service(a1)
-
- check_jobs_statuses(
- a1: 'pending',
- a2: 'created',
- b1: 'skipped',
- b2: 'created',
- c1: 'created',
- c2: 'created'
- )
- end
- end
end
private
diff --git a/spec/services/ci/create_pipeline_service/rate_limit_spec.rb b/spec/services/ci/create_pipeline_service/rate_limit_spec.rb
new file mode 100644
index 00000000000..caea165cc6c
--- /dev/null
+++ b/spec/services/ci/create_pipeline_service/rate_limit_spec.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe Ci::CreatePipelineService, :freeze_time, :clean_gitlab_redis_rate_limiting do
+ describe 'rate limiting' do
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:user) { project.first_owner }
+
+ let(:ref) { 'refs/heads/master' }
+
+ before do
+ stub_ci_pipeline_yaml_file(gitlab_ci_yaml)
+ stub_feature_flags(ci_throttle_pipelines_creation_dry_run: false)
+
+ allow(Gitlab::ApplicationRateLimiter).to receive(:rate_limits)
+ .and_return(pipelines_create: { threshold: 1, interval: 1.minute })
+ end
+
+ context 'when user is under the limit' do
+ let(:pipeline) { create_pipelines(count: 1) }
+
+ it 'allows pipeline creation' do
+ expect(pipeline).to be_created_successfully
+ expect(pipeline.statuses).not_to be_empty
+ end
+ end
+
+ context 'when user is over the limit' do
+ let(:pipeline) { create_pipelines }
+
+ it 'blocks pipeline creation' do
+ throttle_message = 'Too many pipelines created in the last minute. Try again later.'
+
+ expect(pipeline).not_to be_persisted
+ expect(pipeline.statuses).to be_empty
+ expect(pipeline.errors.added?(:base, throttle_message)).to be_truthy
+ end
+ end
+
+ context 'with different users' do
+ let(:other_user) { create(:user) }
+
+ before do
+ project.add_maintainer(other_user)
+ end
+
+ it 'allows other members to create pipelines' do
+ blocked_pipeline = create_pipelines(user: user)
+ allowed_pipeline = create_pipelines(count: 1, user: other_user)
+
+ expect(blocked_pipeline).not_to be_persisted
+ expect(allowed_pipeline).to be_created_successfully
+ end
+ end
+
+ context 'with different commits' do
+ it 'allows user to create pipeline' do
+ blocked_pipeline = create_pipelines(ref: ref)
+ allowed_pipeline = create_pipelines(count: 1, ref: 'refs/heads/feature')
+
+ expect(blocked_pipeline).not_to be_persisted
+ expect(allowed_pipeline).to be_created_successfully
+ end
+ end
+
+ context 'with different projects' do
+ let_it_be(:other_project) { create(:project, :repository) }
+
+ before do
+ other_project.add_maintainer(user)
+ end
+
+ it 'allows user to create pipeline' do
+ blocked_pipeline = create_pipelines(project: project)
+ allowed_pipeline = create_pipelines(count: 1, project: other_project)
+
+ expect(blocked_pipeline).not_to be_persisted
+ expect(allowed_pipeline).to be_created_successfully
+ end
+ end
+ end
+
+ def create_pipelines(attrs = {})
+ attrs.reverse_merge!(user: user, ref: ref, project: project, count: 2)
+
+ service = described_class.new(attrs[:project], attrs[:user], { ref: attrs[:ref] })
+
+ attrs[:count].pred.times { service.execute(:push) }
+ service.execute(:push).payload
+ end
+end
diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb
index a7026f5062e..943d70ba142 100644
--- a/spec/services/ci/create_pipeline_service_spec.rb
+++ b/spec/services/ci/create_pipeline_service_spec.rb
@@ -12,6 +12,10 @@ RSpec.describe Ci::CreatePipelineService do
before do
stub_ci_pipeline_to_return_yaml_file
+
+ # Disable rate limiting for pipeline creation
+ allow(Gitlab::ApplicationRateLimiter).to receive(:rate_limits)
+ .and_return(pipelines_create: { threshold: 0, interval: 1.minute })
end
describe '#execute' do
@@ -526,7 +530,7 @@ RSpec.describe Ci::CreatePipelineService do
let(:ci_yaml) do
<<-EOS
image:
- name: ruby:2.7
+ name: image:1.0
ports:
- 80
EOS
@@ -538,12 +542,12 @@ RSpec.describe Ci::CreatePipelineService do
context 'in the job image' do
let(:ci_yaml) do
<<-EOS
- image: ruby:2.7
+ image: image:1.0
test:
script: rspec
image:
- name: ruby:2.7
+ name: image:1.0
ports:
- 80
EOS
@@ -555,11 +559,11 @@ RSpec.describe Ci::CreatePipelineService do
context 'in the service' do
let(:ci_yaml) do
<<-EOS
- image: ruby:2.7
+ image: image:1.0
test:
script: rspec
- image: ruby:2.7
+ image: image:1.0
services:
- name: test
ports:
diff --git a/spec/services/ci/create_web_ide_terminal_service_spec.rb b/spec/services/ci/create_web_ide_terminal_service_spec.rb
index 0804773442d..3462b48cfe7 100644
--- a/spec/services/ci/create_web_ide_terminal_service_spec.rb
+++ b/spec/services/ci/create_web_ide_terminal_service_spec.rb
@@ -60,7 +60,7 @@ RSpec.describe Ci::CreateWebIdeTerminalService do
<<-EOS
terminal:
image:
- name: ruby:2.7
+ name: image:1.0
ports:
- 80
script: rspec
diff --git a/spec/services/ci/job_artifacts/destroy_all_expired_service_spec.rb b/spec/services/ci/job_artifacts/destroy_all_expired_service_spec.rb
index e95a449d615..1c6963e4a31 100644
--- a/spec/services/ci/job_artifacts/destroy_all_expired_service_spec.rb
+++ b/spec/services/ci/job_artifacts/destroy_all_expired_service_spec.rb
@@ -19,8 +19,23 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s
let!(:artifact) { create(:ci_job_artifact, :expired, job: job, locked: job.pipeline.locked) }
context 'with preloaded relationships' do
+ let(:second_artifact) { create(:ci_job_artifact, :expired, :junit, job: job) }
+
+ let(:more_artifacts) do
+ [
+ create(:ci_job_artifact, :expired, :sast, job: job),
+ create(:ci_job_artifact, :expired, :metadata, job: job),
+ create(:ci_job_artifact, :expired, :codequality, job: job),
+ create(:ci_job_artifact, :expired, :accessibility, job: job)
+ ]
+ end
+
before do
- stub_const("#{described_class}::LARGE_LOOP_LIMIT", 1)
+ stub_const("#{described_class}::LOOP_LIMIT", 1)
+
+ # This artifact-with-file is created before the control execution to ensure
+ # that the DeletedObject operations are accounted for in the query count.
+ second_artifact
end
context 'with ci_destroy_unlocked_job_artifacts feature flag disabled' do
@@ -28,19 +43,12 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s
stub_feature_flags(ci_destroy_unlocked_job_artifacts: false)
end
- it 'performs the smallest number of queries for job_artifacts' do
- log = ActiveRecord::QueryRecorder.new { subject }
+ it 'performs a consistent number of queries' do
+ control = ActiveRecord::QueryRecorder.new { service.execute }
- # SELECT expired ci_job_artifacts - 3 queries from each_batch
- # PRELOAD projects, routes, project_statistics
- # BEGIN
- # INSERT into ci_deleted_objects
- # DELETE loaded ci_job_artifacts
- # DELETE security_findings -- for EE
- # COMMIT
- # SELECT next expired ci_job_artifacts
+ more_artifacts
- expect(log.count).to be_within(1).of(10)
+ expect { subject }.not_to exceed_query_limit(control.count)
end
end
@@ -49,9 +57,12 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s
stub_feature_flags(ci_destroy_unlocked_job_artifacts: true)
end
- it 'performs the smallest number of queries for job_artifacts' do
- log = ActiveRecord::QueryRecorder.new { subject }
- expect(log.count).to be_within(1).of(8)
+ it 'performs a consistent number of queries' do
+ control = ActiveRecord::QueryRecorder.new { service.execute }
+
+ more_artifacts
+
+ expect { subject }.not_to exceed_query_limit(control.count)
end
end
end
@@ -119,7 +130,7 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s
let!(:artifact) { create(:ci_job_artifact, :expired, job: job, locked: job.pipeline.locked) }
before do
- stub_const("#{described_class}::LARGE_LOOP_LIMIT", 10)
+ stub_const("#{described_class}::LOOP_LIMIT", 10)
end
context 'when the import fails' do
@@ -189,8 +200,7 @@ RSpec.describe Ci::JobArtifacts::DestroyAllExpiredService, :clean_gitlab_redis_s
context 'when loop reached loop limit' do
before do
- stub_feature_flags(ci_artifact_fast_removal_large_loop_limit: false)
- stub_const("#{described_class}::SMALL_LOOP_LIMIT", 1)
+ stub_const("#{described_class}::LOOP_LIMIT", 1)
end
it 'destroys one artifact' do
diff --git a/spec/services/ci/job_artifacts/destroy_batch_service_spec.rb b/spec/services/ci/job_artifacts/destroy_batch_service_spec.rb
index 67d664a617b..5e77041a632 100644
--- a/spec/services/ci/job_artifacts/destroy_batch_service_spec.rb
+++ b/spec/services/ci/job_artifacts/destroy_batch_service_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe Ci::JobArtifacts::DestroyBatchService do
- let(:artifacts) { Ci::JobArtifact.where(id: [artifact_with_file.id, artifact_without_file.id]) }
+ let(:artifacts) { Ci::JobArtifact.where(id: [artifact_with_file.id, artifact_without_file.id, trace_artifact.id]) }
let(:service) { described_class.new(artifacts, pick_up_at: Time.current) }
let_it_be(:artifact_with_file, refind: true) do
@@ -18,6 +18,10 @@ RSpec.describe Ci::JobArtifacts::DestroyBatchService do
create(:ci_job_artifact)
end
+ let_it_be(:trace_artifact, refind: true) do
+ create(:ci_job_artifact, :trace, :expired)
+ end
+
describe '.execute' do
subject(:execute) { service.execute }
@@ -42,6 +46,12 @@ RSpec.describe Ci::JobArtifacts::DestroyBatchService do
execute
end
+ it 'preserves trace artifacts and removes any timestamp' do
+ expect { subject }
+ .to change { trace_artifact.reload.expire_at }.from(trace_artifact.expire_at).to(nil)
+ .and not_change { Ci::JobArtifact.exists?(trace_artifact.id) }
+ end
+
context 'ProjectStatistics' do
it 'resets project statistics' do
expect(ProjectStatistics).to receive(:increment_statistic).once
diff --git a/spec/services/ci/job_artifacts/update_unknown_locked_status_service_spec.rb b/spec/services/ci/job_artifacts/update_unknown_locked_status_service_spec.rb
new file mode 100644
index 00000000000..67412e41fb8
--- /dev/null
+++ b/spec/services/ci/job_artifacts/update_unknown_locked_status_service_spec.rb
@@ -0,0 +1,145 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::JobArtifacts::UpdateUnknownLockedStatusService, :clean_gitlab_redis_shared_state do
+ include ExclusiveLeaseHelpers
+
+ let(:service) { described_class.new }
+
+ describe '.execute' do
+ subject { service.execute }
+
+ let_it_be(:locked_pipeline) { create(:ci_pipeline, :artifacts_locked) }
+ let_it_be(:pipeline) { create(:ci_pipeline, :unlocked) }
+ let_it_be(:locked_job) { create(:ci_build, :success, pipeline: locked_pipeline) }
+ let_it_be(:job) { create(:ci_build, :success, pipeline: pipeline) }
+
+ let!(:unknown_unlocked_artifact) do
+ create(:ci_job_artifact, :junit, expire_at: 1.hour.ago, job: job, locked: Ci::JobArtifact.lockeds[:unknown])
+ end
+
+ let!(:unknown_locked_artifact) do
+ create(:ci_job_artifact, :lsif,
+ expire_at: 1.day.ago,
+ job: locked_job,
+ locked: Ci::JobArtifact.lockeds[:unknown]
+ )
+ end
+
+ let!(:unlocked_artifact) do
+ create(:ci_job_artifact, :archive, expire_at: 1.hour.ago, job: job, locked: Ci::JobArtifact.lockeds[:unlocked])
+ end
+
+ let!(:locked_artifact) do
+ create(:ci_job_artifact, :sast, :raw,
+ expire_at: 1.day.ago,
+ job: locked_job,
+ locked: Ci::JobArtifact.lockeds[:artifacts_locked]
+ )
+ end
+
+ context 'when artifacts are expired' do
+ it 'sets artifact_locked when the pipeline is locked' do
+ expect { service.execute }
+ .to change { unknown_locked_artifact.reload.locked }.from('unknown').to('artifacts_locked')
+ .and not_change { Ci::JobArtifact.exists?(locked_artifact.id) }
+ end
+
+ it 'destroys the artifact when the pipeline is unlocked' do
+ expect { subject }.to change { Ci::JobArtifact.exists?(unknown_unlocked_artifact.id) }.from(true).to(false)
+ end
+
+ it 'does not update ci_job_artifact rows with known locked values' do
+ expect { service.execute }
+ .to not_change(locked_artifact, :attributes)
+ .and not_change { Ci::JobArtifact.exists?(locked_artifact.id) }
+ .and not_change(unlocked_artifact, :attributes)
+ .and not_change { Ci::JobArtifact.exists?(unlocked_artifact.id) }
+ end
+
+ it 'logs the counts of affected artifacts' do
+ expect(subject).to eq({ removed: 1, locked: 1 })
+ end
+ end
+
+ context 'in a single iteration' do
+ before do
+ stub_const("#{described_class}::BATCH_SIZE", 1)
+ end
+
+ context 'due to the LOOP_TIMEOUT' do
+ before do
+ stub_const("#{described_class}::LOOP_TIMEOUT", 0.seconds)
+ end
+
+ it 'affects the earliest expired artifact first' do
+ subject
+
+ expect(unknown_locked_artifact.reload.locked).to eq('artifacts_locked')
+ expect(unknown_unlocked_artifact.reload.locked).to eq('unknown')
+ end
+
+ it 'reports the number of destroyed artifacts' do
+ is_expected.to eq({ removed: 0, locked: 1 })
+ end
+ end
+
+ context 'due to @loop_limit' do
+ before do
+ stub_const("#{described_class}::LARGE_LOOP_LIMIT", 1)
+ end
+
+ it 'affects the most recently expired artifact first' do
+ subject
+
+ expect(unknown_locked_artifact.reload.locked).to eq('artifacts_locked')
+ expect(unknown_unlocked_artifact.reload.locked).to eq('unknown')
+ end
+
+ it 'reports the number of destroyed artifacts' do
+ is_expected.to eq({ removed: 0, locked: 1 })
+ end
+ end
+ end
+
+ context 'when artifact is not expired' do
+ let!(:unknown_unlocked_artifact) do
+ create(:ci_job_artifact, :junit,
+ expire_at: 1.year.from_now,
+ job: job,
+ locked: Ci::JobArtifact.lockeds[:unknown]
+ )
+ end
+
+ it 'does not change the locked status' do
+ expect { service.execute }.not_to change { unknown_unlocked_artifact.locked }
+ expect(Ci::JobArtifact.exists?(unknown_unlocked_artifact.id)).to eq(true)
+ end
+ end
+
+ context 'when exclusive lease has already been taken by the other instance' do
+ before do
+ stub_exclusive_lease_taken(described_class::EXCLUSIVE_LOCK_KEY, timeout: described_class::LOCK_TIMEOUT)
+ end
+
+ it 'raises an error and' do
+ expect { subject }.to raise_error(Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError)
+ end
+ end
+
+ context 'when there are no unknown status artifacts' do
+ before do
+ Ci::JobArtifact.update_all(locked: :unlocked)
+ end
+
+ it 'does not raise error' do
+ expect { subject }.not_to raise_error
+ end
+
+ it 'reports the number of destroyed artifacts' do
+ is_expected.to eq({ removed: 0, locked: 0 })
+ end
+ end
+ end
+end
diff --git a/spec/services/ci/pipeline_processing/atomic_processing_service_spec.rb b/spec/services/ci/pipeline_processing/atomic_processing_service_spec.rb
index 7365ad162d2..5bc508447c1 100644
--- a/spec/services/ci/pipeline_processing/atomic_processing_service_spec.rb
+++ b/spec/services/ci/pipeline_processing/atomic_processing_service_spec.rb
@@ -725,7 +725,7 @@ RSpec.describe Ci::PipelineProcessing::AtomicProcessingService do
expect(builds_names).to eq ['build:1', 'build:2', 'test:1', 'test:2']
- Ci::Build.retry(pipeline.builds.find_by(name: 'test:2'), user).reset.success!
+ Ci::RetryJobService.new(pipeline.project, user).execute(pipeline.builds.find_by(name: 'test:2'))[:job].reset.success!
expect(builds_names).to eq ['build:1', 'build:2', 'test:1', 'test:2',
'test:2', 'deploy:1', 'deploy:2']
@@ -1111,11 +1111,11 @@ RSpec.describe Ci::PipelineProcessing::AtomicProcessingService do
end
def enqueue_scheduled(name)
- builds.scheduled.find_by(name: name).enqueue_scheduled
+ builds.scheduled.find_by(name: name).enqueue!
end
def retry_build(name)
- Ci::Build.retry(builds.find_by(name: name), user)
+ Ci::RetryJobService.new(project, user).execute(builds.find_by(name: name))
end
def manual_actions
diff --git a/spec/services/ci/register_job_service_spec.rb b/spec/services/ci/register_job_service_spec.rb
index 245118e71fa..74adbc4efc8 100644
--- a/spec/services/ci/register_job_service_spec.rb
+++ b/spec/services/ci/register_job_service_spec.rb
@@ -103,6 +103,20 @@ module Ci
pending_job.create_queuing_entry!
end
+ context 'when build owner has been blocked' do
+ let(:user) { create(:user, :blocked) }
+
+ before do
+ pending_job.update!(user: user)
+ end
+
+ it 'does not pick the build and drops the build' do
+ expect(execute(shared_runner)).to be_falsey
+
+ expect(pending_job.reload).to be_user_blocked
+ end
+ end
+
context 'for multiple builds' do
let!(:project2) { create :project, shared_runners_enabled: true }
let!(:pipeline2) { create :ci_pipeline, project: project2 }
diff --git a/spec/services/ci/retry_build_service_spec.rb b/spec/services/ci/retry_job_service_spec.rb
index 2421fd56c47..25aab73ab01 100644
--- a/spec/services/ci/retry_build_service_spec.rb
+++ b/spec/services/ci/retry_job_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::RetryBuildService do
+RSpec.describe Ci::RetryJobService do
let_it_be(:reporter) { create(:user) }
let_it_be(:developer) { create(:user) }
let_it_be(:project) { create(:project, :repository) }
@@ -17,7 +17,7 @@ RSpec.describe Ci::RetryBuildService do
name: 'test')
end
- let_it_be_with_refind(:build) { create(:ci_build, pipeline: pipeline, stage_id: stage.id) }
+ let_it_be_with_refind(:build) { create(:ci_build, :success, pipeline: pipeline, stage_id: stage.id) }
let(:user) { developer }
@@ -30,7 +30,7 @@ RSpec.describe Ci::RetryBuildService do
project.add_reporter(reporter)
end
- clone_accessors = described_class.clone_accessors.without(described_class.extra_accessors)
+ clone_accessors = ::Ci::Build.clone_accessors.without(::Ci::Build.extra_accessors)
reject_accessors =
%i[id status user token token_encrypted coverage trace runner
@@ -94,6 +94,10 @@ RSpec.describe Ci::RetryBuildService do
create(:terraform_state_version, build: build)
end
+ before do
+ build.update!(retried: false, status: :success)
+ end
+
describe 'clone accessors' do
let(:forbidden_associations) do
Ci::Build.reflect_on_all_associations.each_with_object(Set.new) do |assoc, memo|
@@ -156,8 +160,8 @@ RSpec.describe Ci::RetryBuildService do
Ci::Build.attribute_aliases.keys.map(&:to_sym) +
Ci::Build.reflect_on_all_associations.map(&:name) +
[:tag_list, :needs_attributes, :job_variables_attributes] -
- # ee-specific accessors should be tested in ee/spec/services/ci/retry_build_service_spec.rb instead
- described_class.extra_accessors -
+ # ee-specific accessors should be tested in ee/spec/services/ci/retry_job_service_spec.rb instead
+ Ci::Build.extra_accessors -
[:dast_site_profiles_build, :dast_scanner_profiles_build] # join tables
current_accessors.uniq!
@@ -170,7 +174,7 @@ RSpec.describe Ci::RetryBuildService do
describe '#execute' do
let(:new_build) do
travel_to(1.second.from_now) do
- service.execute(build)
+ service.execute(build)[:job]
end
end
@@ -248,7 +252,7 @@ RSpec.describe Ci::RetryBuildService do
context 'when build has scheduling_type' do
it 'does not call populate_scheduling_type!' do
- expect_any_instance_of(Ci::Pipeline).not_to receive(:ensure_scheduling_type!)
+ expect_any_instance_of(Ci::Pipeline).not_to receive(:ensure_scheduling_type!) # rubocop: disable RSpec/AnyInstanceOf
expect(new_build.scheduling_type).to eq('stage')
end
@@ -286,6 +290,18 @@ RSpec.describe Ci::RetryBuildService do
expect { service.execute(build) }
.to raise_error Gitlab::Access::AccessDeniedError
end
+
+ context 'when the job is not retryable' do
+ let(:build) { create(:ci_build, :created, pipeline: pipeline) }
+
+ it 'returns a ServiceResponse error' do
+ response = service.execute(build)
+
+ expect(response).to be_a(ServiceResponse)
+ expect(response).to be_error
+ expect(response.message).to eq("Job cannot be retried")
+ end
+ end
end
end
@@ -342,7 +358,7 @@ RSpec.describe Ci::RetryBuildService do
end
shared_examples_for 'when build with dynamic environment is retried' do
- let_it_be(:other_developer) { create(:user).tap { |u| project.add_developer(other_developer) } }
+ let_it_be(:other_developer) { create(:user).tap { |u| project.add_developer(u) } }
let(:environment_name) { 'review/$CI_COMMIT_REF_SLUG-$GITLAB_USER_ID' }
diff --git a/spec/services/ci/retry_pipeline_service_spec.rb b/spec/services/ci/retry_pipeline_service_spec.rb
index df1e159b5c0..24272801480 100644
--- a/spec/services/ci/retry_pipeline_service_spec.rb
+++ b/spec/services/ci/retry_pipeline_service_spec.rb
@@ -340,7 +340,7 @@ RSpec.describe Ci::RetryPipelineService, '#execute' do
context 'when user is not allowed to retry build' do
before do
build = create(:ci_build, pipeline: pipeline, status: :failed)
- allow_next_instance_of(Ci::RetryBuildService) do |service|
+ allow_next_instance_of(Ci::RetryJobService) do |service|
allow(service).to receive(:can?).with(user, :update_build, build).and_return(false)
end
end
diff --git a/spec/services/database/consistency_check_service_spec.rb b/spec/services/database/consistency_check_service_spec.rb
new file mode 100644
index 00000000000..2e642451432
--- /dev/null
+++ b/spec/services/database/consistency_check_service_spec.rb
@@ -0,0 +1,154 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Database::ConsistencyCheckService do
+ let(:batch_size) { 5 }
+ let(:max_batches) { 2 }
+
+ before do
+ stub_const("Gitlab::Database::ConsistencyChecker::BATCH_SIZE", batch_size)
+ stub_const("Gitlab::Database::ConsistencyChecker::MAX_BATCHES", max_batches)
+ end
+
+ after do
+ redis_shared_state_cleanup!
+ end
+
+ subject(:consistency_check_service) do
+ described_class.new(
+ source_model: Namespace,
+ target_model: Ci::NamespaceMirror,
+ source_columns: %w[id traversal_ids],
+ target_columns: %w[namespace_id traversal_ids]
+ )
+ end
+
+ describe '#random_start_id' do
+ let(:batch_size) { 5 }
+
+ before do
+ create_list(:namespace, 50) # This will also create Ci::NameSpaceMirror objects
+ end
+
+ it 'generates a random start_id within the records ids' do
+ 10.times do
+ start_id = subject.send(:random_start_id)
+ expect(start_id).to be_between(Namespace.first.id, Namespace.last.id).inclusive
+ end
+ end
+ end
+
+ describe '#execute' do
+ let(:empty_results) do
+ { batches: 0, matches: 0, mismatches: 0, mismatches_details: [] }
+ end
+
+ context 'when empty tables' do
+ it 'returns results with zero counters' do
+ result = consistency_check_service.execute
+
+ expect(result).to eq(empty_results)
+ end
+
+ it 'does not call the ConsistencyCheckService' do
+ expect(Gitlab::Database::ConsistencyChecker).not_to receive(:new)
+ consistency_check_service.execute
+ end
+ end
+
+ context 'no cursor has been saved before' do
+ let(:selected_start_id) { Namespace.order(:id).limit(5).pluck(:id).last }
+ let(:expected_next_start_id) { selected_start_id + batch_size * max_batches }
+
+ before do
+ create_list(:namespace, 50) # This will also create Ci::NameSpaceMirror objects
+ expect(consistency_check_service).to receive(:random_start_id).and_return(selected_start_id)
+ end
+
+ it 'picks a random start_id' do
+ expected_result = {
+ batches: 2,
+ matches: 10,
+ mismatches: 0,
+ mismatches_details: [],
+ start_id: selected_start_id,
+ next_start_id: expected_next_start_id
+ }
+ expect(consistency_check_service.execute).to eq(expected_result)
+ end
+
+ it 'calls the ConsistencyCheckService with the expected parameters' do
+ allow_next_instance_of(Gitlab::Database::ConsistencyChecker) do |instance|
+ expect(instance).to receive(:execute).with(start_id: selected_start_id).and_return({
+ batches: 2,
+ next_start_id: expected_next_start_id,
+ matches: 10,
+ mismatches: 0,
+ mismatches_details: []
+ })
+ end
+
+ expect(Gitlab::Database::ConsistencyChecker).to receive(:new).with(
+ source_model: Namespace,
+ target_model: Ci::NamespaceMirror,
+ source_columns: %w[id traversal_ids],
+ target_columns: %w[namespace_id traversal_ids]
+ ).and_call_original
+
+ expected_result = {
+ batches: 2,
+ start_id: selected_start_id,
+ next_start_id: expected_next_start_id,
+ matches: 10,
+ mismatches: 0,
+ mismatches_details: []
+ }
+ expect(consistency_check_service.execute).to eq(expected_result)
+ end
+
+ it 'saves the next_start_id in Redis for he next iteration' do
+ expect(consistency_check_service).to receive(:save_next_start_id).with(expected_next_start_id).and_call_original
+ consistency_check_service.execute
+ end
+ end
+
+ context 'cursor saved in Redis and moving' do
+ let(:first_namespace_id) { Namespace.order(:id).first.id }
+ let(:second_namespace_id) { Namespace.order(:id).second.id }
+
+ before do
+ create_list(:namespace, 30) # This will also create Ci::NameSpaceMirror objects
+ end
+
+ it "keeps moving the cursor with each call to the service" do
+ expect(consistency_check_service).to receive(:random_start_id).at_most(:once).and_return(first_namespace_id)
+
+ allow_next_instance_of(Gitlab::Database::ConsistencyChecker) do |instance|
+ expect(instance).to receive(:execute).ordered.with(start_id: first_namespace_id).and_call_original
+ expect(instance).to receive(:execute).ordered.with(start_id: first_namespace_id + 10).and_call_original
+ expect(instance).to receive(:execute).ordered.with(start_id: first_namespace_id + 20).and_call_original
+ # Gets back to the start of the table
+ expect(instance).to receive(:execute).ordered.with(start_id: first_namespace_id).and_call_original
+ end
+
+ 4.times do
+ consistency_check_service.execute
+ end
+ end
+
+ it "keeps moving the cursor from any start point" do
+ expect(consistency_check_service).to receive(:random_start_id).at_most(:once).and_return(second_namespace_id)
+
+ allow_next_instance_of(Gitlab::Database::ConsistencyChecker) do |instance|
+ expect(instance).to receive(:execute).ordered.with(start_id: second_namespace_id).and_call_original
+ expect(instance).to receive(:execute).ordered.with(start_id: second_namespace_id + 10).and_call_original
+ end
+
+ 2.times do
+ consistency_check_service.execute
+ end
+ end
+ end
+ end
+end
diff --git a/spec/services/deployments/update_environment_service_spec.rb b/spec/services/deployments/update_environment_service_spec.rb
index 6996563fdb8..0859aa2c9d1 100644
--- a/spec/services/deployments/update_environment_service_spec.rb
+++ b/spec/services/deployments/update_environment_service_spec.rb
@@ -286,6 +286,37 @@ RSpec.describe Deployments::UpdateEnvironmentService do
end
end
+ context 'when environment url uses a nested variable' do
+ let(:yaml_variables) do
+ [
+ { key: 'MAIN_DOMAIN', value: '${STACK_NAME}.example.com' },
+ { key: 'STACK_NAME', value: 'appname-${ENVIRONMENT_NAME}' },
+ { key: 'ENVIRONMENT_NAME', value: '${CI_COMMIT_REF_SLUG}' }
+ ]
+ end
+
+ let(:job) do
+ create(:ci_build,
+ :with_deployment,
+ pipeline: pipeline,
+ ref: 'master',
+ environment: 'production',
+ project: project,
+ yaml_variables: yaml_variables,
+ options: { environment: { name: 'production', url: 'http://$MAIN_DOMAIN' } })
+ end
+
+ it { is_expected.to eq('http://appname-master.example.com') }
+
+ context 'when the FF ci_expand_environment_name_and_url is disabled' do
+ before do
+ stub_feature_flags(ci_expand_environment_name_and_url: false)
+ end
+
+ it { is_expected.to eq('http://${STACK_NAME}.example.com') }
+ end
+ end
+
context 'when yaml environment does not have url' do
let(:job) { create(:ci_build, :with_deployment, pipeline: pipeline, environment: 'staging', project: project) }
diff --git a/spec/services/emails/create_service_spec.rb b/spec/services/emails/create_service_spec.rb
index 2fabf4ae66a..b13197f21b8 100644
--- a/spec/services/emails/create_service_spec.rb
+++ b/spec/services/emails/create_service_spec.rb
@@ -25,5 +25,34 @@ RSpec.describe Emails::CreateService do
expect(user.emails).to include(Email.find_by(opts))
end
+
+ it 'sends a notification to the user' do
+ expect_next_instance_of(NotificationService) do |notification_service|
+ expect(notification_service).to receive(:new_email_address_added)
+ end
+
+ service.execute
+ end
+
+ it 'does not send a notification when the email is not persisted' do
+ allow_next_instance_of(NotificationService) do |notification_service|
+ expect(notification_service).not_to receive(:new_email_address_added)
+ end
+
+ service.execute(email: 'invalid@@example.com')
+ end
+
+ it 'does not send a notification email when the email is the primary, because we are creating the user' do
+ allow_next_instance_of(NotificationService) do |notification_service|
+ expect(notification_service).not_to receive(:new_email_address_added)
+ end
+
+ # This is here to ensure that the service is actually called.
+ allow_next_instance_of(described_class) do |create_service|
+ expect(create_service).to receive(:execute).and_call_original
+ end
+
+ create(:user)
+ end
end
end
diff --git a/spec/services/environments/stop_service_spec.rb b/spec/services/environments/stop_service_spec.rb
index 362071c1c26..9e9ef127c67 100644
--- a/spec/services/environments/stop_service_spec.rb
+++ b/spec/services/environments/stop_service_spec.rb
@@ -198,6 +198,31 @@ RSpec.describe Environments::StopService do
expect(pipeline.environments_in_self_and_descendants.first).to be_stopped
end
+
+ context 'with environment related jobs ' do
+ let!(:environment) { create(:environment, :available, name: 'staging', project: project) }
+ let!(:prepare_staging_job) { create(:ci_build, :prepare_staging, pipeline: pipeline, project: project) }
+ let!(:start_staging_job) { create(:ci_build, :start_staging, :with_deployment, :manual, pipeline: pipeline, project: project) }
+ let!(:stop_staging_job) { create(:ci_build, :stop_staging, :manual, pipeline: pipeline, project: project) }
+
+ it 'does not stop environments that was not started by the merge request' do
+ subject
+
+ expect(prepare_staging_job.persisted_environment.state).to eq('available')
+ end
+
+ context 'when fix_related_environments_for_merge_requests feature flag is disabled' do
+ before do
+ stub_feature_flags(fix_related_environments_for_merge_requests: false)
+ end
+
+ it 'stops unrelated environments too' do
+ subject
+
+ expect(prepare_staging_job.persisted_environment.state).to eq('stopped')
+ end
+ end
+ end
end
context 'when user is a reporter' do
diff --git a/spec/services/event_create_service_spec.rb b/spec/services/event_create_service_spec.rb
index 611e821f3e5..c22099fe410 100644
--- a/spec/services/event_create_service_spec.rb
+++ b/spec/services/event_create_service_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe EventCreateService, :clean_gitlab_redis_cache, :clean_gitlab_redis_shared_state do
+ include SnowplowHelpers
+
let(:service) { described_class.new }
let_it_be(:user, reload: true) { create :user }
@@ -18,6 +20,28 @@ RSpec.describe EventCreateService, :clean_gitlab_redis_cache, :clean_gitlab_redi
end
end
+ shared_examples 'Snowplow event' do
+ it 'is not emitted if FF is disabled' do
+ stub_feature_flags(route_hll_to_snowplow: false)
+
+ subject
+
+ expect_no_snowplow_event
+ end
+
+ it 'is emitted' do
+ subject
+
+ expect_snowplow_event(
+ category: described_class.to_s,
+ action: 'action_active_users_project_repo',
+ namespace: project.namespace,
+ user: user,
+ project: project
+ )
+ end
+ end
+
describe 'Issues' do
describe '#open_issue' do
let(:issue) { create(:issue) }
@@ -247,7 +271,7 @@ RSpec.describe EventCreateService, :clean_gitlab_redis_cache, :clean_gitlab_redi
end
end
- describe '#push' do
+ describe '#push', :snowplow do
let(:push_data) do
{
commits: [
@@ -270,9 +294,11 @@ RSpec.describe EventCreateService, :clean_gitlab_redis_cache, :clean_gitlab_redi
it_behaves_like "it records the event in the event counter" do
let(:event_action) { Gitlab::UsageDataCounters::TrackUniqueEvents::PUSH_ACTION }
end
+
+ it_behaves_like 'Snowplow event'
end
- describe '#bulk_push' do
+ describe '#bulk_push', :snowplow do
let(:push_data) do
{
action: :created,
@@ -288,6 +314,8 @@ RSpec.describe EventCreateService, :clean_gitlab_redis_cache, :clean_gitlab_redi
it_behaves_like "it records the event in the event counter" do
let(:event_action) { Gitlab::UsageDataCounters::TrackUniqueEvents::PUSH_ACTION }
end
+
+ it_behaves_like 'Snowplow event'
end
describe 'Project' do
diff --git a/spec/services/git/branch_push_service_spec.rb b/spec/services/git/branch_push_service_spec.rb
index 5a637b0956b..57c130f76a4 100644
--- a/spec/services/git/branch_push_service_spec.rb
+++ b/spec/services/git/branch_push_service_spec.rb
@@ -721,4 +721,14 @@ RSpec.describe Git::BranchPushService, services: true do
it_behaves_like 'does not enqueue Jira sync worker'
end
end
+
+ describe 'project target platforms detection' do
+ subject(:execute) { execute_service(project, user, oldrev: blankrev, newrev: newrev, ref: ref) }
+
+ it 'calls enqueue_record_project_target_platforms on the project' do
+ expect(project).to receive(:enqueue_record_project_target_platforms)
+
+ execute
+ end
+ end
end
diff --git a/spec/services/groups/create_service_spec.rb b/spec/services/groups/create_service_spec.rb
index 819569d6e67..6e074f451c4 100644
--- a/spec/services/groups/create_service_spec.rb
+++ b/spec/services/groups/create_service_spec.rb
@@ -263,43 +263,6 @@ RSpec.describe Groups::CreateService, '#execute' do
end
end
- describe 'invite team email' do
- let(:service) { described_class.new(user, group_params) }
-
- before do
- allow(Namespaces::InviteTeamEmailWorker).to receive(:perform_in)
- end
-
- it 'is sent' do
- group = service.execute
- delay = Namespaces::InviteTeamEmailService::DELIVERY_DELAY_IN_MINUTES
- expect(Namespaces::InviteTeamEmailWorker).to have_received(:perform_in).with(delay, group.id, user.id)
- end
-
- context 'when group has not been persisted' do
- let(:service) { described_class.new(user, group_params.merge(name: '<script>alert("Attack!")</script>')) }
-
- it 'not sent' do
- expect(Namespaces::InviteTeamEmailWorker).not_to receive(:perform_in)
- service.execute
- end
- end
-
- context 'when group is not root' do
- let(:parent_group) { create :group }
- let(:service) { described_class.new(user, group_params.merge(parent_id: parent_group.id)) }
-
- before do
- parent_group.add_owner(user)
- end
-
- it 'not sent' do
- expect(Namespaces::InviteTeamEmailWorker).not_to receive(:perform_in)
- service.execute
- end
- end
- end
-
describe 'logged_out_marketing_header experiment', :experiment do
let(:service) { described_class.new(user, group_params) }
diff --git a/spec/services/groups/transfer_service_spec.rb b/spec/services/groups/transfer_service_spec.rb
index 3a696228382..1c4b7aac87e 100644
--- a/spec/services/groups/transfer_service_spec.rb
+++ b/spec/services/groups/transfer_service_spec.rb
@@ -17,7 +17,7 @@ RSpec.describe Groups::TransferService, :sidekiq_inline do
end
let_it_be(:user) { create(:user) }
- let_it_be(:new_parent_group) { create(:group, :public) }
+ let_it_be(:new_parent_group) { create(:group, :public, :crm_enabled) }
let!(:group_member) { create(:group_member, :owner, group: group, user: user) }
let(:transfer_service) { described_class.new(group, user) }
@@ -252,23 +252,6 @@ RSpec.describe Groups::TransferService, :sidekiq_inline do
expect(transfer_service.execute(new_parent_group)).to be_falsy
expect(transfer_service.error).to eq('Transfer failed: The parent group already has a subgroup or a project with the same path.')
end
-
- # currently when a project is created it gets a corresponding project namespace
- # so we test the case where a project without a project namespace is transferred
- # for backward compatibility
- context 'without project namespace' do
- before do
- project_namespace = project.project_namespace
- project.update_column(:project_namespace_id, nil)
- project_namespace.delete
- end
-
- it 'adds an error on group' do
- expect(project.reload.project_namespace).to be_nil
- expect(transfer_service.execute(new_parent_group)).to be_falsy
- expect(transfer_service.error).to eq('Transfer failed: Validation failed: Group URL has already been taken')
- end
- end
end
context 'when projects have project namespaces' do
@@ -876,5 +859,108 @@ RSpec.describe Groups::TransferService, :sidekiq_inline do
end
end
end
+
+ context 'crm' do
+ let(:root_group) { create(:group, :public) }
+ let(:subgroup) { create(:group, :public, parent: root_group) }
+ let(:another_subgroup) { create(:group, :public, parent: root_group) }
+ let(:subsubgroup) { create(:group, :public, parent: subgroup) }
+
+ let(:root_project) { create(:project, group: root_group) }
+ let(:sub_project) { create(:project, group: subgroup) }
+ let(:another_project) { create(:project, group: another_subgroup) }
+ let(:subsub_project) { create(:project, group: subsubgroup) }
+
+ let!(:contacts) { create_list(:contact, 4, group: root_group) }
+ let!(:organizations) { create_list(:organization, 2, group: root_group) }
+
+ before do
+ create(:issue_customer_relations_contact, contact: contacts[0], issue: create(:issue, project: root_project))
+ create(:issue_customer_relations_contact, contact: contacts[1], issue: create(:issue, project: sub_project))
+ create(:issue_customer_relations_contact, contact: contacts[2], issue: create(:issue, project: another_project))
+ create(:issue_customer_relations_contact, contact: contacts[3], issue: create(:issue, project: subsub_project))
+ root_group.add_owner(user)
+ end
+
+ context 'moving up' do
+ let(:group) { subsubgroup }
+
+ it 'retains issue contacts' do
+ expect { transfer_service.execute(root_group) }
+ .not_to change { CustomerRelations::IssueContact.count }
+ end
+ end
+
+ context 'moving down' do
+ let(:group) { subgroup }
+
+ it 'retains issue contacts' do
+ expect { transfer_service.execute(another_subgroup) }
+ .not_to change { CustomerRelations::IssueContact.count }
+ end
+ end
+
+ context 'moving sideways' do
+ let(:group) { subsubgroup }
+
+ it 'retains issue contacts' do
+ expect { transfer_service.execute(another_subgroup) }
+ .not_to change { CustomerRelations::IssueContact.count }
+ end
+ end
+
+ context 'moving to new root group' do
+ let(:group) { root_group }
+
+ before do
+ new_parent_group.add_owner(user)
+ end
+
+ it 'moves all crm objects' do
+ expect { transfer_service.execute(new_parent_group) }
+ .to change { root_group.contacts.count }.by(-4)
+ .and change { root_group.organizations.count }.by(-2)
+ end
+
+ it 'retains issue contacts' do
+ expect { transfer_service.execute(new_parent_group) }
+ .not_to change { CustomerRelations::IssueContact.count }
+ end
+ end
+
+ context 'moving to a subgroup within a new root group' do
+ let(:group) { root_group }
+ let(:subgroup_in_new_parent_group) { create(:group, parent: new_parent_group) }
+
+ context 'with permission on the root group' do
+ before do
+ new_parent_group.add_owner(user)
+ end
+
+ it 'moves all crm objects' do
+ expect { transfer_service.execute(subgroup_in_new_parent_group) }
+ .to change { root_group.contacts.count }.by(-4)
+ .and change { root_group.organizations.count }.by(-2)
+ end
+
+ it 'retains issue contacts' do
+ expect { transfer_service.execute(subgroup_in_new_parent_group) }
+ .not_to change { CustomerRelations::IssueContact.count }
+ end
+ end
+
+ context 'with permission on the subgroup' do
+ before do
+ subgroup_in_new_parent_group.add_owner(user)
+ end
+
+ it 'raises error' do
+ transfer_service.execute(subgroup_in_new_parent_group)
+
+ expect(transfer_service.error).to eq("Transfer failed: Group contains contacts/organizations and you don't have enough permissions to move them to the new root group.")
+ end
+ end
+ end
+ end
end
end
diff --git a/spec/services/import/github_service_spec.rb b/spec/services/import/github_service_spec.rb
index 04a94d96f67..58afae1e647 100644
--- a/spec/services/import/github_service_spec.rb
+++ b/spec/services/import/github_service_spec.rb
@@ -34,11 +34,11 @@ RSpec.describe Import::GithubService do
subject.execute(access_params, :github)
end
- it 'returns an error' do
+ it 'returns an error with message and code' do
result = subject.execute(access_params, :github)
expect(result).to include(
- message: 'Import failed due to a GitHub error: Not Found',
+ message: 'Import failed due to a GitHub error: Not Found (HTTP 404)',
status: :error,
http_status: :unprocessable_entity
)
diff --git a/spec/services/incident_management/issuable_escalation_statuses/build_service_spec.rb b/spec/services/incident_management/issuable_escalation_statuses/build_service_spec.rb
new file mode 100644
index 00000000000..c20a0688ac2
--- /dev/null
+++ b/spec/services/incident_management/issuable_escalation_statuses/build_service_spec.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe IncidentManagement::IssuableEscalationStatuses::BuildService do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:incident, reload: true) { create(:incident, project: project) }
+
+ let(:service) { described_class.new(incident) }
+
+ subject(:execute) { service.execute }
+
+ it_behaves_like 'initializes new escalation status with expected attributes'
+
+ context 'with associated alert' do
+ let_it_be(:alert) { create(:alert_management_alert, :acknowledged, project: project, issue: incident) }
+
+ it_behaves_like 'initializes new escalation status with expected attributes', { status_event: :acknowledge }
+ end
+end
diff --git a/spec/services/incident_management/issuable_escalation_statuses/create_service_spec.rb b/spec/services/incident_management/issuable_escalation_statuses/create_service_spec.rb
index 8fbab361ec4..2c7d330766c 100644
--- a/spec/services/incident_management/issuable_escalation_statuses/create_service_spec.rb
+++ b/spec/services/incident_management/issuable_escalation_statuses/create_service_spec.rb
@@ -8,12 +8,12 @@ RSpec.describe IncidentManagement::IssuableEscalationStatuses::CreateService do
let(:incident) { create(:incident, project: project) }
let(:service) { described_class.new(incident) }
- subject(:execute) { service.execute}
+ subject(:execute) { service.execute }
it 'creates an escalation status for the incident with no policy set' do
- expect { execute }.to change { incident.reload.incident_management_issuable_escalation_status }.from(nil)
+ expect { execute }.to change { incident.reload.escalation_status }.from(nil)
- status = incident.incident_management_issuable_escalation_status
+ status = incident.escalation_status
expect(status.policy_id).to eq(nil)
expect(status.escalations_started_at).to eq(nil)
@@ -24,7 +24,22 @@ RSpec.describe IncidentManagement::IssuableEscalationStatuses::CreateService do
let!(:existing_status) { create(:incident_management_issuable_escalation_status, issue: incident) }
it 'exits without changing anything' do
- expect { execute }.not_to change { incident.reload.incident_management_issuable_escalation_status }
+ expect { execute }.not_to change { incident.reload.escalation_status }
+ end
+ end
+
+ context 'with associated alert' do
+ before do
+ create(:alert_management_alert, :acknowledged, project: project, issue: incident)
+ end
+
+ it 'creates an escalation status matching the alert attributes' do
+ expect { execute }.to change { incident.reload.escalation_status }.from(nil)
+ expect(incident.escalation_status).to have_attributes(
+ status_name: :acknowledged,
+ policy_id: nil,
+ escalations_started_at: nil
+ )
end
end
end
diff --git a/spec/services/incident_management/issuable_escalation_statuses/prepare_update_service_spec.rb b/spec/services/incident_management/issuable_escalation_statuses/prepare_update_service_spec.rb
index b30b3a69ae6..25164df40ca 100644
--- a/spec/services/incident_management/issuable_escalation_statuses/prepare_update_service_spec.rb
+++ b/spec/services/incident_management/issuable_escalation_statuses/prepare_update_service_spec.rb
@@ -71,7 +71,12 @@ RSpec.describe IncidentManagement::IssuableEscalationStatuses::PrepareUpdateServ
context 'when an IssuableEscalationStatus record for the issue does not exist' do
let(:issue) { create(:incident) }
- it_behaves_like 'availability error response'
+ it_behaves_like 'successful response', { status_event: :acknowledge }
+
+ it 'initializes an issuable escalation status record' do
+ expect { result }.not_to change(::IncidentManagement::IssuableEscalationStatus, :count)
+ expect(issue.escalation_status).to be_present
+ end
end
context 'when called without params' do
diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb
index 6d3c3dd4e39..d496857bb25 100644
--- a/spec/services/issues/update_service_spec.rb
+++ b/spec/services/issues/update_service_spec.rb
@@ -9,8 +9,8 @@ RSpec.describe Issues::UpdateService, :mailer do
let_it_be(:guest) { create(:user) }
let_it_be(:group) { create(:group, :public, :crm_enabled) }
let_it_be(:project, reload: true) { create(:project, :repository, group: group) }
- let_it_be(:label) { create(:label, project: project) }
- let_it_be(:label2) { create(:label, project: project) }
+ let_it_be(:label) { create(:label, title: 'a', project: project) }
+ let_it_be(:label2) { create(:label, title: 'b', project: project) }
let_it_be(:milestone) { create(:milestone, project: project) }
let(:issue) do
@@ -1224,6 +1224,18 @@ RSpec.describe Issues::UpdateService, :mailer do
end
context 'without an escalation status record' do
+ it 'creates a new record' do
+ expect { update_issue(opts) }.to change(::IncidentManagement::IssuableEscalationStatus, :count).by(1)
+ end
+
+ it_behaves_like 'updates the escalation status record', :acknowledged
+ end
+
+ context 'with :incident_escalations feature flag disabled' do
+ before do
+ stub_feature_flags(incident_escalations: false)
+ end
+
it_behaves_like 'does not change the status record'
end
end
@@ -1349,6 +1361,19 @@ RSpec.describe Issues::UpdateService, :mailer do
end
end
+ context 'labels are updated' do
+ let(:label_a) { label }
+ let(:label_b) { label2 }
+ let(:issuable) { issue }
+
+ it_behaves_like 'keeps issuable labels sorted after update'
+ it_behaves_like 'broadcasting issuable labels updates'
+
+ def update_issuable(update_params)
+ update_issue(update_params)
+ end
+ end
+
it_behaves_like 'issuable record that supports quick actions' do
let(:existing_issue) { create(:issue, project: project) }
let(:issuable) { described_class.new(project: project, current_user: user, params: params).execute(existing_issue) }
diff --git a/spec/services/members/create_service_spec.rb b/spec/services/members/create_service_spec.rb
index 4396a0d3ec3..25437be1e78 100644
--- a/spec/services/members/create_service_spec.rb
+++ b/spec/services/members/create_service_spec.rb
@@ -6,6 +6,7 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_
let_it_be(:source, reload: true) { create(:project) }
let_it_be(:user) { create(:user) }
let_it_be(:member) { create(:user) }
+ let_it_be(:user_invited_by_id) { create(:user) }
let_it_be(:user_ids) { member.id.to_s }
let_it_be(:access_level) { Gitlab::Access::GUEST }
@@ -49,6 +50,36 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_
expect(OnboardingProgress.completed?(source.namespace, :user_added)).to be(true)
end
+ context 'when user_id is passed as an integer' do
+ let(:user_ids) { member.id }
+
+ it 'successfully creates member' do
+ expect(execute_service[:status]).to eq(:success)
+ expect(source.users).to include member
+ expect(OnboardingProgress.completed?(source.namespace, :user_added)).to be(true)
+ end
+ end
+
+ context 'with user_ids as an array of integers' do
+ let(:user_ids) { [member.id, user_invited_by_id.id] }
+
+ it 'successfully creates members' do
+ expect(execute_service[:status]).to eq(:success)
+ expect(source.users).to include(member, user_invited_by_id)
+ expect(OnboardingProgress.completed?(source.namespace, :user_added)).to be(true)
+ end
+ end
+
+ context 'with user_ids as an array of strings' do
+ let(:user_ids) { [member.id.to_s, user_invited_by_id.id.to_s] }
+
+ it 'successfully creates members' do
+ expect(execute_service[:status]).to eq(:success)
+ expect(source.users).to include(member, user_invited_by_id)
+ expect(OnboardingProgress.completed?(source.namespace, :user_added)).to be(true)
+ end
+ end
+
context 'when executing on a group' do
let_it_be(:source) { create(:group) }
@@ -112,6 +143,32 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_
end
end
+ context 'when adding a project_bot' do
+ let_it_be(:project_bot) { create(:user, :project_bot) }
+
+ let(:user_ids) { project_bot.id }
+
+ context 'when project_bot is already a member' do
+ before do
+ source.add_developer(project_bot)
+ end
+
+ it 'does not update the member' do
+ expect(execute_service[:status]).to eq(:error)
+ expect(execute_service[:message]).to eq("#{project_bot.username}: not authorized to update member")
+ expect(OnboardingProgress.completed?(source.namespace, :user_added)).to be(false)
+ end
+ end
+
+ context 'when project_bot is not already a member' do
+ it 'adds the member' do
+ expect(execute_service[:status]).to eq(:success)
+ expect(source.users).to include project_bot
+ expect(OnboardingProgress.completed?(source.namespace, :user_added)).to be(true)
+ end
+ end
+ end
+
context 'when tracking the invite source', :snowplow do
context 'when invite_source is not passed' do
let(:additional_params) { {} }
@@ -191,6 +248,15 @@ RSpec.describe Members::CreateService, :aggregate_failures, :clean_gitlab_redis_
)
end
+ context 'when it is an invite by email passed to user_ids' do
+ let(:user_ids) { 'email@example.org' }
+
+ it 'does not create task issues' do
+ expect(TasksToBeDone::CreateWorker).not_to receive(:perform_async)
+ execute_service
+ end
+ end
+
context 'when passing many user ids' do
before do
stub_licensed_features(multiple_issue_assignees: false)
diff --git a/spec/services/members/creator_service_spec.rb b/spec/services/members/creator_service_spec.rb
new file mode 100644
index 00000000000..ff5bf705b6c
--- /dev/null
+++ b/spec/services/members/creator_service_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Members::CreatorService do
+ let_it_be(:source, reload: true) { create(:group, :public) }
+ let_it_be(:member_type) { GroupMember }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:current_user) { create(:user) }
+
+ describe '#execute' do
+ it 'raises error for new member on authorization check implementation' do
+ expect do
+ described_class.new(source, user, :maintainer, current_user: current_user).execute
+ end.to raise_error(NotImplementedError)
+ end
+
+ it 'raises error for an existing member on authorization check implementation' do
+ source.add_developer(user)
+
+ expect do
+ described_class.new(source, user, :maintainer, current_user: current_user).execute
+ end.to raise_error(NotImplementedError)
+ end
+ end
+end
diff --git a/spec/services/members/invite_service_spec.rb b/spec/services/members/invite_service_spec.rb
index 8ceb9979f33..ab740138a8b 100644
--- a/spec/services/members/invite_service_spec.rb
+++ b/spec/services/members/invite_service_spec.rb
@@ -6,6 +6,7 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_
let_it_be(:project, reload: true) { create(:project) }
let_it_be(:user) { project.first_owner }
let_it_be(:project_user) { create(:user) }
+ let_it_be(:user_invited_by_id) { create(:user) }
let_it_be(:namespace) { project.namespace }
let(:params) { {} }
@@ -41,148 +42,422 @@ RSpec.describe Members::InviteService, :aggregate_failures, :clean_gitlab_redis_
end
end
- context 'when email is not a valid email' do
- let(:params) { { email: '_bogus_' } }
+ context 'when invites are passed as array' do
+ context 'with emails' do
+ let(:params) { { email: %w[email@example.org email2@example.org] } }
- it 'returns an error' do
- expect_not_to_create_members
- expect(result[:message]['_bogus_']).to eq("Invite email is invalid")
+ it 'successfully creates members' do
+ expect_to_create_members(count: 2)
+ expect(result[:status]).to eq(:success)
+ end
+ end
+
+ context 'with user_ids as integers' do
+ let(:params) { { user_ids: [project_user.id, user_invited_by_id.id] } }
+
+ it 'successfully creates members' do
+ expect_to_create_members(count: 2)
+ expect(result[:status]).to eq(:success)
+ end
end
- it_behaves_like 'does not record an onboarding progress action'
+ context 'with user_ids as strings' do
+ let(:params) { { user_ids: [project_user.id.to_s, user_invited_by_id.id.to_s] } }
+
+ it 'successfully creates members' do
+ expect_to_create_members(count: 2)
+ expect(result[:status]).to eq(:success)
+ end
+ end
+
+ context 'with a mixture of emails and user_ids' do
+ let(:params) do
+ { user_ids: [project_user.id, user_invited_by_id.id], email: %w[email@example.org email2@example.org] }
+ end
+
+ it 'successfully creates members' do
+ expect_to_create_members(count: 4)
+ expect(result[:status]).to eq(:success)
+ end
+ end
end
- context 'when emails are passed as an array' do
- let(:params) { { email: %w[email@example.org email2@example.org] } }
+ context 'with multiple invites passed as strings' do
+ context 'with emails' do
+ let(:params) { { email: 'email@example.org,email2@example.org' } }
- it 'successfully creates members' do
- expect_to_create_members(count: 2)
- expect(result[:status]).to eq(:success)
+ it 'successfully creates members' do
+ expect_to_create_members(count: 2)
+ expect(result[:status]).to eq(:success)
+ end
+ end
+
+ context 'with user_ids' do
+ let(:params) { { user_ids: "#{project_user.id},#{user_invited_by_id.id}" } }
+
+ it 'successfully creates members' do
+ expect_to_create_members(count: 2)
+ expect(result[:status]).to eq(:success)
+ end
+ end
+
+ context 'with a mixture of emails and user_ids' do
+ let(:params) do
+ { user_ids: "#{project_user.id},#{user_invited_by_id.id}", email: 'email@example.org,email2@example.org' }
+ end
+
+ it 'successfully creates members' do
+ expect_to_create_members(count: 4)
+ expect(result[:status]).to eq(:success)
+ end
end
end
- context 'when emails are passed as an empty string' do
- let(:params) { { email: '' } }
+ context 'when invites formats are mixed' do
+ context 'when user_ids is an array and emails is a string' do
+ let(:params) do
+ { user_ids: [project_user.id, user_invited_by_id.id], email: 'email@example.org,email2@example.org' }
+ end
+
+ it 'successfully creates members' do
+ expect_to_create_members(count: 4)
+ expect(result[:status]).to eq(:success)
+ end
+ end
+
+ context 'when user_ids is a string and emails is an array' do
+ let(:params) do
+ { user_ids: "#{project_user.id},#{user_invited_by_id.id}", email: %w[email@example.org email2@example.org] }
+ end
- it 'returns an error' do
- expect_not_to_create_members
- expect(result[:message]).to eq('Emails cannot be blank')
+ it 'successfully creates members' do
+ expect_to_create_members(count: 4)
+ expect(result[:status]).to eq(:success)
+ end
end
end
- context 'when email param is not included' do
- it 'returns an error' do
- expect_not_to_create_members
- expect(result[:message]).to eq('Emails cannot be blank')
+ context 'when invites are passed in different formats' do
+ context 'when emails are passed as an empty string' do
+ let(:params) { { email: '' } }
+
+ it 'returns an error' do
+ expect_not_to_create_members
+ expect(result[:message]).to eq('Invites cannot be blank')
+ end
+ end
+
+ context 'when user_ids are passed as an empty string' do
+ let(:params) { { user_ids: '' } }
+
+ it 'returns an error' do
+ expect_not_to_create_members
+ expect(result[:message]).to eq('Invites cannot be blank')
+ end
+ end
+
+ context 'when user_ids and emails are both passed as empty strings' do
+ let(:params) { { user_ids: '', email: '' } }
+
+ it 'returns an error' do
+ expect_not_to_create_members
+ expect(result[:message]).to eq('Invites cannot be blank')
+ end
+ end
+
+ context 'when user_id is passed as an integer' do
+ let(:params) { { user_ids: project_user.id } }
+
+ it 'successfully creates member' do
+ expect_to_create_members(count: 1)
+ expect(result[:status]).to eq(:success)
+ end
+ end
+
+ context 'when invite params are not included' do
+ it 'returns an error' do
+ expect_not_to_create_members
+ expect(result[:message]).to eq('Invites cannot be blank')
+ end
end
end
context 'when email is not a valid email format' do
- let(:params) { { email: '_bogus_' } }
+ context 'with singular email' do
+ let(:params) { { email: '_bogus_' } }
- it 'returns an error' do
- expect { result }.not_to change(ProjectMember, :count)
- expect(result[:status]).to eq(:error)
- expect(result[:message][params[:email]]).to eq("Invite email is invalid")
+ it 'returns an error' do
+ expect_not_to_create_members
+ expect(result[:status]).to eq(:error)
+ expect(result[:message][params[:email]]).to eq("Invite email is invalid")
+ end
+
+ it_behaves_like 'does not record an onboarding progress action'
+ end
+
+ context 'with user_id and singular invalid email' do
+ let(:params) { { user_ids: project_user.id, email: '_bogus_' } }
+
+ it 'has partial success' do
+ expect_to_create_members(count: 1)
+ expect(project.users).to include project_user
+
+ expect(result[:status]).to eq(:error)
+ expect(result[:message][params[:email]]).to eq("Invite email is invalid")
+ end
end
end
- context 'when duplicate email addresses are passed' do
- let(:params) { { email: 'email@example.org,email@example.org' } }
+ context 'with duplicate invites' do
+ context 'with duplicate emails' do
+ let(:params) { { email: 'email@example.org,email@example.org' } }
- it 'only creates one member per unique address' do
- expect_to_create_members(count: 1)
- expect(result[:status]).to eq(:success)
+ it 'only creates one member per unique invite' do
+ expect_to_create_members(count: 1)
+ expect(result[:status]).to eq(:success)
+ end
+ end
+
+ context 'with duplicate user ids' do
+ let(:params) { { user_ids: "#{project_user.id},#{project_user.id}" } }
+
+ it 'only creates one member per unique invite' do
+ expect_to_create_members(count: 1)
+ expect(result[:status]).to eq(:success)
+ end
+ end
+
+ context 'with duplicate member by adding as user id and email' do
+ let(:params) { { user_ids: project_user.id, email: project_user.email } }
+
+ it 'only creates one member per unique invite' do
+ expect_to_create_members(count: 1)
+ expect(result[:status]).to eq(:success)
+ end
end
end
- context 'when observing email limits' do
- let_it_be(:emails) { Array(1..101).map { |n| "email#{n}@example.com" } }
+ context 'when observing invite limits' do
+ context 'with emails and in general' do
+ let_it_be(:emails) { Array(1..101).map { |n| "email#{n}@example.com" } }
- context 'when over the allowed default limit of emails' do
- let(:params) { { email: emails } }
+ context 'when over the allowed default limit of emails' do
+ let(:params) { { email: emails } }
- it 'limits the number of emails to 100' do
- expect_not_to_create_members
- expect(result[:message]).to eq('Too many users specified (limit is 100)')
+ it 'limits the number of emails to 100' do
+ expect_not_to_create_members
+ expect(result[:message]).to eq('Too many users specified (limit is 100)')
+ end
+ end
+
+ context 'when over the allowed custom limit of emails' do
+ let(:params) { { email: 'email@example.org,email2@example.org', limit: 1 } }
+
+ it 'limits the number of emails to the limit supplied' do
+ expect_not_to_create_members
+ expect(result[:message]).to eq('Too many users specified (limit is 1)')
+ end
+ end
+
+ context 'when limit allowed is disabled via limit param' do
+ let(:params) { { email: emails, limit: -1 } }
+
+ it 'does not limit number of emails' do
+ expect_to_create_members(count: 101)
+ expect(result[:status]).to eq(:success)
+ end
end
end
- context 'when over the allowed custom limit of emails' do
- let(:params) { { email: 'email@example.org,email2@example.org', limit: 1 } }
+ context 'with user_ids' do
+ let(:user_ids) { 1.upto(101).to_a.join(',') }
+ let(:params) { { user_ids: user_ids } }
- it 'limits the number of emails to the limit supplied' do
+ it 'limits the number of users to 100' do
expect_not_to_create_members
- expect(result[:message]).to eq('Too many users specified (limit is 1)')
+ expect(result[:message]).to eq('Too many users specified (limit is 100)')
end
end
+ end
- context 'when limit allowed is disabled via limit param' do
- let(:params) { { email: emails, limit: -1 } }
+ context 'with an existing user' do
+ context 'with email' do
+ let(:params) { { email: project_user.email } }
- it 'does not limit number of emails' do
- expect_to_create_members(count: 101)
+ it 'adds an existing user to members' do
+ expect_to_create_members(count: 1)
expect(result[:status]).to eq(:success)
+ expect(project.users).to include project_user
end
end
- end
- context 'when email belongs to an existing user' do
- let(:params) { { email: project_user.email } }
+ context 'with user_id' do
+ let(:params) { { user_ids: project_user.id } }
- it 'adds an existing user to members' do
- expect_to_create_members(count: 1)
- expect(result[:status]).to eq(:success)
- expect(project.users).to include project_user
+ it_behaves_like 'records an onboarding progress action', :user_added
+
+ it 'adds an existing user to members' do
+ expect_to_create_members(count: 1)
+ expect(result[:status]).to eq(:success)
+ expect(project.users).to include project_user
+ end
+
+ context 'when assigning tasks to be done' do
+ let(:params) do
+ { user_ids: project_user.id, tasks_to_be_done: %w(ci code), tasks_project_id: project.id }
+ end
+
+ it 'creates 2 task issues', :aggregate_failures do
+ expect(TasksToBeDone::CreateWorker)
+ .to receive(:perform_async)
+ .with(anything, user.id, [project_user.id])
+ .once
+ .and_call_original
+ expect { result }.to change { project.issues.count }.by(2)
+
+ expect(project.issues).to all have_attributes(project: project, author: user)
+ end
+ end
end
end
context 'when access level is not valid' do
- let(:params) { { email: project_user.email, access_level: -1 } }
+ context 'with email' do
+ let(:params) { { email: project_user.email, access_level: -1 } }
- it 'returns an error' do
- expect_not_to_create_members
- expect(result[:message][project_user.email])
- .to eq("Access level is not included in the list")
+ it 'returns an error' do
+ expect_not_to_create_members
+ expect(result[:message][project_user.email]).to eq("Access level is not included in the list")
+ end
end
- end
- context 'when invite already exists for an included email' do
- let!(:invited_member) { create(:project_member, :invited, project: project) }
- let(:params) { { email: "#{invited_member.invite_email},#{project_user.email}" } }
+ context 'with user_id' do
+ let(:params) { { user_ids: user_invited_by_id.id, access_level: -1 } }
- it 'adds new email and returns an error for the already invited email' do
- expect_to_create_members(count: 1)
- expect(result[:status]).to eq(:error)
- expect(result[:message][invited_member.invite_email])
- .to eq("The member's email address has already been taken")
- expect(project.users).to include project_user
+ it 'returns an error' do
+ expect_not_to_create_members
+ expect(result[:message][user_invited_by_id.username]).to eq("Access level is not included in the list")
+ end
end
- end
- context 'when access request already exists for an included email' do
- let!(:requested_member) { create(:project_member, :access_request, project: project) }
- let(:params) { { email: "#{requested_member.user.email},#{project_user.email}" } }
+ context 'with a mix of user_id and email' do
+ let(:params) { { user_ids: user_invited_by_id.id, email: project_user.email, access_level: -1 } }
- it 'adds new email and returns an error for the already invited email' do
- expect_to_create_members(count: 1)
- expect(result[:status]).to eq(:error)
- expect(result[:message][requested_member.user.email])
- .to eq("User already exists in source")
- expect(project.users).to include project_user
+ it 'returns errors' do
+ expect_not_to_create_members
+ expect(result[:message][project_user.email]).to eq("Access level is not included in the list")
+ expect(result[:message][user_invited_by_id.username]).to eq("Access level is not included in the list")
+ end
end
end
- context 'when email is already a member on the project' do
- let!(:existing_member) { create(:project_member, :guest, project: project) }
- let(:params) { { email: "#{existing_member.user.email},#{project_user.email}" } }
+ context 'when member already exists' do
+ context 'with email' do
+ let!(:invited_member) { create(:project_member, :invited, project: project) }
+ let(:params) { { email: "#{invited_member.invite_email},#{project_user.email}" } }
+
+ it 'adds new email and returns an error for the already invited email' do
+ expect_to_create_members(count: 1)
+ expect(result[:status]).to eq(:error)
+ expect(result[:message][invited_member.invite_email])
+ .to eq("The member's email address has already been taken")
+ expect(project.users).to include project_user
+ end
+ end
- it 'adds new email and returns an error for the already invited email' do
- expect_to_create_members(count: 1)
- expect(result[:status]).to eq(:error)
- expect(result[:message][existing_member.user.email])
- .to eq("User already exists in source")
- expect(project.users).to include project_user
+ context 'when email is already a member with a user on the project' do
+ let!(:existing_member) { create(:project_member, :guest, project: project) }
+ let(:params) { { email: "#{existing_member.user.email}" } }
+
+ it 'returns an error for the already invited email' do
+ expect_not_to_create_members
+ expect(result[:message][existing_member.user.email]).to eq("User already exists in source")
+ end
+
+ context 'when email belongs to an existing user as a secondary email' do
+ let(:secondary_email) { create(:email, email: 'secondary@example.com', user: existing_member.user) }
+ let(:params) { { email: "#{secondary_email.email}" } }
+
+ it 'returns an error for the already invited email' do
+ expect_not_to_create_members
+ expect(result[:message][secondary_email.email]).to eq("User already exists in source")
+ end
+ end
+ end
+
+ context 'with user_id that already exists' do
+ let!(:existing_member) { create(:project_member, project: project, user: project_user) }
+ let(:params) { { user_ids: existing_member.user_id } }
+
+ it 'does not add the member again and is successful' do
+ expect_to_create_members(count: 0)
+ expect(project.users).to include project_user
+ end
+ end
+
+ context 'with user_id that already exists with a lower access_level' do
+ let!(:existing_member) { create(:project_member, :developer, project: project, user: project_user) }
+ let(:params) { { user_ids: existing_member.user_id, access_level: ProjectMember::MAINTAINER } }
+
+ it 'does not add the member again and updates the access_level' do
+ expect_to_create_members(count: 0)
+ expect(project.users).to include project_user
+ expect(existing_member.reset.access_level).to eq ProjectMember::MAINTAINER
+ end
+ end
+
+ context 'with user_id that already exists with a higher access_level' do
+ let!(:existing_member) { create(:project_member, :developer, project: project, user: project_user) }
+ let(:params) { { user_ids: existing_member.user_id, access_level: ProjectMember::GUEST } }
+
+ it 'does not add the member again and updates the access_level' do
+ expect_to_create_members(count: 0)
+ expect(project.users).to include project_user
+ expect(existing_member.reset.access_level).to eq ProjectMember::GUEST
+ end
+ end
+
+ context 'with user_id that already exists in a parent group' do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:group_member) { create(:group_member, :developer, source: group, user: project_user) }
+ let_it_be(:project, reload: true) { create(:project, group: group) }
+ let_it_be(:user) { project.creator }
+
+ before_all do
+ project.add_maintainer(user)
+ end
+
+ context 'when access_level is lower than inheriting member' do
+ let(:params) { { user_ids: group_member.user_id, access_level: ProjectMember::GUEST }}
+
+ it 'does not add the member and returns an error' do
+ msg = "Access level should be greater than or equal " \
+ "to Developer inherited membership from group #{group.name}"
+
+ expect_not_to_create_members
+ expect(result[:message][project_user.username]).to eq msg
+ end
+ end
+
+ context 'when access_level is the same as the inheriting member' do
+ let(:params) { { user_ids: group_member.user_id, access_level: ProjectMember::DEVELOPER }}
+
+ it 'adds the member with correct access_level' do
+ expect_to_create_members(count: 1)
+ expect(project.users).to include project_user
+ expect(project.project_members.last.access_level).to eq ProjectMember::DEVELOPER
+ end
+ end
+
+ context 'when access_level is greater than the inheriting member' do
+ let(:params) { { user_ids: group_member.user_id, access_level: ProjectMember::MAINTAINER }}
+
+ it 'adds the member with correct access_level' do
+ expect_to_create_members(count: 1)
+ expect(project.users).to include project_user
+ expect(project.project_members.last.access_level).to eq ProjectMember::MAINTAINER
+ end
+ end
end
end
diff --git a/spec/services/merge_requests/update_service_spec.rb b/spec/services/merge_requests/update_service_spec.rb
index eb587797201..30095ebeb50 100644
--- a/spec/services/merge_requests/update_service_spec.rb
+++ b/spec/services/merge_requests/update_service_spec.rb
@@ -10,7 +10,7 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
let(:user) { create(:user) }
let(:user2) { create(:user) }
let(:user3) { create(:user) }
- let(:label) { create(:label, project: project) }
+ let(:label) { create(:label, title: 'a', project: project) }
let(:label2) { create(:label) }
let(:milestone) { create(:milestone, project: project) }
@@ -1192,5 +1192,18 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
let(:existing_merge_request) { create(:merge_request, source_project: project) }
let(:issuable) { described_class.new(project: project, current_user: user, params: params).execute(existing_merge_request) }
end
+
+ context 'labels are updated' do
+ let(:label_a) { label }
+ let(:label_b) { create(:label, title: 'b', project: project) }
+ let(:issuable) { merge_request }
+
+ it_behaves_like 'keeps issuable labels sorted after update'
+ it_behaves_like 'broadcasting issuable labels updates'
+
+ def update_issuable(update_params)
+ update_merge_request(update_params)
+ end
+ end
end
end
diff --git a/spec/services/metrics/dashboard/custom_dashboard_service_spec.rb b/spec/services/metrics/dashboard/custom_dashboard_service_spec.rb
index 5dc30c156ac..afeb1646005 100644
--- a/spec/services/metrics/dashboard/custom_dashboard_service_spec.rb
+++ b/spec/services/metrics/dashboard/custom_dashboard_service_spec.rb
@@ -15,7 +15,7 @@ RSpec.describe Metrics::Dashboard::CustomDashboardService, :use_clean_rails_memo
subject { described_class.new(*service_params) }
before do
- project.add_maintainer(user)
+ project.add_maintainer(user) if user
end
describe '#raw_dashboard' do
diff --git a/spec/services/metrics/dashboard/custom_metric_embed_service_spec.rb b/spec/services/metrics/dashboard/custom_metric_embed_service_spec.rb
index 82321dbc822..127cec6275c 100644
--- a/spec/services/metrics/dashboard/custom_metric_embed_service_spec.rb
+++ b/spec/services/metrics/dashboard/custom_metric_embed_service_spec.rb
@@ -10,7 +10,7 @@ RSpec.describe Metrics::Dashboard::CustomMetricEmbedService do
let_it_be(:environment) { create(:environment, project: project) }
before do
- project.add_maintainer(user)
+ project.add_maintainer(user) if user
end
let(:dashboard_path) { system_dashboard_path }
diff --git a/spec/services/metrics/dashboard/default_embed_service_spec.rb b/spec/services/metrics/dashboard/default_embed_service_spec.rb
index 2ce10eac026..647778eadc1 100644
--- a/spec/services/metrics/dashboard/default_embed_service_spec.rb
+++ b/spec/services/metrics/dashboard/default_embed_service_spec.rb
@@ -10,7 +10,7 @@ RSpec.describe Metrics::Dashboard::DefaultEmbedService, :use_clean_rails_memory_
let_it_be(:environment) { create(:environment, project: project) }
before do
- project.add_maintainer(user)
+ project.add_maintainer(user) if user
end
describe '.valid_params?' do
diff --git a/spec/services/metrics/dashboard/dynamic_embed_service_spec.rb b/spec/services/metrics/dashboard/dynamic_embed_service_spec.rb
index 3c533b0c464..5eb8f24266c 100644
--- a/spec/services/metrics/dashboard/dynamic_embed_service_spec.rb
+++ b/spec/services/metrics/dashboard/dynamic_embed_service_spec.rb
@@ -10,7 +10,7 @@ RSpec.describe Metrics::Dashboard::DynamicEmbedService, :use_clean_rails_memory_
let_it_be(:environment) { create(:environment, project: project) }
before do
- project.add_maintainer(user)
+ project.add_maintainer(user) if user
end
let(:dashboard_path) { '.gitlab/dashboards/test.yml' }
diff --git a/spec/services/metrics/dashboard/self_monitoring_dashboard_service_spec.rb b/spec/services/metrics/dashboard/self_monitoring_dashboard_service_spec.rb
index 33b7e3c85cd..d0cefdbeb30 100644
--- a/spec/services/metrics/dashboard/self_monitoring_dashboard_service_spec.rb
+++ b/spec/services/metrics/dashboard/self_monitoring_dashboard_service_spec.rb
@@ -12,7 +12,7 @@ RSpec.describe Metrics::Dashboard::SelfMonitoringDashboardService, :use_clean_ra
let(:service_params) { [project, user, { environment: environment }] }
before do
- project.add_maintainer(user)
+ project.add_maintainer(user) if user
stub_application_setting(self_monitoring_project_id: project.id)
end
diff --git a/spec/services/metrics/dashboard/system_dashboard_service_spec.rb b/spec/services/metrics/dashboard/system_dashboard_service_spec.rb
index ced7c29b507..e1c6aaeec66 100644
--- a/spec/services/metrics/dashboard/system_dashboard_service_spec.rb
+++ b/spec/services/metrics/dashboard/system_dashboard_service_spec.rb
@@ -15,7 +15,7 @@ RSpec.describe Metrics::Dashboard::SystemDashboardService, :use_clean_rails_memo
subject { described_class.new(*service_params) }
before do
- project.add_maintainer(user)
+ project.add_maintainer(user) if user
end
describe '#raw_dashboard' do
diff --git a/spec/services/metrics/dashboard/transient_embed_service_spec.rb b/spec/services/metrics/dashboard/transient_embed_service_spec.rb
index 3fd0c97d909..53ea83c33d6 100644
--- a/spec/services/metrics/dashboard/transient_embed_service_spec.rb
+++ b/spec/services/metrics/dashboard/transient_embed_service_spec.rb
@@ -8,7 +8,7 @@ RSpec.describe Metrics::Dashboard::TransientEmbedService, :use_clean_rails_memor
let_it_be(:environment) { create(:environment, project: project) }
before do
- project.add_maintainer(user)
+ project.add_maintainer(user) if user
end
describe '.valid_params?' do
diff --git a/spec/services/namespaces/in_product_marketing_email_records_spec.rb b/spec/services/namespaces/in_product_marketing_email_records_spec.rb
index e5f1b275f9c..d80e20135d5 100644
--- a/spec/services/namespaces/in_product_marketing_email_records_spec.rb
+++ b/spec/services/namespaces/in_product_marketing_email_records_spec.rb
@@ -15,7 +15,7 @@ RSpec.describe Namespaces::InProductMarketingEmailRecords do
before do
allow(Users::InProductMarketingEmail).to receive(:bulk_insert!)
- records.add(user, :invite_team, 0)
+ records.add(user, :team_short, 0)
records.add(user, :create, 1)
end
@@ -33,13 +33,13 @@ RSpec.describe Namespaces::InProductMarketingEmailRecords do
describe '#add' do
it 'adds a Users::InProductMarketingEmail record to its records' do
freeze_time do
- records.add(user, :invite_team, 0)
+ records.add(user, :team_short, 0)
records.add(user, :create, 1)
first, second = records.records
expect(first).to be_a Users::InProductMarketingEmail
- expect(first.track.to_sym).to eq :invite_team
+ expect(first.track.to_sym).to eq :team_short
expect(first.series).to eq 0
expect(first.created_at).to eq Time.zone.now
expect(first.updated_at).to eq Time.zone.now
diff --git a/spec/services/namespaces/in_product_marketing_emails_service_spec.rb b/spec/services/namespaces/in_product_marketing_emails_service_spec.rb
index 58ba577b7e7..de84666ca1d 100644
--- a/spec/services/namespaces/in_product_marketing_emails_service_spec.rb
+++ b/spec/services/namespaces/in_product_marketing_emails_service_spec.rb
@@ -183,7 +183,7 @@ RSpec.describe Namespaces::InProductMarketingEmailsService, '#execute' do
expect(
Users::InProductMarketingEmail.where(
user: user,
- track: Users::InProductMarketingEmail.tracks[:create],
+ track: Users::InProductMarketingEmail::ACTIVE_TRACKS[:create],
series: 0
)
).to exist
diff --git a/spec/services/namespaces/invite_team_email_service_spec.rb b/spec/services/namespaces/invite_team_email_service_spec.rb
deleted file mode 100644
index 60ba91f433d..00000000000
--- a/spec/services/namespaces/invite_team_email_service_spec.rb
+++ /dev/null
@@ -1,128 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Namespaces::InviteTeamEmailService do
- let_it_be(:user) { create(:user, email_opted_in: true) }
-
- let(:track) { described_class::TRACK }
- let(:series) { 0 }
-
- let(:setup_for_company) { true }
- let(:parent_group) { nil }
- let(:group) { create(:group, parent: parent_group) }
-
- subject(:action) { described_class.send_email(user, group) }
-
- before do
- group.add_owner(user)
- allow(group).to receive(:setup_for_company).and_return(setup_for_company)
- allow(Notify).to receive(:in_product_marketing_email).and_return(double(deliver_later: nil))
- end
-
- RSpec::Matchers.define :send_invite_team_email do |*args|
- match do
- expect(Notify).to have_received(:in_product_marketing_email).with(*args).once
- end
-
- match_when_negated do
- expect(Notify).not_to have_received(:in_product_marketing_email)
- end
- end
-
- shared_examples 'unexperimented' do
- it { is_expected.not_to send_invite_team_email }
-
- it 'does not record sent email' do
- expect { subject }.not_to change { Users::InProductMarketingEmail.count }
- end
- end
-
- shared_examples 'candidate' do
- it { is_expected.to send_invite_team_email(user.id, group.id, track, 0) }
-
- it 'records sent email' do
- expect { subject }.to change { Users::InProductMarketingEmail.count }.by(1)
-
- expect(
- Users::InProductMarketingEmail.where(
- user: user,
- track: track,
- series: 0
- )
- ).to exist
- end
-
- it_behaves_like 'tracks assignment and records the subject', :invite_team_email, :group do
- subject { group }
- end
- end
-
- context 'when group is in control path' do
- before do
- stub_experiments(invite_team_email: :control)
- end
-
- it { is_expected.not_to send_invite_team_email }
-
- it 'does not record sent email' do
- expect { subject }.not_to change { Users::InProductMarketingEmail.count }
- end
-
- it_behaves_like 'tracks assignment and records the subject', :invite_team_email, :group do
- subject { group }
- end
- end
-
- context 'when group is in candidate path' do
- before do
- stub_experiments(invite_team_email: :candidate)
- end
-
- it_behaves_like 'candidate'
-
- context 'when the user has not opted into marketing emails' do
- let(:user) { create(:user, email_opted_in: false ) }
-
- it_behaves_like 'unexperimented'
- end
-
- context 'when group is not top level' do
- it_behaves_like 'unexperimented' do
- let(:parent_group) do
- create(:group).tap { |g| g.add_owner(user) }
- end
- end
- end
-
- context 'when group is not set up for a company' do
- it_behaves_like 'unexperimented' do
- let(:setup_for_company) { nil }
- end
- end
-
- context 'when other users have already been added to the group' do
- before do
- group.add_developer(create(:user))
- end
-
- it_behaves_like 'unexperimented'
- end
-
- context 'when other users have already been invited to the group' do
- before do
- group.add_developer('not_a_user_yet@example.com')
- end
-
- it_behaves_like 'unexperimented'
- end
-
- context 'when the user already got sent the email' do
- before do
- create(:in_product_marketing_email, user: user, track: track, series: 0)
- end
-
- it_behaves_like 'unexperimented'
- end
- end
-end
diff --git a/spec/services/notes/build_service_spec.rb b/spec/services/notes/build_service_spec.rb
index b7b08390dcd..0e2bbcc8c66 100644
--- a/spec/services/notes/build_service_spec.rb
+++ b/spec/services/notes/build_service_spec.rb
@@ -5,12 +5,14 @@ require 'spec_helper'
RSpec.describe Notes::BuildService do
include AdminModeHelper
- let(:note) { create(:discussion_note_on_issue) }
- let(:project) { note.project }
- let(:author) { note.author }
- let(:user) { author }
- let(:merge_request) { create(:merge_request, source_project: project) }
- let(:mr_note) { create(:discussion_note_on_merge_request, noteable: merge_request, project: project, author: note.author) }
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:note) { create(:discussion_note_on_issue, project: project) }
+ let_it_be(:author) { note.author }
+ let_it_be(:user) { author }
+ let_it_be(:noteable_author) { create(:user) }
+ let_it_be(:other_user) { create(:user) }
+ let_it_be(:external) { create(:user, :external) }
+
let(:base_params) { { note: 'Test' } }
let(:params) { {} }
@@ -28,11 +30,10 @@ RSpec.describe Notes::BuildService do
end
context 'when discussion is resolved' do
- let(:params) { { in_reply_to_discussion_id: mr_note.discussion_id } }
+ let_it_be(:merge_request) { create(:merge_request, source_project: project) }
+ let_it_be(:mr_note) { create(:discussion_note_on_merge_request, :resolved, noteable: merge_request, project: project, author: author) }
- before do
- mr_note.resolve!(author)
- end
+ let(:params) { { in_reply_to_discussion_id: mr_note.discussion_id } }
it 'resolves the note' do
expect(new_note).to be_valid
@@ -57,7 +58,7 @@ RSpec.describe Notes::BuildService do
end
context 'when user has no access to discussion' do
- let(:user) { create(:user) }
+ let(:user) { other_user }
it 'sets an error' do
expect(new_note.errors[:base]).to include('Discussion to reply to cannot be found')
@@ -65,16 +66,14 @@ RSpec.describe Notes::BuildService do
end
context 'personal snippet note' do
- def reply(note, user = nil)
- user ||= create(:user)
-
+ def reply(note, user = other_user)
described_class.new(nil,
user,
note: 'Test',
in_reply_to_discussion_id: note.discussion_id).execute
end
- let(:snippet_author) { create(:user) }
+ let_it_be(:snippet_author) { noteable_author }
context 'when a snippet is public' do
it 'creates a reply note' do
@@ -89,8 +88,8 @@ RSpec.describe Notes::BuildService do
end
context 'when a snippet is private' do
- let(:snippet) { create(:personal_snippet, :private, author: snippet_author) }
- let(:note) { create(:discussion_note_on_personal_snippet, noteable: snippet) }
+ let_it_be(:snippet) { create(:personal_snippet, :private, author: snippet_author) }
+ let_it_be(:note) { create(:discussion_note_on_personal_snippet, noteable: snippet) }
it 'creates a reply note when the author replies' do
new_note = reply(note, snippet_author)
@@ -107,8 +106,8 @@ RSpec.describe Notes::BuildService do
end
context 'when a snippet is internal' do
- let(:snippet) { create(:personal_snippet, :internal, author: snippet_author) }
- let(:note) { create(:discussion_note_on_personal_snippet, noteable: snippet) }
+ let_it_be(:snippet) { create(:personal_snippet, :internal, author: snippet_author) }
+ let_it_be(:note) { create(:discussion_note_on_personal_snippet, noteable: snippet) }
it 'creates a reply note when the author replies' do
new_note = reply(note, snippet_author)
@@ -125,7 +124,7 @@ RSpec.describe Notes::BuildService do
end
it 'sets an error when an external user replies' do
- new_note = reply(note, create(:user, :external))
+ new_note = reply(note, external)
expect(new_note.errors[:base]).to include('Discussion to reply to cannot be found')
end
@@ -134,7 +133,8 @@ RSpec.describe Notes::BuildService do
end
context 'when replying to individual note' do
- let(:note) { create(:note_on_issue) }
+ let_it_be(:note) { create(:note_on_issue, project: project) }
+
let(:params) { { in_reply_to_discussion_id: note.discussion_id } }
it 'sets the note up to be in reply to that note' do
@@ -144,7 +144,7 @@ RSpec.describe Notes::BuildService do
end
context 'when noteable does not support replies' do
- let(:note) { create(:note_on_commit) }
+ let_it_be(:note) { create(:note_on_commit, project: project) }
it 'builds another individual note' do
expect(new_note).to be_valid
@@ -155,87 +155,137 @@ RSpec.describe Notes::BuildService do
end
context 'confidential comments' do
+ let_it_be(:project) { create(:project, :public) }
+ let_it_be(:guest) { create(:user) }
+ let_it_be(:reporter) { create(:user) }
+ let_it_be(:admin) { create(:admin) }
+ let_it_be(:issuable_assignee) { other_user }
+ let_it_be(:issue) do
+ create(:issue, project: project, author: noteable_author, assignees: [issuable_assignee])
+ end
+
before do
- project.add_reporter(author)
+ project.add_guest(guest)
+ project.add_reporter(reporter)
end
- context 'when replying to a confidential comment' do
- let(:note) { create(:note_on_issue, confidential: true) }
- let(:params) { { in_reply_to_discussion_id: note.discussion_id, confidential: false } }
+ context 'when creating a new confidential comment' do
+ let(:params) { { confidential: true, noteable: issue } }
- context 'when the user can read confidential comments' do
- it '`confidential` param is ignored and set to `true`' do
- expect(new_note.confidential).to be_truthy
- end
+ shared_examples 'user allowed to set comment as confidential' do
+ it { expect(new_note.confidential).to be_truthy }
end
- context 'when the user cannot read confidential comments' do
- let(:user) { create(:user) }
+ shared_examples 'user not allowed to set comment as confidential' do
+ it { expect(new_note.confidential).to be_falsey }
+ end
- it 'returns `Discussion to reply to cannot be found` error' do
- expect(new_note.errors.added?(:base, "Discussion to reply to cannot be found")).to be true
+ context 'reporter' do
+ let(:user) { reporter }
+
+ it_behaves_like 'user allowed to set comment as confidential'
+ end
+
+ context 'issuable author' do
+ let(:user) { noteable_author }
+
+ it_behaves_like 'user allowed to set comment as confidential'
+ end
+
+ context 'issuable assignee' do
+ let(:user) { issuable_assignee }
+
+ it_behaves_like 'user allowed to set comment as confidential'
+ end
+
+ context 'admin' do
+ before do
+ enable_admin_mode!(admin)
end
+
+ let(:user) { admin }
+
+ it_behaves_like 'user allowed to set comment as confidential'
end
- end
- context 'when replying to a public comment' do
- let(:note) { create(:note_on_issue, confidential: false) }
- let(:params) { { in_reply_to_discussion_id: note.discussion_id, confidential: true } }
+ context 'external' do
+ let(:user) { external }
- it '`confidential` param is ignored and set to `false`' do
- expect(new_note.confidential).to be_falsey
+ it_behaves_like 'user not allowed to set comment as confidential'
+ end
+
+ context 'guest' do
+ let(:user) { guest }
+
+ it_behaves_like 'user not allowed to set comment as confidential'
end
end
- context 'when creating a new comment' do
- context 'when the `confidential` note flag is set to `true`' do
- context 'when the user is allowed (reporter)' do
- let(:params) { { confidential: true, noteable: merge_request } }
+ context 'when replying to a confidential comment' do
+ let_it_be(:note) { create(:note_on_issue, confidential: true, noteable: issue, project: project) }
- it 'note `confidential` flag is set to `true`' do
- expect(new_note.confidential).to be_truthy
- end
- end
+ let(:params) { { in_reply_to_discussion_id: note.discussion_id, confidential: false } }
- context 'when the user is allowed (issuable author)' do
- let(:user) { create(:user) }
- let(:issue) { create(:issue, author: user) }
- let(:params) { { confidential: true, noteable: issue } }
+ shared_examples 'returns `Discussion to reply to cannot be found` error' do
+ it do
+ expect(new_note.errors.added?(:base, "Discussion to reply to cannot be found")).to be true
+ end
+ end
- it 'note `confidential` flag is set to `true`' do
- expect(new_note.confidential).to be_truthy
- end
+ shared_examples 'confidential set to `true`' do
+ it '`confidential` param is ignored to match the parent note confidentiality' do
+ expect(new_note.confidential).to be_truthy
end
+ end
- context 'when the user is allowed (admin)' do
- before do
- enable_admin_mode!(admin)
- end
+ context 'with reporter access' do
+ let(:user) { reporter }
+
+ it_behaves_like 'confidential set to `true`'
+ end
- let(:admin) { create(:admin) }
- let(:params) { { confidential: true, noteable: merge_request } }
+ context 'with admin access' do
+ let(:user) { admin }
- it 'note `confidential` flag is set to `true`' do
- expect(new_note.confidential).to be_truthy
- end
+ before do
+ enable_admin_mode!(admin)
end
- context 'when the user is not allowed' do
- let(:user) { create(:user) }
- let(:params) { { confidential: true, noteable: merge_request } }
+ it_behaves_like 'confidential set to `true`'
+ end
+
+ context 'with noteable author' do
+ let(:user) { note.noteable.author }
- it 'note `confidential` flag is set to `false`' do
- expect(new_note.confidential).to be_falsey
- end
- end
+ it_behaves_like 'confidential set to `true`'
end
- context 'when the `confidential` note flag is set to `false`' do
- let(:params) { { confidential: false, noteable: merge_request } }
+ context 'with noteable assignee' do
+ let(:user) { issuable_assignee }
- it 'note `confidential` flag is set to `false`' do
- expect(new_note.confidential).to be_falsey
- end
+ it_behaves_like 'confidential set to `true`'
+ end
+
+ context 'with guest access' do
+ let(:user) { guest }
+
+ it_behaves_like 'returns `Discussion to reply to cannot be found` error'
+ end
+
+ context 'with external user' do
+ let(:user) { external }
+
+ it_behaves_like 'returns `Discussion to reply to cannot be found` error'
+ end
+ end
+
+ context 'when replying to a public comment' do
+ let_it_be(:note) { create(:note_on_issue, confidential: false, noteable: issue, project: project) }
+
+ let(:params) { { in_reply_to_discussion_id: note.discussion_id, confidential: true } }
+
+ it '`confidential` param is ignored and set to `false`' do
+ expect(new_note.confidential).to be_falsey
end
end
end
diff --git a/spec/services/notes/create_service_spec.rb b/spec/services/notes/create_service_spec.rb
index babbd44a9f1..b0410123630 100644
--- a/spec/services/notes/create_service_spec.rb
+++ b/spec/services/notes/create_service_spec.rb
@@ -106,7 +106,8 @@ RSpec.describe Notes::CreateService do
type: 'DiffNote',
noteable_type: 'MergeRequest',
noteable_id: merge_request.id,
- position: position.to_h)
+ position: position.to_h,
+ confidential: false)
end
before do
@@ -141,7 +142,8 @@ RSpec.describe Notes::CreateService do
type: 'DiffNote',
noteable_type: 'MergeRequest',
noteable_id: merge_request.id,
- position: position.to_h)
+ position: position.to_h,
+ confidential: false)
expect(merge_request).not_to receive(:diffs)
@@ -173,7 +175,8 @@ RSpec.describe Notes::CreateService do
type: 'DiffNote',
noteable_type: 'MergeRequest',
noteable_id: merge_request.id,
- position: position.to_h)
+ position: position.to_h,
+ confidential: false)
end
it 'note is associated with a note diff file' do
@@ -201,7 +204,8 @@ RSpec.describe Notes::CreateService do
type: 'DiffNote',
noteable_type: 'MergeRequest',
noteable_id: merge_request.id,
- position: position.to_h)
+ position: position.to_h,
+ confidential: false)
end
it 'note is not associated with a note diff file' do
@@ -230,7 +234,8 @@ RSpec.describe Notes::CreateService do
type: 'DiffNote',
noteable_type: 'MergeRequest',
noteable_id: merge_request.id,
- position: image_position.to_h)
+ position: image_position.to_h,
+ confidential: false)
end
it 'note is not associated with a note diff file' do
@@ -306,7 +311,7 @@ RSpec.describe Notes::CreateService do
let_it_be(:merge_request) { create(:merge_request, source_project: project, labels: [bug_label]) }
let(:issuable) { merge_request }
- let(:note_params) { opts.merge(noteable_type: 'MergeRequest', noteable_id: merge_request.id) }
+ let(:note_params) { opts.merge(noteable_type: 'MergeRequest', noteable_id: merge_request.id, confidential: false) }
let(:merge_request_quick_actions) do
[
QuickAction.new(
diff --git a/spec/services/notes/update_service_spec.rb b/spec/services/notes/update_service_spec.rb
index 71ac1641ca5..ae7bea30944 100644
--- a/spec/services/notes/update_service_spec.rb
+++ b/spec/services/notes/update_service_spec.rb
@@ -138,45 +138,6 @@ RSpec.describe Notes::UpdateService do
end
end
- context 'setting confidentiality' do
- let(:opts) { { confidential: true } }
-
- context 'simple note' do
- it 'updates the confidentiality' do
- expect { update_note(opts) }.to change { note.reload.confidential }.from(nil).to(true)
- end
- end
-
- context 'discussion notes' do
- let(:note) { create(:discussion_note, project: project, noteable: issue, author: user, note: "Old note #{user2.to_reference}") }
- let!(:response_note_1) { create(:discussion_note, project: project, noteable: issue, in_reply_to: note) }
- let!(:response_note_2) { create(:discussion_note, project: project, noteable: issue, in_reply_to: note, confidential: false) }
- let!(:other_note) { create(:note, project: project, noteable: issue) }
-
- context 'when updating the root note' do
- it 'updates the confidentiality of the root note and all the responses' do
- update_note(opts)
-
- expect(note.reload.confidential).to be_truthy
- expect(response_note_1.reload.confidential).to be_truthy
- expect(response_note_2.reload.confidential).to be_truthy
- expect(other_note.reload.confidential).to be_falsey
- end
- end
-
- context 'when updating one of the response notes' do
- it 'updates only the confidentiality of the note that is being updated' do
- Notes::UpdateService.new(project, user, opts).execute(response_note_1)
-
- expect(note.reload.confidential).to be_falsey
- expect(response_note_1.reload.confidential).to be_truthy
- expect(response_note_2.reload.confidential).to be_falsey
- expect(other_note.reload.confidential).to be_falsey
- end
- end
- end
- end
-
context 'todos' do
shared_examples 'does not update todos' do
it 'keep todos' do
diff --git a/spec/services/notification_recipients/builder/default_spec.rb b/spec/services/notification_recipients/builder/default_spec.rb
index c142cc11384..4d0ddc7c4f7 100644
--- a/spec/services/notification_recipients/builder/default_spec.rb
+++ b/spec/services/notification_recipients/builder/default_spec.rb
@@ -5,7 +5,7 @@ require 'spec_helper'
RSpec.describe NotificationRecipients::Builder::Default do
describe '#build!' do
let_it_be(:group) { create(:group, :public) }
- let_it_be(:project) { create(:project, :public, group: group).tap { |p| p.add_developer(project_watcher) } }
+ let_it_be(:project) { create(:project, :public, group: group).tap { |p| p.add_developer(project_watcher) if project_watcher } }
let_it_be(:target) { create(:issue, project: project) }
let_it_be(:current_user) { create(:user) }
diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb
index 399b2b4be2d..d2d55c5ab79 100644
--- a/spec/services/notification_service_spec.rb
+++ b/spec/services/notification_service_spec.rb
@@ -376,6 +376,17 @@ RSpec.describe NotificationService, :mailer do
end
end
+ describe '#new_email_address_added' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:email) { create(:email, user: user) }
+
+ subject { notification.new_email_address_added(user, email) }
+
+ it 'sends email to the user' do
+ expect { subject }.to have_enqueued_email(user, email, mail: 'new_email_address_added_email')
+ end
+ end
+
describe 'Notes' do
context 'issue note' do
let_it_be(:project) { create(:project, :private) }
@@ -2090,6 +2101,70 @@ RSpec.describe NotificationService, :mailer do
should_not_email(@u_lazy_participant)
end
+ describe 'triggers push_to_merge_request_email with corresponding email' do
+ let_it_be(:merge_request) { create(:merge_request, author: author, source_project: project) }
+
+ def mock_commits(length)
+ Array.new(length) { |i| double(:commit, short_id: SecureRandom.hex(4), title: "This is commit #{i}") }
+ end
+
+ def commit_to_hash(commit)
+ { short_id: commit.short_id, title: commit.title }
+ end
+
+ let(:existing_commits) { mock_commits(50) }
+ let(:expected_existing_commits) { [commit_to_hash(existing_commits.first), commit_to_hash(existing_commits.last)] }
+
+ before do
+ allow(::Notify).to receive(:push_to_merge_request_email).and_call_original
+ end
+
+ where(:number_of_new_commits, :number_of_new_commits_displayed) do
+ limit = described_class::NEW_COMMIT_EMAIL_DISPLAY_LIMIT
+ [
+ [0, 0],
+ [limit - 2, limit - 2],
+ [limit - 1, limit - 1],
+ [limit, limit],
+ [limit + 1, limit],
+ [limit + 2, limit]
+ ]
+ end
+
+ with_them do
+ let(:new_commits) { mock_commits(number_of_new_commits) }
+ let(:expected_new_commits) { new_commits.first(number_of_new_commits_displayed).map(&method(:commit_to_hash)) }
+
+ it 'triggers the corresponding mailer method with list of stripped commits' do
+ notification.push_to_merge_request(
+ merge_request, merge_request.author,
+ new_commits: new_commits, existing_commits: existing_commits
+ )
+
+ expect(Notify).to have_received(:push_to_merge_request_email).at_least(:once).with(
+ @subscriber.id, merge_request.id, merge_request.author.id, "subscribed",
+ new_commits: expected_new_commits, total_new_commits_count: number_of_new_commits,
+ existing_commits: expected_existing_commits, total_existing_commits_count: 50
+ )
+ end
+ end
+
+ context 'there is only one existing commit' do
+ let(:new_commits) { mock_commits(10) }
+ let(:expected_new_commits) { new_commits.map(&method(:commit_to_hash)) }
+
+ it 'triggers corresponding mailer method with only one existing commit' do
+ notification.push_to_merge_request(merge_request, merge_request.author, new_commits: new_commits, existing_commits: existing_commits.first(1))
+
+ expect(Notify).to have_received(:push_to_merge_request_email).at_least(:once).with(
+ @subscriber.id, merge_request.id, merge_request.author.id, "subscribed",
+ new_commits: expected_new_commits, total_new_commits_count: 10,
+ existing_commits: expected_existing_commits.first(1), total_existing_commits_count: 1
+ )
+ end
+ end
+ end
+
it_behaves_like 'participating notifications' do
let(:participant) { create(:user, username: 'user-participant') }
let(:issuable) { merge_request }
diff --git a/spec/services/packages/rubygems/metadata_extraction_service_spec.rb b/spec/services/packages/rubygems/metadata_extraction_service_spec.rb
index b308daad8f5..bbd5b6f3d59 100644
--- a/spec/services/packages/rubygems/metadata_extraction_service_spec.rb
+++ b/spec/services/packages/rubygems/metadata_extraction_service_spec.rb
@@ -46,5 +46,13 @@ RSpec.describe Packages::Rubygems::MetadataExtractionService do
expect(metadata.requirements).to eq(gemspec.requirements.to_json)
expect(metadata.rubygems_version).to eq(gemspec.rubygems_version)
end
+
+ context 'with an existing metadatum' do
+ let_it_be(:metadatum) { create(:rubygems_metadatum, package: package) }
+
+ it 'updates it' do
+ expect { subject }.not_to change { Packages::Rubygems::Metadatum.count }
+ end
+ end
end
end
diff --git a/spec/services/projects/apple_target_platform_detector_service_spec.rb b/spec/services/projects/apple_target_platform_detector_service_spec.rb
new file mode 100644
index 00000000000..6391161824c
--- /dev/null
+++ b/spec/services/projects/apple_target_platform_detector_service_spec.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::AppleTargetPlatformDetectorService do
+ let_it_be(:project) { build(:project) }
+
+ subject { described_class.new(project).execute }
+
+ context 'when project is not an xcode project' do
+ before do
+ allow(Gitlab::FileFinder).to receive(:new) { instance_double(Gitlab::FileFinder, find: []) }
+ end
+
+ it 'returns an empty array' do
+ is_expected.to match_array []
+ end
+ end
+
+ context 'when project is an xcode project' do
+ using RSpec::Parameterized::TableSyntax
+
+ let(:finder) { instance_double(Gitlab::FileFinder) }
+
+ before do
+ allow(Gitlab::FileFinder).to receive(:new) { finder }
+ end
+
+ def search_query(sdk, filename)
+ "SDKROOT = #{sdk} filename:#{filename}"
+ end
+
+ context 'when setting string is found' do
+ where(:sdk, :filename, :result) do
+ 'iphoneos' | 'project.pbxproj' | [:ios]
+ 'iphoneos' | '*.xcconfig' | [:ios]
+ end
+
+ with_them do
+ before do
+ allow(finder).to receive(:find).with(anything) { [] }
+ allow(finder).to receive(:find).with(search_query(sdk, filename)) { [instance_double(Gitlab::Search::FoundBlob)] }
+ end
+
+ it 'returns an array of unique detected targets' do
+ is_expected.to match_array result
+ end
+ end
+ end
+
+ context 'when setting string is not found' do
+ before do
+ allow(finder).to receive(:find).with(anything) { [] }
+ end
+
+ it 'returns an empty array' do
+ is_expected.to match_array []
+ end
+ end
+ end
+end
diff --git a/spec/services/projects/container_repository/cleanup_tags_service_spec.rb b/spec/services/projects/container_repository/cleanup_tags_service_spec.rb
index 38a3e00c8e7..86c0ba4222c 100644
--- a/spec/services/projects/container_repository/cleanup_tags_service_spec.rb
+++ b/spec/services/projects/container_repository/cleanup_tags_service_spec.rb
@@ -13,7 +13,7 @@ RSpec.describe Projects::ContainerRepository::CleanupTagsService, :clean_gitlab_
let(:tags) { %w[latest A Ba Bb C D E] }
before do
- project.add_maintainer(user)
+ project.add_maintainer(user) if user
stub_container_registry_config(enabled: true)
diff --git a/spec/services/projects/container_repository/third_party/delete_tags_service_spec.rb b/spec/services/projects/container_repository/third_party/delete_tags_service_spec.rb
index 7fc963949eb..22cada7816b 100644
--- a/spec/services/projects/container_repository/third_party/delete_tags_service_spec.rb
+++ b/spec/services/projects/container_repository/third_party/delete_tags_service_spec.rb
@@ -58,7 +58,19 @@ RSpec.describe Projects::ContainerRepository::ThirdParty::DeleteTagsService do
stub_put_manifest_request('Ba', 500, {})
end
- it { is_expected.to eq(status: :error, message: 'could not delete tags') }
+ it { is_expected.to eq(status: :error, message: "could not delete tags: #{tags.join(', ')}")}
+
+ context 'when a large list of tag updates fails' do
+ let(:tags) { Array.new(1000) { |i| "tag_#{i}" } }
+
+ before do
+ expect(service).to receive(:replace_tag_manifests).and_return({})
+ end
+
+ it 'truncates the log message' do
+ expect(subject).to eq(status: :error, message: "could not delete tags: #{tags.join(', ')}".truncate(1000))
+ end
+ end
end
context 'a single tag update fails' do
diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb
index 96a50b26871..c5c5af3cb01 100644
--- a/spec/services/projects/create_service_spec.rb
+++ b/spec/services/projects/create_service_spec.rb
@@ -135,10 +135,22 @@ RSpec.describe Projects::CreateService, '#execute' do
create_project(user, opts)
end
- it 'builds associated project settings' do
+ it 'creates associated project settings' do
project = create_project(user, opts)
- expect(project.project_setting).to be_new_record
+ expect(project.project_setting).to be_persisted
+ end
+
+ context 'create_project_settings feature flag is disabled' do
+ before do
+ stub_feature_flags(create_project_settings: false)
+ end
+
+ it 'builds associated project settings' do
+ project = create_project(user, opts)
+
+ expect(project.project_setting).to be_new_record
+ end
end
it_behaves_like 'storing arguments in the application context' do
@@ -376,6 +388,18 @@ RSpec.describe Projects::CreateService, '#execute' do
imported_project
end
+
+ describe 'import scheduling' do
+ context 'when project import type is gitlab project migration' do
+ it 'does not schedule project import' do
+ opts[:import_type] = 'gitlab_project_migration'
+
+ project = create_project(user, opts)
+
+ expect(project.import_state.status).to eq('none')
+ end
+ end
+ end
end
context 'builds_enabled global setting' do
diff --git a/spec/services/projects/operations/update_service_spec.rb b/spec/services/projects/operations/update_service_spec.rb
index b64f2d1e7d6..3ee867ba6f2 100644
--- a/spec/services/projects/operations/update_service_spec.rb
+++ b/spec/services/projects/operations/update_service_spec.rb
@@ -407,10 +407,11 @@ RSpec.describe Projects::Operations::UpdateService do
context 'prometheus integration' do
context 'prometheus params were passed into service' do
- let(:prometheus_integration) do
- build_stubbed(:prometheus_integration, project: project, properties: {
+ let!(:prometheus_integration) do
+ create(:prometheus_integration, :instance, properties: {
api_url: "http://example.prometheus.com",
- manual_configuration: "0"
+ manual_configuration: "0",
+ google_iap_audience_client_id: 123
})
end
@@ -424,21 +425,23 @@ RSpec.describe Projects::Operations::UpdateService do
end
it 'uses Project#find_or_initialize_integration to include instance defined defaults and pass them to Projects::UpdateService', :aggregate_failures do
- project_update_service = double(Projects::UpdateService)
-
- expect(project)
- .to receive(:find_or_initialize_integration)
- .with('prometheus')
- .and_return(prometheus_integration)
expect(Projects::UpdateService).to receive(:new) do |project_arg, user_arg, update_params_hash|
+ prometheus_attrs = update_params_hash[:prometheus_integration_attributes]
+
expect(project_arg).to eq project
expect(user_arg).to eq user
- expect(update_params_hash[:prometheus_integration_attributes]).to include('properties' => { 'api_url' => 'http://new.prometheus.com', 'manual_configuration' => '1' })
- expect(update_params_hash[:prometheus_integration_attributes]).not_to include(*%w(id project_id created_at updated_at))
- end.and_return(project_update_service)
- expect(project_update_service).to receive(:execute)
+ expect(prometheus_attrs).to have_key('encrypted_properties')
+ expect(prometheus_attrs.keys).not_to include(*%w(id project_id created_at updated_at properties))
+ expect(prometheus_attrs['encrypted_properties']).not_to eq(prometheus_integration.encrypted_properties)
+ end.and_call_original
- subject.execute
+ expect { subject.execute }.to change(Integrations::Prometheus, :count).by(1)
+
+ expect(Integrations::Prometheus.last).to have_attributes(
+ api_url: 'http://new.prometheus.com',
+ manual_configuration: true,
+ google_iap_audience_client_id: 123
+ )
end
end
diff --git a/spec/services/projects/record_target_platforms_service_spec.rb b/spec/services/projects/record_target_platforms_service_spec.rb
new file mode 100644
index 00000000000..85311f36428
--- /dev/null
+++ b/spec/services/projects/record_target_platforms_service_spec.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::RecordTargetPlatformsService, '#execute' do
+ let_it_be(:project) { create(:project) }
+
+ subject(:execute) { described_class.new(project).execute }
+
+ context 'when project is an XCode project' do
+ before do
+ double = instance_double(Projects::AppleTargetPlatformDetectorService, execute: [:ios, :osx])
+ allow(Projects::AppleTargetPlatformDetectorService).to receive(:new) { double }
+ end
+
+ it 'creates a new setting record for the project', :aggregate_failures do
+ expect { execute }.to change { ProjectSetting.count }.from(0).to(1)
+ expect(ProjectSetting.last.target_platforms).to match_array(%w(ios osx))
+ end
+
+ it 'returns array of detected target platforms' do
+ expect(execute).to match_array %w(ios osx)
+ end
+
+ context 'when a project has an existing setting record' do
+ before do
+ create(:project_setting, project: project, target_platforms: saved_target_platforms)
+ end
+
+ def project_setting
+ ProjectSetting.find_by_project_id(project.id)
+ end
+
+ context 'when target platforms changed' do
+ let(:saved_target_platforms) { %w(tvos) }
+
+ it 'updates' do
+ expect { execute }.to change { project_setting.target_platforms }.from(%w(tvos)).to(%w(ios osx))
+ end
+
+ it { is_expected.to match_array %w(ios osx) }
+ end
+
+ context 'when target platforms are the same' do
+ let(:saved_target_platforms) { %w(osx ios) }
+
+ it 'does not update' do
+ expect { execute }.not_to change { project_setting.updated_at }
+ end
+ end
+ end
+ end
+
+ context 'when project is not an XCode project' do
+ before do
+ double = instance_double(Projects::AppleTargetPlatformDetectorService, execute: [])
+ allow(Projects::AppleTargetPlatformDetectorService).to receive(:new).with(project) { double }
+ end
+
+ it 'does nothing' do
+ expect { execute }.not_to change { ProjectSetting.count }
+ end
+
+ it { is_expected.to be_nil }
+ end
+end
diff --git a/spec/services/projects/refresh_build_artifacts_size_statistics_service_spec.rb b/spec/services/projects/refresh_build_artifacts_size_statistics_service_spec.rb
index 41de8c6bdbb..41487e9ea48 100644
--- a/spec/services/projects/refresh_build_artifacts_size_statistics_service_spec.rb
+++ b/spec/services/projects/refresh_build_artifacts_size_statistics_service_spec.rb
@@ -10,7 +10,8 @@ RSpec.describe Projects::RefreshBuildArtifactsSizeStatisticsService, :clean_gitl
let_it_be(:artifact_1) { create(:ci_job_artifact, project: project, size: 1, created_at: 14.days.ago) }
let_it_be(:artifact_2) { create(:ci_job_artifact, project: project, size: 2, created_at: 13.days.ago) }
- let_it_be(:artifact_3) { create(:ci_job_artifact, project: project, size: 5, created_at: 12.days.ago) }
+ let_it_be(:artifact_3) { create(:ci_job_artifact, project: project, size: nil, created_at: 13.days.ago) }
+ let_it_be(:artifact_4) { create(:ci_job_artifact, project: project, size: 5, created_at: 12.days.ago) }
# This should not be included in the recalculation as it is created later than the refresh start time
let_it_be(:future_artifact) { create(:ci_job_artifact, project: project, size: 8, created_at: 2.days.from_now) }
@@ -33,7 +34,7 @@ RSpec.describe Projects::RefreshBuildArtifactsSizeStatisticsService, :clean_gitl
end
before do
- stub_const("#{described_class}::BATCH_SIZE", 2)
+ stub_const("#{described_class}::BATCH_SIZE", 3)
stats = create(:project_statistics, project: project, build_artifacts_size: 120)
stats.increment_counter(:build_artifacts_size, 30)
@@ -48,7 +49,7 @@ RSpec.describe Projects::RefreshBuildArtifactsSizeStatisticsService, :clean_gitl
end
it 'updates the last_job_artifact_id to the ID of the last artifact from the batch' do
- expect { service.execute }.to change { refresh.reload.last_job_artifact_id.to_i }.to(artifact_2.id)
+ expect { service.execute }.to change { refresh.reload.last_job_artifact_id.to_i }.to(artifact_3.id)
end
it 'requeues the refresh job' do
@@ -62,7 +63,7 @@ RSpec.describe Projects::RefreshBuildArtifactsSizeStatisticsService, :clean_gitl
:project_build_artifacts_size_refresh,
:pending,
project: project,
- last_job_artifact_id: artifact_2.id
+ last_job_artifact_id: artifact_3.id
)
end
@@ -73,7 +74,7 @@ RSpec.describe Projects::RefreshBuildArtifactsSizeStatisticsService, :clean_gitl
end
it 'keeps the last_job_artifact_id unchanged' do
- expect(refresh.reload.last_job_artifact_id).to eq(artifact_2.id)
+ expect(refresh.reload.last_job_artifact_id).to eq(artifact_3.id)
end
it 'keeps the state of the refresh record at running' do
@@ -89,7 +90,7 @@ RSpec.describe Projects::RefreshBuildArtifactsSizeStatisticsService, :clean_gitl
project: project,
updated_at: 2.days.ago,
refresh_started_at: now,
- last_job_artifact_id: artifact_3.id
+ last_job_artifact_id: artifact_4.id
)
end
diff --git a/spec/services/projects/transfer_service_spec.rb b/spec/services/projects/transfer_service_spec.rb
index fb94e94fd18..e547ace1d9f 100644
--- a/spec/services/projects/transfer_service_spec.rb
+++ b/spec/services/projects/transfer_service_spec.rb
@@ -11,8 +11,9 @@ RSpec.describe Projects::TransferService do
let(:project) { create(:project, :repository, :legacy_storage, namespace: user.namespace) }
let(:target) { group }
+ let(:executor) { user }
- subject(:execute_transfer) { described_class.new(project, user).execute(target).tap { project.reload } }
+ subject(:execute_transfer) { described_class.new(project, executor).execute(target).tap { project.reload } }
context 'with npm packages' do
before do
@@ -92,6 +93,55 @@ RSpec.describe Projects::TransferService do
end
end
+ context 'project in a group -> a personal namespace', :enable_admin_mode do
+ let(:project) { create(:project, :repository, :legacy_storage, group: group) }
+ let(:target) { user.namespace }
+ # We need to use an admin user as the executor because
+ # only an admin user has required permissions to transfer projects
+ # under _all_ the different circumstances specified below.
+ let(:executor) { create(:user, :admin) }
+
+ it 'executes the transfer to personal namespace successfully' do
+ execute_transfer
+
+ expect(project.namespace).to eq(user.namespace)
+ end
+
+ context 'the owner of the namespace does not have a direct membership in the project residing in the group' do
+ it 'creates a project membership record for the owner of the namespace, with OWNER access level, after the transfer' do
+ execute_transfer
+
+ expect(project.members.owners.find_by(user_id: user.id)).to be_present
+ end
+ end
+
+ context 'the owner of the namespace has a direct membership in the project residing in the group' do
+ context 'that membership has an access level of OWNER' do
+ before do
+ project.add_owner(user)
+ end
+
+ it 'retains the project membership record for the owner of the namespace, with OWNER access level, after the transfer' do
+ execute_transfer
+
+ expect(project.members.owners.find_by(user_id: user.id)).to be_present
+ end
+ end
+
+ context 'that membership has an access level that is not OWNER' do
+ before do
+ project.add_developer(user)
+ end
+
+ it 'updates the project membership record for the owner of the namespace, to OWNER access level, after the transfer' do
+ execute_transfer
+
+ expect(project.members.owners.find_by(user_id: user.id)).to be_present
+ end
+ end
+ end
+ end
+
context 'when transfer succeeds' do
before do
group.add_owner(user)
@@ -148,23 +198,23 @@ RSpec.describe Projects::TransferService do
context 'with a project integration' do
let_it_be_with_reload(:project) { create(:project, namespace: user.namespace) }
- let_it_be(:instance_integration) { create(:integrations_slack, :instance, webhook: 'http://project.slack.com') }
+ let_it_be(:instance_integration) { create(:integrations_slack, :instance) }
+ let_it_be(:project_integration) { create(:integrations_slack, project: project) }
- context 'with an inherited integration' do
- let_it_be(:project_integration) { create(:integrations_slack, project: project, webhook: 'http://project.slack.com', inherit_from_id: instance_integration.id) }
+ context 'when it inherits from instance_integration' do
+ before do
+ project_integration.update!(inherit_from_id: instance_integration.id, webhook: instance_integration.webhook)
+ end
it 'replaces inherited integrations', :aggregate_failures do
- execute_transfer
-
- expect(project.slack_integration.webhook).to eq(group_integration.webhook)
- expect(Integration.count).to eq(3)
+ expect { execute_transfer }
+ .to change(Integration, :count).by(0)
+ .and change { project.slack_integration.webhook }.to eq(group_integration.webhook)
end
end
context 'with a custom integration' do
- let_it_be(:project_integration) { create(:integrations_slack, project: project, webhook: 'http://project.slack.com') }
-
- it 'does not updates the integrations' do
+ it 'does not update the integrations' do
expect { execute_transfer }.not_to change { project.slack_integration.webhook }
end
end
diff --git a/spec/services/quick_actions/interpret_service_spec.rb b/spec/services/quick_actions/interpret_service_spec.rb
index 94e0e8a9ea1..85dbc39edcf 100644
--- a/spec/services/quick_actions/interpret_service_spec.rb
+++ b/spec/services/quick_actions/interpret_service_spec.rb
@@ -671,6 +671,19 @@ RSpec.describe QuickActions::InterpretService do
end
shared_examples 'assign command' do
+ it 'assigns to users with escaped underscores' do
+ user = create(:user)
+ base = user.username
+ user.update!(username: "#{base}_")
+ issuable.project.add_developer(user)
+
+ cmd = "/assign @#{base}\\_"
+
+ _, updates, _ = service.execute(cmd, issuable)
+
+ expect(updates).to eq(assignee_ids: [user.id])
+ end
+
it 'assigns to a single user' do
_, updates, _ = service.execute(content, issuable)
@@ -726,6 +739,17 @@ RSpec.describe QuickActions::InterpretService do
expect(reviewer).to be_attention_requested
end
+
+ it 'supports attn alias' do
+ attn_cmd = content.gsub(/attention/, 'attn')
+ _, _, message = service.execute(attn_cmd, issuable)
+
+ expect(message).to eq("Requested attention from #{developer.to_reference}.")
+
+ reviewer.reload
+
+ expect(reviewer).to be_attention_requested
+ end
end
shared_examples 'remove attention command' do
@@ -800,7 +824,7 @@ RSpec.describe QuickActions::InterpretService do
let(:project) { repository_project }
let(:service) { described_class.new(project, developer, {}) }
- it_behaves_like 'failed command', 'Merge request diff sha parameter is required for the merge quick action.' do
+ it_behaves_like 'failed command', 'The `/merge` quick action requires the SHA of the head of the branch.' do
let(:content) { "/merge" }
let(:issuable) { merge_request }
end
diff --git a/spec/services/service_ping/build_payload_service_spec.rb b/spec/services/service_ping/build_payload_service_spec.rb
index b90e5e66518..cd2685069c9 100644
--- a/spec/services/service_ping/build_payload_service_spec.rb
+++ b/spec/services/service_ping/build_payload_service_spec.rb
@@ -4,10 +4,6 @@ require 'spec_helper'
RSpec.describe ServicePing::BuildPayloadService do
describe '#execute', :without_license do
- before do
- stub_feature_flags(merge_service_ping_instrumented_metrics: false)
- end
-
subject(:service_ping_payload) { described_class.new.execute }
include_context 'stubbed service ping metrics definitions' do
diff --git a/spec/services/task_list_toggle_service_spec.rb b/spec/services/task_list_toggle_service_spec.rb
index 81f80ee926a..f889f298213 100644
--- a/spec/services/task_list_toggle_service_spec.rb
+++ b/spec/services/task_list_toggle_service_spec.rb
@@ -16,6 +16,8 @@ RSpec.describe TaskListToggleService do
- [ ] loose list
with an embedded paragraph
+
+ + [ ] No-break space (U+00A0)
EOT
end
@@ -40,12 +42,17 @@ RSpec.describe TaskListToggleService do
</ul>
</li>
</ol>
- <ul data-sourcepos="9:1-11:28" class="task-list" dir="auto">
- <li data-sourcepos="9:1-11:28" class="task-list-item">
+ <ul data-sourcepos="9:1-12:0" class="task-list" dir="auto">
+ <li data-sourcepos="9:1-12:0" class="task-list-item">
<p data-sourcepos="9:3-9:16"><input type="checkbox" class="task-list-item-checkbox" disabled=""> loose list</p>
<p data-sourcepos="11:3-11:28">with an embedded paragraph</p>
</li>
</ul>
+ <ul data-sourcepos="13:1-13:21" class="task-list" dir="auto">
+ <li data-sourcepos="13:1-13:21" class="task-list-item">
+ <input type="checkbox" class="task-list-item-checkbox" disabled=""> No-break space (U+00A0)
+ </li>
+ </ul>
EOT
end
@@ -79,6 +86,16 @@ RSpec.describe TaskListToggleService do
expect(toggler.updated_markdown_html).to include('disabled checked> loose list')
end
+ it 'checks task with no-break space' do
+ toggler = described_class.new(markdown, markdown_html,
+ toggle_as_checked: true,
+ line_source: '+ [ ] No-break space (U+00A0)', line_number: 13)
+
+ expect(toggler.execute).to be_truthy
+ expect(toggler.updated_markdown.lines[12]).to eq "+ [x] No-break space (U+00A0)"
+ expect(toggler.updated_markdown_html).to include('disabled checked> No-break space (U+00A0)')
+ end
+
it 'returns false if line_source does not match the text' do
toggler = described_class.new(markdown, markdown_html,
toggle_as_checked: false,
diff --git a/spec/services/users/destroy_service_spec.rb b/spec/services/users/destroy_service_spec.rb
index 602db66dba1..80a506bb1d6 100644
--- a/spec/services/users/destroy_service_spec.rb
+++ b/spec/services/users/destroy_service_spec.rb
@@ -332,4 +332,39 @@ RSpec.describe Users::DestroyService do
expect(User.exists?(other_user.id)).to be(false)
end
end
+
+ context 'batched nullify' do
+ let(:other_user) { create(:user) }
+
+ context 'when :nullify_in_batches_on_user_deletion feature flag is enabled' do
+ it 'nullifies related associations in batches' do
+ expect(other_user).to receive(:nullify_dependent_associations_in_batches).and_call_original
+
+ described_class.new(user).execute(other_user, skip_authorization: true)
+ end
+
+ it 'nullifies last_updated_issues and closed_issues' do
+ issue = create(:issue, closed_by: other_user, updated_by: other_user)
+
+ described_class.new(user).execute(other_user, skip_authorization: true)
+
+ issue.reload
+
+ expect(issue.closed_by).to be_nil
+ expect(issue.updated_by).to be_nil
+ end
+ end
+
+ context 'when :nullify_in_batches_on_user_deletion feature flag is disabled' do
+ before do
+ stub_feature_flags(nullify_in_batches_on_user_deletion: false)
+ end
+
+ it 'does not use batching' do
+ expect(other_user).not_to receive(:nullify_dependent_associations_in_batches)
+
+ described_class.new(user).execute(other_user, skip_authorization: true)
+ end
+ end
+ end
end
diff --git a/spec/services/users/saved_replies/destroy_service_spec.rb b/spec/services/users/saved_replies/destroy_service_spec.rb
new file mode 100644
index 00000000000..cb97fac7b7c
--- /dev/null
+++ b/spec/services/users/saved_replies/destroy_service_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Users::SavedReplies::DestroyService do
+ describe '#execute' do
+ let!(:saved_reply) { create(:saved_reply) }
+
+ subject { described_class.new(saved_reply: saved_reply).execute }
+
+ context 'when destroy fails' do
+ before do
+ allow(saved_reply).to receive(:destroy).and_return(false)
+ end
+
+ it 'does not remove Saved Reply from database' do
+ expect { subject }.not_to change(::Users::SavedReply, :count)
+ end
+
+ it { is_expected.not_to be_success }
+ end
+
+ context 'when destroy succeeds' do
+ it { is_expected.to be_success }
+
+ it 'removes Saved Reply from database' do
+ expect { subject }.to change(::Users::SavedReply, :count).by(-1)
+ end
+
+ it 'returns saved reply' do
+ expect(subject[:saved_reply]).to eq(saved_reply)
+ end
+ end
+ end
+end
diff --git a/spec/services/users/saved_replies/update_service_spec.rb b/spec/services/users/saved_replies/update_service_spec.rb
index b67d09977c6..bdb54d7c8f7 100644
--- a/spec/services/users/saved_replies/update_service_spec.rb
+++ b/spec/services/users/saved_replies/update_service_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe Users::SavedReplies::UpdateService do
let_it_be(:other_saved_reply) { create(:saved_reply, user: current_user) }
let_it_be(:saved_reply_from_other_user) { create(:saved_reply) }
- subject { described_class.new(current_user: current_user, saved_reply: saved_reply, name: name, content: content).execute }
+ subject { described_class.new(saved_reply: saved_reply, name: name, content: content).execute }
context 'when update fails' do
let(:name) { other_saved_reply.name }
diff --git a/spec/services/web_hook_service_spec.rb b/spec/services/web_hook_service_spec.rb
index c938ad9ee39..b99bc860523 100644
--- a/spec/services/web_hook_service_spec.rb
+++ b/spec/services/web_hook_service_spec.rb
@@ -107,6 +107,21 @@ RSpec.describe WebHookService, :request_store, :clean_gitlab_redis_shared_state
).once
end
+ context 'when the data is a Gitlab::DataBuilder::Pipeline' do
+ let(:pipeline) { create(:ci_pipeline, project: project) }
+ let(:data) { ::Gitlab::DataBuilder::Pipeline.new(pipeline) }
+
+ it 'can log the request payload' do
+ stub_full_request(project_hook.url, method: :post)
+
+ # we call this with force to ensure that the logs are written inline,
+ # which tests that we can serialize the data to the DB correctly.
+ service = described_class.new(project_hook, data, :push_hooks, force: true)
+
+ expect { service.execute }.to change(::WebHookLog, :count).by(1)
+ end
+ end
+
context 'when auth credentials are present' do
let_it_be(:url) {'https://example.org'}
let_it_be(:project_hook) { create(:project_hook, url: 'https://demo:demo@example.org/') }
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index a72c8d2c4e8..88f10cc2a01 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -1,10 +1,9 @@
# frozen_string_literal: true
-# $" is $LOADED_FEATURES, but RuboCop didn't like it
if $".include?(File.expand_path('fast_spec_helper.rb', __dir__))
warn 'Detected fast_spec_helper is loaded first than spec_helper.'
warn 'If running test files using both spec_helper and fast_spec_helper,'
- warn 'make sure test file with spec_helper is loaded first.'
+ warn 'make sure spec_helper is loaded first, or run rspec with `-r spec_helper`.'
abort 'Aborting...'
end
@@ -192,6 +191,7 @@ RSpec.configure do |config|
config.include MigrationsHelpers, :migration
config.include RedisHelpers
config.include Rails.application.routes.url_helpers, type: :routing
+ config.include Rails.application.routes.url_helpers, type: :component
config.include PolicyHelpers, type: :policy
config.include ExpectRequestWithStatus, type: :request
config.include IdempotentWorkerHelper, type: :worker
@@ -238,6 +238,7 @@ RSpec.configure do |config|
# Enable all features by default for testing
# Reset any changes in after hook.
stub_all_feature_flags
+ stub_feature_flags(main_branch_over_master: false)
TestEnv.seed_db
end
@@ -329,10 +330,9 @@ RSpec.configure do |config|
stub_feature_flags(disable_anonymous_search: false)
stub_feature_flags(disable_anonymous_project_search: false)
- # Disable the refactored top nav search until there is functionality
- # Can be removed once all existing functionality has been replicated
- # For more information check https://gitlab.com/gitlab-org/gitlab/-/issues/339348
- stub_feature_flags(new_header_search: false)
+ # Specs should not get a CAPTCHA challenge by default, this makes the sign-in flow simpler in
+ # most cases. We do test the CAPTCHA flow in the appropriate specs.
+ stub_feature_flags(arkose_labs_login_challenge: false)
allow(Gitlab::GitalyClient).to receive(:can_use_disk?).and_return(enable_rugged)
else
diff --git a/spec/support/database_cleaner.rb b/spec/support/database_cleaner.rb
index 8f706fdebc9..f8ddf3e66a5 100644
--- a/spec/support/database_cleaner.rb
+++ b/spec/support/database_cleaner.rb
@@ -20,6 +20,9 @@ RSpec.configure do |config|
# We drop and recreate the database if any table has more than 1200 columns, just to be safe.
if any_connection_class_with_more_than_allowed_columns?
recreate_all_databases!
+
+ # Seed required data as recreating DBs will delete it
+ TestEnv.seed_db
end
end
diff --git a/spec/support/fips.rb b/spec/support/fips.rb
new file mode 100644
index 00000000000..1d278dcdf60
--- /dev/null
+++ b/spec/support/fips.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+# rubocop: disable RSpec/EnvAssignment
+
+RSpec.configure do |config|
+ config.around(:each, :fips_mode) do |example|
+ set_fips_mode(true) do
+ example.run
+ end
+ end
+
+ config.around(:each, fips_mode: false) do |example|
+ set_fips_mode(false) do
+ example.run
+ end
+ end
+
+ def set_fips_mode(value)
+ prior_value = ENV["FIPS_MODE"]
+ ENV["FIPS_MODE"] = value.to_s
+
+ yield
+
+ ENV["FIPS_MODE"] = prior_value
+ end
+end
+
+# rubocop: enable RSpec/EnvAssignment
diff --git a/spec/support/gitlab_stubs/gitlab_ci.yml b/spec/support/gitlab_stubs/gitlab_ci.yml
index 52ae36229a6..b1533879e32 100644
--- a/spec/support/gitlab_stubs/gitlab_ci.yml
+++ b/spec/support/gitlab_stubs/gitlab_ci.yml
@@ -1,4 +1,4 @@
-image: ruby:2.6
+image: image:1.0
services:
- postgres
diff --git a/spec/support/helpers/cycle_analytics_helpers.rb b/spec/support/helpers/cycle_analytics_helpers.rb
index 70b794f7d82..044ec56b1cc 100644
--- a/spec/support/helpers/cycle_analytics_helpers.rb
+++ b/spec/support/helpers/cycle_analytics_helpers.rb
@@ -86,6 +86,25 @@ module CycleAnalyticsHelpers
wait_for_stages_to_load(ready_selector)
end
+ def select_value_stream(value_stream_name)
+ toggle_value_stream_dropdown
+
+ page.find('[data-testid="dropdown-value-streams"]').all('li button').find { |item| item.text == value_stream_name.to_s }.click
+ wait_for_requests
+ end
+
+ def create_value_stream_group_aggregation(group)
+ aggregation = Analytics::CycleAnalytics::Aggregation.safe_create_for_group(group)
+ Analytics::CycleAnalytics::AggregatorService.new(aggregation: aggregation).execute
+ end
+
+ def select_group_and_custom_value_stream(group, custom_value_stream_name)
+ create_value_stream_group_aggregation(group)
+
+ select_group(group)
+ select_value_stream(custom_value_stream_name)
+ end
+
def toggle_dropdown(field)
page.within("[data-testid*='#{field}']") do
find('.dropdown-toggle').click
diff --git a/spec/support/helpers/features/invite_members_modal_helper.rb b/spec/support/helpers/features/invite_members_modal_helper.rb
index 2a4f78ca57f..7ed64615020 100644
--- a/spec/support/helpers/features/invite_members_modal_helper.rb
+++ b/spec/support/helpers/features/invite_members_modal_helper.rb
@@ -5,19 +5,22 @@ module Spec
module Helpers
module Features
module InviteMembersModalHelper
- def invite_member(name, role: 'Guest', expires_at: nil)
+ def invite_member(names, role: 'Guest', expires_at: nil, refresh: true)
click_on 'Invite members'
- page.within '[data-testid="invite-modal"]' do
- find('[data-testid="members-token-select-input"]').set(name)
+ page.within invite_modal_selector do
+ Array.wrap(names).each do |name|
+ find(member_dropdown_selector).set(name)
+
+ wait_for_requests
+ click_button name
+ end
- wait_for_requests
- click_button name
choose_options(role, expires_at)
click_button 'Invite'
- page.refresh
+ page.refresh if refresh
end
end
@@ -43,6 +46,31 @@ module Spec
fill_in 'YYYY-MM-DD', with: expires_at.strftime('%Y-%m-%d') if expires_at
end
+
+ def click_groups_tab
+ expect(page).to have_link 'Groups'
+ click_link "Groups"
+ end
+
+ def group_dropdown_selector
+ '[data-testid="group-select-dropdown"]'
+ end
+
+ def member_dropdown_selector
+ '[data-testid="members-token-select-input"]'
+ end
+
+ def invite_modal_selector
+ '[data-testid="invite-modal"]'
+ end
+
+ def expect_to_have_group(group)
+ expect(page).to have_selector("[entity-id='#{group.id}']")
+ end
+
+ def expect_not_to_have_group(group)
+ expect(page).not_to have_selector("[entity-id='#{group.id}']")
+ end
end
end
end
diff --git a/spec/support/helpers/features/runner_helpers.rb b/spec/support/helpers/features/runner_helpers.rb
new file mode 100644
index 00000000000..63fc628358c
--- /dev/null
+++ b/spec/support/helpers/features/runner_helpers.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+module Spec
+ module Support
+ module Helpers
+ module Features
+ module RunnersHelpers
+ def within_runner_row(runner_id)
+ within "[data-testid='runner-row-#{runner_id}']" do
+ yield
+ end
+ end
+
+ def search_bar_selector
+ '[data-testid="runners-filtered-search"]'
+ end
+
+ # The filters must be clicked first to be able to receive events
+ # See: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1493
+ def focus_filtered_search
+ page.within(search_bar_selector) do
+ page.find('.gl-filtered-search-term-token').click
+ end
+ end
+
+ def input_filtered_search_keys(search_term)
+ focus_filtered_search
+
+ page.within(search_bar_selector) do
+ page.find('input').send_keys(search_term)
+ click_on 'Search'
+ end
+
+ wait_for_requests
+ end
+
+ def open_filtered_search_suggestions(filter)
+ focus_filtered_search
+
+ page.within(search_bar_selector) do
+ click_on filter
+ end
+
+ wait_for_requests
+ end
+
+ def input_filtered_search_filter_is_only(filter, value)
+ focus_filtered_search
+
+ page.within(search_bar_selector) do
+ click_on filter
+
+ # For OPERATOR_IS_ONLY, clicking the filter
+ # immediately preselects "=" operator
+
+ page.find('input').send_keys(value)
+ page.find('input').send_keys(:enter)
+
+ click_on 'Search'
+ end
+
+ wait_for_requests
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/support/helpers/gitaly_setup.rb b/spec/support/helpers/gitaly_setup.rb
index a4ee618457d..0ad83bdeeb2 100644
--- a/spec/support/helpers/gitaly_setup.rb
+++ b/spec/support/helpers/gitaly_setup.rb
@@ -267,7 +267,7 @@ module GitalySetup
{ 'default' => repos_path },
force: true,
options: {
- internal_socket_dir: File.join(gitaly_dir, "internal_gitaly2"),
+ runtime_dir: File.join(gitaly_dir, "run2"),
gitaly_socket: "gitaly2.socket",
config_filename: "gitaly2.config.toml"
}
diff --git a/spec/support/helpers/login_helpers.rb b/spec/support/helpers/login_helpers.rb
index 7666d71f13c..29b1bb260f2 100644
--- a/spec/support/helpers/login_helpers.rb
+++ b/spec/support/helpers/login_helpers.rb
@@ -99,7 +99,7 @@ module LoginHelpers
fill_in "user_password", with: (password || "12345678")
check 'user_remember_me' if remember
- click_button "Sign in"
+ find('[data-testid="sign-in-button"]:enabled').click
if two_factor_auth
fill_in "user_otp_attempt", with: user.reload.current_otp
diff --git a/spec/support/helpers/navbar_structure_helper.rb b/spec/support/helpers/navbar_structure_helper.rb
index fb06ebfdae2..315303401cc 100644
--- a/spec/support/helpers/navbar_structure_helper.rb
+++ b/spec/support/helpers/navbar_structure_helper.rb
@@ -92,4 +92,16 @@ module NavbarStructureHelper
new_sub_nav_item_name: _('Google Cloud')
)
end
+
+ def analytics_sub_nav_item
+ [
+ _('Value stream'),
+ _('CI/CD'),
+ (_('Code review') if Gitlab.ee?),
+ (_('Merge request') if Gitlab.ee?),
+ _('Repository')
+ ]
+ end
end
+
+NavbarStructureHelper.prepend_mod
diff --git a/spec/support/helpers/search_helpers.rb b/spec/support/helpers/search_helpers.rb
index f5a1a97a1d0..581ef07752e 100644
--- a/spec/support/helpers/search_helpers.rb
+++ b/spec/support/helpers/search_helpers.rb
@@ -2,9 +2,12 @@
module SearchHelpers
def fill_in_search(text)
- page.within('.search-input-wrap') do
+ # Once the `new_header_search` feature flag has been removed
+ # We can remove the `.search-input-wrap` selector
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/339348
+ page.within('.header-search-new') do
find('#search').click
- fill_in('search', with: text)
+ fill_in 'search', with: text
end
wait_for_all_requests
diff --git a/spec/support/helpers/test_env.rb b/spec/support/helpers/test_env.rb
index 587d4e22828..d81d0d436a1 100644
--- a/spec/support/helpers/test_env.rb
+++ b/spec/support/helpers/test_env.rb
@@ -41,7 +41,6 @@ module TestEnv
'pages-deploy-target' => '7975be0',
'audio' => 'c3c21fd',
'video' => '8879059',
- 'add-balsamiq-file' => 'b89b56d',
'crlf-diff' => '5938907',
'conflict-start' => '824be60',
'conflict-resolvable' => '1450cd6',
@@ -81,7 +80,9 @@ module TestEnv
'compare-with-merge-head-source' => 'f20a03d',
'compare-with-merge-head-target' => '2f1e176',
'trailers' => 'f0a5ed6',
- 'add_commit_with_5mb_subject' => '8cf8e80'
+ 'add_commit_with_5mb_subject' => '8cf8e80',
+ 'blame-on-renamed' => '32c33da',
+ 'with-executables' => '6b8dc4a'
}.freeze
# gitlab-test-fork is a fork of gitlab-fork, but we don't necessarily
diff --git a/spec/support/helpers/usage_data_helpers.rb b/spec/support/helpers/usage_data_helpers.rb
index b9f90b11a69..50d1b14cf56 100644
--- a/spec/support/helpers/usage_data_helpers.rb
+++ b/spec/support/helpers/usage_data_helpers.rb
@@ -26,7 +26,6 @@ module UsageDataHelpers
COUNTS_KEYS = %i(
assignee_lists
- boards
ci_builds
ci_internal_pipelines
ci_external_pipelines
diff --git a/spec/support/matchers/graphql_matchers.rb b/spec/support/matchers/graphql_matchers.rb
index dcaec176687..3ba88c3ae71 100644
--- a/spec/support/matchers/graphql_matchers.rb
+++ b/spec/support/matchers/graphql_matchers.rb
@@ -7,14 +7,14 @@ RSpec::Matchers.define :require_graphql_authorizations do |*expected|
if klass.respond_to?(:required_permissions)
klass.required_permissions
else
- [klass.to_graphql.metadata[:authorize]]
+ Array.wrap(klass.authorize)
end
end
match do |klass|
actual = permissions_for(klass)
- expect(actual).to match_array(expected)
+ expect(actual).to match_array(expected.compact)
end
failure_message do |klass|
@@ -213,16 +213,16 @@ RSpec::Matchers.define :have_graphql_resolver do |expected|
match do |field|
case expected
when Method
- expect(field.to_graphql.metadata[:type_class].resolve_proc).to eq(expected)
+ expect(field.type_class.resolve_proc).to eq(expected)
else
- expect(field.to_graphql.metadata[:type_class].resolver).to eq(expected)
+ expect(field.type_class.resolver).to eq(expected)
end
end
end
RSpec::Matchers.define :have_graphql_extension do |expected|
match do |field|
- expect(field.to_graphql.metadata[:type_class].extensions).to include(expected)
+ expect(field.type_class.extensions).to include(expected)
end
end
diff --git a/spec/support/matchers/markdown_matchers.rb b/spec/support/matchers/markdown_matchers.rb
index f01c4075eeb..1932f78506f 100644
--- a/spec/support/matchers/markdown_matchers.rb
+++ b/spec/support/matchers/markdown_matchers.rb
@@ -270,7 +270,7 @@ module MarkdownMatchers
set_default_markdown_messages
match do |actual|
- expect(actual).to have_link(href: 'http://localhost:8000/nomnoml/svg/eNqLDsgsSixJrUmtTHXOL80rsVLwzCupKUrMTNHQtC7IzMlJTE_V0KzhUlCITkpNLEqJ1dWNLkgsKsoviUUSs7KLTssvzVHIzS8tyYjligUAMhEd0g==')
+ expect(actual).to have_link(href: 'http://localhost:8000/nomnoml/svg/eNqLDsgsSixJrUmtTHXOL80rsVLwzCupKUrMTNHQtC7IzMlJTE_V0KzhUlCITkpNLEqJ1dWNLkgsKsoviUUSs7KLTssvzVHIzS8tyYjliuUCAE_tHdw=')
end
end
end
diff --git a/spec/support/matchers/project_namespace_matcher.rb b/spec/support/matchers/project_namespace_matcher.rb
index 95aa5429679..8666a605276 100644
--- a/spec/support/matchers/project_namespace_matcher.rb
+++ b/spec/support/matchers/project_namespace_matcher.rb
@@ -10,7 +10,7 @@ RSpec::Matchers.define :be_in_sync_with_project do |project|
project_namespace.present? &&
project.name == project_namespace.name &&
project.path == project_namespace.path &&
- project.namespace == project_namespace.parent &&
+ project.namespace_id == project_namespace.parent_id &&
project.visibility_level == project_namespace.visibility_level &&
project.shared_runners_enabled == project_namespace.shared_runners_enabled
end
diff --git a/spec/support/services/deploy_token_shared_examples.rb b/spec/support/services/deploy_token_shared_examples.rb
index adc5ea0fcdc..d322b3fc81d 100644
--- a/spec/support/services/deploy_token_shared_examples.rb
+++ b/spec/support/services/deploy_token_shared_examples.rb
@@ -19,6 +19,10 @@ RSpec.shared_examples 'a deploy token creation service' do
it 'returns a DeployToken' do
expect(subject[:deploy_token]).to be_an_instance_of DeployToken
end
+
+ it 'sets the creator_id as the id of the current_user' do
+ expect(subject[:deploy_token].read_attribute(:creator_id)).to eq(user.id)
+ end
end
context 'when expires at date is not passed' do
diff --git a/spec/support/services/issuable_update_service_shared_examples.rb b/spec/support/services/issuable_update_service_shared_examples.rb
index 4d2843af1c4..c168df7a7d2 100644
--- a/spec/support/services/issuable_update_service_shared_examples.rb
+++ b/spec/support/services/issuable_update_service_shared_examples.rb
@@ -23,3 +23,47 @@ RSpec.shared_examples 'issuable update service' do
end
end
end
+
+RSpec.shared_examples 'keeps issuable labels sorted after update' do
+ before do
+ update_issuable(label_ids: [label_b.id])
+ end
+
+ context 'when label is changed' do
+ it 'keeps the labels sorted by title ASC' do
+ update_issuable({ add_label_ids: [label_a.id] })
+
+ expect(issuable.labels).to eq([label_a, label_b])
+ end
+ end
+end
+
+RSpec.shared_examples 'broadcasting issuable labels updates' do
+ before do
+ update_issuable(label_ids: [label_a.id])
+ end
+
+ context 'when label is added' do
+ it 'triggers the GraphQL subscription' do
+ expect(GraphqlTriggers).to receive(:issuable_labels_updated).with(issuable)
+
+ update_issuable({ add_label_ids: [label_b.id] })
+ end
+ end
+
+ context 'when label is removed' do
+ it 'triggers the GraphQL subscription' do
+ expect(GraphqlTriggers).to receive(:issuable_labels_updated).with(issuable)
+
+ update_issuable({ remove_label_ids: [label_a.id] })
+ end
+ end
+
+ context 'when label is unchanged' do
+ it 'does not trigger the GraphQL subscription' do
+ expect(GraphqlTriggers).not_to receive(:issuable_labels_updated).with(issuable)
+
+ update_issuable({ label_ids: [label_a.id] })
+ end
+ end
+end
diff --git a/spec/support/shared_contexts/container_repositories_shared_context.rb b/spec/support/shared_contexts/container_repositories_shared_context.rb
index 9a9f80a3cbd..a74b09d38bd 100644
--- a/spec/support/shared_contexts/container_repositories_shared_context.rb
+++ b/spec/support/shared_contexts/container_repositories_shared_context.rb
@@ -1,13 +1,10 @@
# frozen_string_literal: true
RSpec.shared_context 'importable repositories' do
- let_it_be(:root_group) { create(:group) }
- let_it_be(:group) { create(:group, parent_id: root_group.id) }
- let_it_be(:project) { create(:project, namespace: group) }
- let_it_be(:valid_container_repository) { create(:container_repository, project: project, created_at: 2.days.ago) }
- let_it_be(:valid_container_repository2) { create(:container_repository, project: project, created_at: 1.year.ago) }
- let_it_be(:importing_container_repository) { create(:container_repository, :importing, project: project, created_at: 2.days.ago) }
- let_it_be(:new_container_repository) { create(:container_repository, project: project) }
+ let_it_be(:valid_container_repository) { create(:container_repository, created_at: 2.days.ago, migration_plan: 'free') }
+ let_it_be(:valid_container_repository2) { create(:container_repository, created_at: 1.year.ago, migration_plan: 'free') }
+ let_it_be(:importing_container_repository) { create(:container_repository, :importing, created_at: 2.days.ago, migration_plan: 'free') }
+ let_it_be(:new_container_repository) { create(:container_repository, migration_plan: 'free') }
let_it_be(:denied_root_group) { create(:group) }
let_it_be(:denied_group) { create(:group, parent_id: denied_root_group.id) }
@@ -18,7 +15,8 @@ RSpec.shared_context 'importable repositories' do
stub_application_setting(container_registry_import_created_before: 1.day.ago)
stub_feature_flags(
container_registry_phase_2_deny_list: false,
- container_registry_migration_limit_gitlab_org: false
+ container_registry_migration_limit_gitlab_org: false,
+ container_registry_migration_phase2_all_plans: false
)
Feature::FlipperGate.create!(
diff --git a/spec/support/shared_contexts/finders/users_finder_shared_contexts.rb b/spec/support/shared_contexts/finders/users_finder_shared_contexts.rb
index 6a09497a497..ef1c01f72f9 100644
--- a/spec/support/shared_contexts/finders/users_finder_shared_contexts.rb
+++ b/spec/support/shared_contexts/finders/users_finder_shared_contexts.rb
@@ -3,8 +3,10 @@
RSpec.shared_context 'UsersFinder#execute filter by project context' do
let_it_be(:normal_user) { create(:user, username: 'johndoe') }
let_it_be(:admin_user) { create(:user, :admin, username: 'iamadmin') }
+ let_it_be(:banned_user) { create(:user, :banned, username: 'iambanned') }
let_it_be(:blocked_user) { create(:user, :blocked, username: 'notsorandom') }
let_it_be(:external_user) { create(:user, :external) }
+ let_it_be(:unconfirmed_user) { create(:user, confirmed_at: nil) }
let_it_be(:omniauth_user) { create(:omniauth_user, provider: 'twitter', extern_uid: '123456') }
- let_it_be(:internal_user) { User.alert_bot }
+ let_it_be(:internal_user) { User.alert_bot.tap { |u| u.confirm } }
end
diff --git a/spec/support/shared_contexts/lib/container_registry/client_stubs_shared_context.rb b/spec/support/shared_contexts/lib/container_registry/client_stubs_shared_context.rb
index d857e683aa2..196173d4a63 100644
--- a/spec/support/shared_contexts/lib/container_registry/client_stubs_shared_context.rb
+++ b/spec/support/shared_contexts/lib/container_registry/client_stubs_shared_context.rb
@@ -8,8 +8,8 @@ RSpec.shared_context 'container registry client stubs' do
end
end
- def stub_container_registry_gitlab_api_repository_details(client, path:, size_bytes:)
- allow(client).to receive(:repository_details).with(path, with_size: true).and_return('size_bytes' => size_bytes)
+ def stub_container_registry_gitlab_api_repository_details(client, path:, size_bytes:, sizing: :self)
+ allow(client).to receive(:repository_details).with(path, sizing: sizing).and_return('size_bytes' => size_bytes)
end
def stub_container_registry_gitlab_api_network_error(client_method: :supports_gitlab_api?)
diff --git a/spec/support/shared_contexts/markdown_golden_master_shared_examples.rb b/spec/support/shared_contexts/markdown_golden_master_shared_examples.rb
index d0915bbf158..dea03af2248 100644
--- a/spec/support/shared_contexts/markdown_golden_master_shared_examples.rb
+++ b/spec/support/shared_contexts/markdown_golden_master_shared_examples.rb
@@ -64,6 +64,9 @@ RSpec.shared_context 'API::Markdown Golden Master shared context' do |markdown_y
let(:substitutions) { markdown_example.fetch(:substitutions, {}) }
it "verifies conversion of GFM to HTML", :unlimited_max_formatted_output_length do
+ stub_application_setting(plantuml_enabled: true, plantuml_url: 'http://localhost:8080')
+ stub_application_setting(kroki_enabled: true, kroki_url: 'http://localhost:8000')
+
pending pending_reason if pending_reason
normalized_example_html = normalize_html(example_html, substitutions)
diff --git a/spec/support/shared_contexts/navbar_structure_context.rb b/spec/support/shared_contexts/navbar_structure_context.rb
index b4a71f52092..65c7f63cf6e 100644
--- a/spec/support/shared_contexts/navbar_structure_context.rb
+++ b/spec/support/shared_contexts/navbar_structure_context.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
RSpec.shared_context 'project navbar structure' do
+ include NavbarStructureHelper
+
let(:security_and_compliance_nav_item) do
{
nav_item: _('Security & Compliance'),
@@ -93,13 +95,7 @@ RSpec.shared_context 'project navbar structure' do
},
{
nav_item: _('Analytics'),
- nav_sub_items: [
- _('Value stream'),
- _('CI/CD'),
- (_('Code review') if Gitlab.ee?),
- (_('Merge request') if Gitlab.ee?),
- _('Repository')
- ]
+ nav_sub_items: analytics_sub_nav_item
},
{
nav_item: _('Wiki'),
diff --git a/spec/support/shared_contexts/serializers/group_group_link_shared_context.rb b/spec/support/shared_contexts/serializers/group_group_link_shared_context.rb
index fce78957eba..efd5d344a28 100644
--- a/spec/support/shared_contexts/serializers/group_group_link_shared_context.rb
+++ b/spec/support/shared_contexts/serializers/group_group_link_shared_context.rb
@@ -1,10 +1,10 @@
# frozen_string_literal: true
RSpec.shared_context 'group_group_link' do
- let(:shared_with_group) { create(:group) }
- let(:shared_group) { create(:group) }
+ let_it_be(:shared_with_group) { create(:group) }
+ let_it_be(:shared_group) { create(:group) }
- let!(:group_group_link) do
+ let_it_be(:group_group_link) do
create(
:group_group_link,
{
diff --git a/spec/support/shared_contexts/services/service_ping/stubbed_service_ping_metrics_definitions_shared_context.rb b/spec/support/shared_contexts/services/service_ping/stubbed_service_ping_metrics_definitions_shared_context.rb
index e1d864213b5..37d410a35bf 100644
--- a/spec/support/shared_contexts/services/service_ping/stubbed_service_ping_metrics_definitions_shared_context.rb
+++ b/spec/support/shared_contexts/services/service_ping/stubbed_service_ping_metrics_definitions_shared_context.rb
@@ -21,7 +21,7 @@ RSpec.shared_context 'stubbed service ping metrics definitions' do
let(:optional_metrics) do
[
- metric_attributes('counts.boards', 'optional', 'number'),
+ metric_attributes('counts.boards', 'optional', 'number', 'CountBoardsMetric'),
metric_attributes('gitaly.filesystems', '').except('data_category'),
metric_attributes('usage_activity_by_stage.monitor.projects_with_enabled_alert_integrations_histogram', 'optional', 'object'),
metric_attributes('topology', 'optional', 'object')
@@ -43,11 +43,14 @@ RSpec.shared_context 'stubbed service ping metrics definitions' do
Gitlab::Usage::MetricDefinition.instance_variable_set(:@all, nil)
end
- def metric_attributes(key_path, category, value_type = 'string')
+ def metric_attributes(key_path, category, value_type = 'string', instrumentation_class = '')
{
'key_path' => key_path,
'data_category' => category,
- 'value_type' => value_type
+ 'value_type' => value_type,
+ 'status' => 'active',
+ 'instrumentation_class' => instrumentation_class,
+ 'time_frame' => 'all'
}
end
end
diff --git a/spec/support/shared_contexts/url_shared_context.rb b/spec/support/shared_contexts/url_shared_context.rb
index da1d6e0049c..0e1534bf6c7 100644
--- a/spec/support/shared_contexts/url_shared_context.rb
+++ b/spec/support/shared_contexts/url_shared_context.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
RSpec.shared_context 'valid urls with CRLF' do
- let(:valid_urls_with_CRLF) do
+ let(:valid_urls_with_crlf) do
[
"http://example.com/pa%0dth",
"http://example.com/pa%0ath",
@@ -16,7 +16,7 @@ RSpec.shared_context 'valid urls with CRLF' do
end
RSpec.shared_context 'invalid urls' do
- let(:urls_with_CRLF) do
+ let(:urls_with_crlf) do
[
"git://example.com/pa%0dth",
"git://example.com/pa%0ath",
diff --git a/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb b/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb
index 1e303197990..15590fd10dc 100644
--- a/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb
+++ b/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb
@@ -138,7 +138,7 @@ RSpec.shared_examples 'multiple issue boards' do
wait_for_requests
- dropdown_selector = '.js-boards-selector .dropdown-menu'
+ dropdown_selector = '[data-testid="boards-selector"] .dropdown-menu'
page.within(dropdown_selector) do
yield
end
diff --git a/spec/support/shared_examples/controllers/githubish_import_controller_shared_examples.rb b/spec/support/shared_examples/controllers/githubish_import_controller_shared_examples.rb
index 46fc2cbdc9b..2ea98002de1 100644
--- a/spec/support/shared_examples/controllers/githubish_import_controller_shared_examples.rb
+++ b/spec/support/shared_examples/controllers/githubish_import_controller_shared_examples.rb
@@ -184,6 +184,41 @@ RSpec.shared_examples 'a GitHub-ish import controller: GET status' do
expect(json_response.dig("provider_repos").count).to eq(1)
end
end
+
+ context 'when namespace_id query param is provided' do
+ let_it_be(:current_user) { create(:user) }
+
+ let(:namespace) { create(:namespace) }
+
+ before do
+ allow(controller).to receive(:current_user).and_return(current_user)
+ end
+
+ context 'when user is allowed to create projects in this namespace' do
+ before do
+ allow(current_user).to receive(:can?).and_return(true)
+ end
+
+ it 'provides namespace to the template' do
+ get :status, params: { namespace_id: namespace.id }, format: :html
+
+ expect(response).to have_gitlab_http_status :ok
+ expect(assigns(:namespace)).to eq(namespace)
+ end
+ end
+
+ context 'when user is not allowed to create projects in this namespace' do
+ before do
+ allow(current_user).to receive(:can?).and_return(false)
+ end
+
+ it 'renders 404' do
+ get :status, params: { namespace_id: namespace.id }, format: :html
+
+ expect(response).to have_gitlab_http_status :not_found
+ end
+ end
+ end
end
end
@@ -515,7 +550,7 @@ RSpec.shared_examples 'a GitHub-ish import controller: GET realtime_changes' do
get :realtime_changes
- expect(json_response).to eq([{ "id" => project.id, "import_status" => project.import_status }])
+ expect(json_response).to match([a_hash_including({ "id" => project.id, "import_status" => project.import_status })])
expect(Integer(response.headers['Poll-Interval'])).to be > -1
end
end
diff --git a/spec/support/shared_examples/controllers/wiki_actions_shared_examples.rb b/spec/support/shared_examples/controllers/wiki_actions_shared_examples.rb
index bf26922d9c5..885c0229038 100644
--- a/spec/support/shared_examples/controllers/wiki_actions_shared_examples.rb
+++ b/spec/support/shared_examples/controllers/wiki_actions_shared_examples.rb
@@ -150,6 +150,7 @@ RSpec.shared_examples 'wiki controller actions' do
expect(response).to render_template('shared/wikis/diff')
expect(assigns(:diffs)).to be_a(Gitlab::Diff::FileCollection::Base)
expect(assigns(:diff_notes_disabled)).to be(true)
+ expect(assigns(:page).content).to be_empty
end
end
@@ -475,9 +476,13 @@ RSpec.shared_examples 'wiki controller actions' do
context 'when page exists' do
shared_examples 'deletes the page' do
specify do
- expect do
- request
- end.to change { wiki.list_pages.size }.by(-1)
+ aggregate_failures do
+ expect do
+ request
+ end.to change { wiki.list_pages.size }.by(-1)
+
+ expect(assigns(:page).content).to be_empty
+ end
end
end
diff --git a/spec/support/shared_examples/features/access_tokens_shared_examples.rb b/spec/support/shared_examples/features/access_tokens_shared_examples.rb
index ae246a87bb6..215d9d3e5a8 100644
--- a/spec/support/shared_examples/features/access_tokens_shared_examples.rb
+++ b/spec/support/shared_examples/features/access_tokens_shared_examples.rb
@@ -29,15 +29,15 @@ RSpec.shared_examples 'resource access tokens creation' do |resource_type|
click_on '1'
# Scopes
- check 'api'
check 'read_api'
+ check 'read_repository'
click_on "Create #{resource_type} access token"
expect(active_resource_access_tokens).to have_text(name)
expect(active_resource_access_tokens).to have_text('in')
- expect(active_resource_access_tokens).to have_text('api')
expect(active_resource_access_tokens).to have_text('read_api')
+ expect(active_resource_access_tokens).to have_text('read_repository')
expect(active_resource_access_tokens).to have_text('Maintainer')
expect(created_resource_access_token).not_to be_empty
end
diff --git a/spec/support/shared_examples/features/content_editor_shared_examples.rb b/spec/support/shared_examples/features/content_editor_shared_examples.rb
index 2332285540a..5c44cb7f04b 100644
--- a/spec/support/shared_examples/features/content_editor_shared_examples.rb
+++ b/spec/support/shared_examples/features/content_editor_shared_examples.rb
@@ -1,14 +1,48 @@
# frozen_string_literal: true
RSpec.shared_examples 'edits content using the content editor' do
- it 'formats text as bold using bubble menu' do
- content_editor_testid = '[data-testid="content-editor"] [contenteditable]'
+ content_editor_testid = '[data-testid="content-editor"] [contenteditable].ProseMirror'
- expect(page).to have_css(content_editor_testid)
+ describe 'formatting bubble menu' do
+ it 'shows a formatting bubble menu for a regular paragraph' do
+ expect(page).to have_css(content_editor_testid)
- find(content_editor_testid).send_keys 'Typing text in the content editor'
- find(content_editor_testid).send_keys [:shift, :left]
+ find(content_editor_testid).send_keys 'Typing text in the content editor'
+ find(content_editor_testid).send_keys [:shift, :left]
- expect(page).to have_css('[data-testid="formatting-bubble-menu"]')
+ expect(page).to have_css('[data-testid="formatting-bubble-menu"]')
+ end
+
+ it 'does not show a formatting bubble menu for code' do
+ find(content_editor_testid).send_keys 'This is a `code`'
+ find(content_editor_testid).send_keys [:shift, :left]
+
+ expect(page).not_to have_css('[data-testid="formatting-bubble-menu"]')
+ end
+ end
+
+ describe 'code block bubble menu' do
+ it 'shows a code block bubble menu for a code block' do
+ find(content_editor_testid).send_keys '```js ' # trigger input rule
+ find(content_editor_testid).send_keys 'var a = 0'
+ find(content_editor_testid).send_keys [:shift, :left]
+
+ expect(page).not_to have_css('[data-testid="formatting-bubble-menu"]')
+ expect(page).to have_css('[data-testid="code-block-bubble-menu"]')
+ end
+
+ it 'sets code block type to "javascript" for `js`' do
+ find(content_editor_testid).send_keys '```js '
+ find(content_editor_testid).send_keys 'var a = 0'
+
+ expect(find('[data-testid="code-block-bubble-menu"]')).to have_text('Javascript')
+ end
+
+ it 'sets code block type to "Custom (nomnoml)" for `nomnoml`' do
+ find(content_editor_testid).send_keys '```nomnoml '
+ find(content_editor_testid).send_keys 'test'
+
+ expect(find('[data-testid="code-block-bubble-menu"]')).to have_text('Custom (nomnoml)')
+ end
end
end
diff --git a/spec/support/shared_examples/features/inviting_members_shared_examples.rb b/spec/support/shared_examples/features/inviting_members_shared_examples.rb
new file mode 100644
index 00000000000..58357b262f5
--- /dev/null
+++ b/spec/support/shared_examples/features/inviting_members_shared_examples.rb
@@ -0,0 +1,175 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'inviting members' do |snowplow_invite_label|
+ before_all do
+ group.add_owner(user1)
+ end
+
+ it 'adds user as member', :js, :snowplow, :aggregate_failures do
+ visit members_page_path
+
+ invite_member(user2.name, role: 'Reporter')
+
+ page.within find_member_row(user2) do
+ expect(page).to have_button('Reporter')
+ end
+
+ expect_snowplow_event(
+ category: 'Members::InviteService',
+ action: 'create_member',
+ label: snowplow_invite_label,
+ property: 'existing_user',
+ user: user1
+ )
+ end
+
+ it 'invites user by email', :js, :snowplow, :aggregate_failures do
+ visit members_page_path
+
+ invite_member('test@example.com', role: 'Reporter')
+
+ click_link 'Invited'
+
+ page.within find_invited_member_row('test@example.com') do
+ expect(page).to have_button('Reporter')
+ end
+
+ expect_snowplow_event(
+ category: 'Members::InviteService',
+ action: 'create_member',
+ label: snowplow_invite_label,
+ property: 'net_new_user',
+ user: user1
+ )
+ end
+
+ it 'invites user by username and invites user by email', :js, :aggregate_failures do
+ visit members_page_path
+
+ invite_member([user2.name, 'test@example.com'], role: 'Reporter')
+
+ page.within find_member_row(user2) do
+ expect(page).to have_button('Reporter')
+ end
+
+ click_link 'Invited'
+
+ page.within find_invited_member_row('test@example.com') do
+ expect(page).to have_button('Reporter')
+ end
+ end
+
+ context 'when member is already a member by username' do
+ it 'updates the member for that user', :js do
+ visit members_page_path
+
+ invite_member(user2.name, role: 'Developer')
+
+ invite_member(user2.name, role: 'Reporter', refresh: false)
+
+ expect(page).not_to have_selector(invite_modal_selector)
+
+ page.refresh
+
+ page.within find_invited_member_row(user2.name) do
+ expect(page).to have_button('Reporter')
+ end
+ end
+ end
+
+ context 'when member is already a member by email' do
+ it 'fails with an error', :js do
+ visit members_page_path
+
+ invite_member('test@example.com', role: 'Developer')
+
+ invite_member('test@example.com', role: 'Reporter', refresh: false)
+
+ expect(page).to have_selector(invite_modal_selector)
+ expect(page).to have_content("The member's email address has already been taken")
+
+ page.refresh
+
+ click_link 'Invited'
+
+ page.within find_invited_member_row('test@example.com') do
+ expect(page).to have_button('Developer')
+ end
+ end
+ end
+
+ context 'when inviting a parent group member to the sub-entity' do
+ before_all do
+ group.add_owner(user1)
+ group.add_developer(user2)
+ end
+
+ context 'when role is higher than parent group membership' do
+ let(:role) { 'Maintainer' }
+
+ it 'adds the user as a member on sub-entity with higher access level', :js do
+ visit subentity_members_page_path
+
+ invite_member(user2.name, role: role, refresh: false)
+
+ expect(page).not_to have_selector(invite_modal_selector)
+
+ page.refresh
+
+ page.within find_invited_member_row(user2.name) do
+ expect(page).to have_button(role)
+ end
+ end
+ end
+
+ context 'when role is lower than parent group membership' do
+ let(:role) { 'Reporter' }
+
+ it 'fails with an error', :js do
+ visit subentity_members_page_path
+
+ invite_member(user2.name, role: role, refresh: false)
+
+ expect(page).to have_selector(invite_modal_selector)
+ expect(page).to have_content "Access level should be greater than or equal to Developer inherited membership " \
+ "from group #{group.name}"
+
+ page.refresh
+
+ page.within find_invited_member_row(user2.name) do
+ expect(page).to have_content('Developer')
+ expect(page).not_to have_button('Developer')
+ end
+ end
+
+ context 'when there are multiple users invited with errors' do
+ let_it_be(:user3) { create(:user) }
+
+ before do
+ group.add_maintainer(user3)
+ end
+
+ it 'only shows the first user error', :js do
+ visit subentity_members_page_path
+
+ invite_member([user2.name, user3.name], role: role, refresh: false)
+
+ expect(page).to have_selector(invite_modal_selector)
+ expect(page).to have_text("Access level should be greater than or equal to", count: 1)
+
+ page.refresh
+
+ page.within find_invited_member_row(user2.name) do
+ expect(page).to have_content('Developer')
+ expect(page).not_to have_button('Developer')
+ end
+
+ page.within find_invited_member_row(user3.name) do
+ expect(page).to have_content('Maintainer')
+ expect(page).not_to have_button('Maintainer')
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/features/project_upload_files_shared_examples.rb b/spec/support/shared_examples/features/project_upload_files_shared_examples.rb
index 066c3e17a09..0a5ad5a59c0 100644
--- a/spec/support/shared_examples/features/project_upload_files_shared_examples.rb
+++ b/spec/support/shared_examples/features/project_upload_files_shared_examples.rb
@@ -62,7 +62,7 @@ RSpec.shared_examples 'it uploads and commits a new image file' do |drop: false|
visit(project_blob_path(project, 'upload_image/logo_sample.svg'))
- expect(page).to have_css('.file-content img')
+ expect(page).to have_css('.file-holder img')
end
end
diff --git a/spec/support/shared_examples/features/runners_shared_examples.rb b/spec/support/shared_examples/features/runners_shared_examples.rb
new file mode 100644
index 00000000000..d9460c7b8f1
--- /dev/null
+++ b/spec/support/shared_examples/features/runners_shared_examples.rb
@@ -0,0 +1,141 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'shows and resets runner registration token' do
+ include Spec::Support::Helpers::ModalHelpers
+ include Spec::Support::Helpers::Features::RunnersHelpers
+
+ before do
+ click_on dropdown_text
+ end
+
+ describe 'shows registration instructions' do
+ before do
+ click_on 'Show runner installation and registration instructions'
+
+ wait_for_requests
+ end
+
+ it 'opens runner installation modal', :aggregate_failures do
+ within_modal do
+ expect(page).to have_text "Install a runner"
+ expect(page).to have_text "Environment"
+ expect(page).to have_text "Architecture"
+ expect(page).to have_text "Download and install binary"
+ end
+ end
+
+ it 'dismisses runner installation modal' do
+ within_modal do
+ click_button('Close', match: :first)
+ end
+
+ expect(page).not_to have_text "Install a runner"
+ end
+ end
+
+ it 'has a registration token' do
+ click_on 'Click to reveal'
+ expect(page.find('[data-testid="token-value"] input').value).to have_content(registration_token)
+ end
+
+ describe 'reset registration token' do
+ let!(:old_registration_token) { find('[data-testid="token-value"] input').value }
+
+ before do
+ click_on 'Reset registration token'
+
+ within_modal do
+ click_button('Reset token', match: :first)
+ end
+
+ wait_for_requests
+ end
+
+ it 'changes registration token' do
+ expect(find('.gl-toast')).to have_content('New registration token generated!')
+
+ click_on dropdown_text
+ click_on 'Click to reveal'
+
+ expect(old_registration_token).not_to eq registration_token
+ end
+ end
+end
+
+RSpec.shared_examples 'shows no runners' do
+ it 'shows counts with 0' do
+ expect(page).to have_text "Online runners 0"
+ expect(page).to have_text "Offline runners 0"
+ expect(page).to have_text "Stale runners 0"
+ end
+
+ it 'shows "no runners" message' do
+ expect(page).to have_text 'No runners found'
+ end
+end
+
+RSpec.shared_examples 'shows runner in list' do
+ it 'does not show empty state' do
+ expect(page).not_to have_content 'No runners found'
+ end
+
+ it 'shows runner row' do
+ within_runner_row(runner.id) do
+ expect(page).to have_text "##{runner.id}"
+ expect(page).to have_text runner.short_sha
+ expect(page).to have_text runner.description
+ end
+ end
+end
+
+RSpec.shared_examples 'pauses, resumes and deletes a runner' do
+ include Spec::Support::Helpers::ModalHelpers
+
+ it 'pauses and resumes runner' do
+ within_runner_row(runner.id) do
+ click_button "Pause"
+
+ expect(page).to have_text 'paused'
+ expect(page).to have_button 'Resume'
+ expect(page).not_to have_button 'Pause'
+
+ click_button "Resume"
+
+ expect(page).not_to have_text 'paused'
+ expect(page).not_to have_button 'Resume'
+ expect(page).to have_button 'Pause'
+ end
+ end
+
+ describe 'deletes runner' do
+ before do
+ within_runner_row(runner.id) do
+ click_on 'Delete runner'
+ end
+ end
+
+ it 'shows a confirmation modal' do
+ expect(page).to have_text "Delete runner ##{runner.id} (#{runner.short_sha})?"
+ expect(page).to have_text "Are you sure you want to continue?"
+ end
+
+ it 'deletes a runner' do
+ within_modal do
+ click_on 'Delete runner'
+ end
+
+ expect(page.find('.gl-toast')).to have_text(/Runner .+ deleted/)
+ expect(page).not_to have_content runner.description
+ end
+
+ it 'cancels runner deletion' do
+ within_modal do
+ click_on 'Cancel'
+ end
+
+ wait_for_requests
+
+ expect(page).to have_content runner.description
+ end
+ end
+end
diff --git a/spec/support/shared_examples/features/search/search_timeouts_shared_examples.rb b/spec/support/shared_examples/features/search/search_timeouts_shared_examples.rb
index bb5460e2a6f..095c48cade8 100644
--- a/spec/support/shared_examples/features/search/search_timeouts_shared_examples.rb
+++ b/spec/support/shared_examples/features/search/search_timeouts_shared_examples.rb
@@ -11,6 +11,7 @@ RSpec.shared_examples 'search timeouts' do |scope|
end
it 'renders timeout information' do
+ # expect(page).to have_content('This endpoint has been requested too many times.')
expect(page).to have_content('Your search timed out')
end
diff --git a/spec/support/shared_examples/features/wiki/user_creates_wiki_page_shared_examples.rb b/spec/support/shared_examples/features/wiki/user_creates_wiki_page_shared_examples.rb
index f676b6aa60d..41b1964cff0 100644
--- a/spec/support/shared_examples/features/wiki/user_creates_wiki_page_shared_examples.rb
+++ b/spec/support/shared_examples/features/wiki/user_creates_wiki_page_shared_examples.rb
@@ -20,6 +20,12 @@ RSpec.shared_examples 'User creates wiki page' do
click_link "Create your first page"
end
+ it 'shows all available formats in the dropdown' do
+ Wiki::VALID_USER_MARKUPS.each do |key, markup|
+ expect(page).to have_css("#wiki_format option[value=#{key}]", text: markup[:name])
+ end
+ end
+
it "disables the submit button", :js do
page.within(".wiki-form") do
fill_in(:wiki_content, with: "")
diff --git a/spec/support/shared_examples/features/wiki/user_updates_wiki_page_shared_examples.rb b/spec/support/shared_examples/features/wiki/user_updates_wiki_page_shared_examples.rb
index 85490bffc0e..12a4c6d7583 100644
--- a/spec/support/shared_examples/features/wiki/user_updates_wiki_page_shared_examples.rb
+++ b/spec/support/shared_examples/features/wiki/user_updates_wiki_page_shared_examples.rb
@@ -145,19 +145,6 @@ RSpec.shared_examples 'User updates wiki page' do
it_behaves_like 'edits content using the content editor'
end
-
- context 'with feature flag off' do
- before do
- stub_feature_flags(wiki_switch_between_content_editor_raw_markdown: false)
- visit(wiki_path(wiki))
-
- click_link('Edit')
-
- click_button 'Use the new editor'
- end
-
- it_behaves_like 'edits content using the content editor'
- end
end
end
diff --git a/spec/support/shared_examples/graphql/mutations/boards_create_shared_examples.rb b/spec/support/shared_examples/graphql/mutations/boards_create_shared_examples.rb
index 2e3a3ce6b41..04bb2fb69bb 100644
--- a/spec/support/shared_examples/graphql/mutations/boards_create_shared_examples.rb
+++ b/spec/support/shared_examples/graphql/mutations/boards_create_shared_examples.rb
@@ -3,16 +3,6 @@
RSpec.shared_examples 'boards create mutation' do
include GraphqlHelpers
- let_it_be(:current_user, reload: true) { create(:user) }
- let(:name) { 'board name' }
- let(:mutation) { graphql_mutation(:create_board, params) }
-
- subject { post_graphql_mutation(mutation, current_user: current_user) }
-
- def mutation_response
- graphql_mutation_response(:create_board)
- end
-
context 'when the user does not have permission' do
it_behaves_like 'a mutation that returns a top-level access error'
diff --git a/spec/support/shared_examples/graphql/notes_creation_shared_examples.rb b/spec/support/shared_examples/graphql/notes_creation_shared_examples.rb
index 56b6dc682eb..2c6118779e6 100644
--- a/spec/support/shared_examples/graphql/notes_creation_shared_examples.rb
+++ b/spec/support/shared_examples/graphql/notes_creation_shared_examples.rb
@@ -85,3 +85,14 @@ RSpec.shared_examples 'a Note mutation when there are rate limit validation erro
end
end
end
+
+RSpec.shared_examples 'a Note mutation with confidential notes' do
+ it_behaves_like 'a Note mutation that creates a Note'
+
+ it 'returns a Note with confidentiality enabled' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(mutation_response).to have_key('note')
+ expect(mutation_response['note']['confidential']).to eq(true)
+ end
+end
diff --git a/spec/support/shared_examples/graphql/types/gitlab_style_deprecations_shared_examples.rb b/spec/support/shared_examples/graphql/types/gitlab_style_deprecations_shared_examples.rb
index efb2c466f70..3caf153c2fa 100644
--- a/spec/support/shared_examples/graphql/types/gitlab_style_deprecations_shared_examples.rb
+++ b/spec/support/shared_examples/graphql/types/gitlab_style_deprecations_shared_examples.rb
@@ -62,9 +62,9 @@ RSpec.shared_examples 'Gitlab-style deprecations' do
expect(deprecable.deprecation_reason).to include 'This was renamed.'
end
- it 'supports named reasons: discouraged' do
- deprecable = subject(deprecated: { milestone: '1.10', reason: :discouraged })
+ it 'supports named reasons: alpha' do
+ deprecable = subject(deprecated: { milestone: '1.10', reason: :alpha })
- expect(deprecable.deprecation_reason).to include 'Use of this is not recommended.'
+ expect(deprecable.deprecation_reason).to include 'This feature is in Alpha'
end
end
diff --git a/spec/support/shared_examples/helpers/wiki_helpers_shared_examples.rb b/spec/support/shared_examples/helpers/wiki_helpers_shared_examples.rb
new file mode 100644
index 00000000000..c2c27fb65ca
--- /dev/null
+++ b/spec/support/shared_examples/helpers/wiki_helpers_shared_examples.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'wiki endpoint helpers' do
+ let(:resource_path) { page.wiki.container.class.to_s.pluralize.downcase }
+ let(:url) { "/api/v4/#{resource_path}/#{page.wiki.container.id}/wikis/#{page.slug}?version=#{page.version.id}"}
+
+ it 'returns the full endpoint url' do
+ expect(helper.wiki_page_render_api_endpoint(page)).to end_with(url)
+ end
+
+ context 'when relative url is set' do
+ let(:relative_url) { "/gitlab#{url}" }
+
+ it 'returns the full endpoint url with the relative path' do
+ stub_config_setting(relative_url_root: '/gitlab')
+
+ expect(helper.wiki_page_render_api_endpoint(page)).to end_with(relative_url)
+ end
+ end
+end
diff --git a/spec/support/shared_examples/incident_management/issuable_escalation_statuses/build_examples.rb b/spec/support/shared_examples/incident_management/issuable_escalation_statuses/build_examples.rb
new file mode 100644
index 00000000000..050fdc3fff7
--- /dev/null
+++ b/spec/support/shared_examples/incident_management/issuable_escalation_statuses/build_examples.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'initializes new escalation status with expected attributes' do |attributes = {}|
+ let(:expected_attributes) { attributes }
+
+ specify do
+ expect { execute }.to change { incident.escalation_status }
+ .from(nil)
+ .to(instance_of(::IncidentManagement::IssuableEscalationStatus))
+
+ expect(incident.escalation_status).to have_attributes(
+ id: nil,
+ issue_id: incident.id,
+ policy_id: nil,
+ escalations_started_at: nil,
+ status_event: nil,
+ **expected_attributes
+ )
+ end
+end
diff --git a/spec/support/shared_examples/lib/gitlab/event_store_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/event_store_shared_examples.rb
new file mode 100644
index 00000000000..4fc15cacab4
--- /dev/null
+++ b/spec/support/shared_examples/lib/gitlab/event_store_shared_examples.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'subscribes to event' do
+ include AfterNextHelpers
+
+ it 'consumes the published event', :sidekiq_inline do
+ expect_next(described_class)
+ .to receive(:handle_event)
+ .with(instance_of(event.class))
+ .and_call_original
+
+ ::Gitlab::EventStore.publish(event)
+ end
+end
+
+def consume_event(subscriber:, event:)
+ subscriber.new.perform(event.class.name, event.data)
+end
diff --git a/spec/support/shared_examples/lib/gitlab/usage_data_counters/issuable_activity_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/usage_data_counters/issuable_activity_shared_examples.rb
index 4b956c2b566..b5d93aec1bf 100644
--- a/spec/support/shared_examples/lib/gitlab/usage_data_counters/issuable_activity_shared_examples.rb
+++ b/spec/support/shared_examples/lib/gitlab/usage_data_counters/issuable_activity_shared_examples.rb
@@ -5,7 +5,7 @@ RSpec.shared_examples 'a daily tracked issuable event' do
stub_application_setting(usage_ping_enabled: true)
end
- def count_unique(date_from:, date_to:)
+ def count_unique(date_from: 1.minute.ago, date_to: 1.minute.from_now)
Gitlab::UsageDataCounters::HLLRedisCounter.unique_events(event_names: action, start_date: date_from, end_date: date_to)
end
@@ -14,6 +14,7 @@ RSpec.shared_examples 'a daily tracked issuable event' do
expect(track_action(author: user1)).to be_truthy
expect(track_action(author: user1)).to be_truthy
expect(track_action(author: user2)).to be_truthy
+ expect(count_unique).to eq(2)
end
end
diff --git a/spec/support/shared_examples/lib/sidebars/projects/menus/zentao_menu_shared_examples.rb b/spec/support/shared_examples/lib/sidebars/projects/menus/zentao_menu_shared_examples.rb
index b4c438771ce..d816754f328 100644
--- a/spec/support/shared_examples/lib/sidebars/projects/menus/zentao_menu_shared_examples.rb
+++ b/spec/support/shared_examples/lib/sidebars/projects/menus/zentao_menu_shared_examples.rb
@@ -34,8 +34,16 @@ RSpec.shared_examples 'ZenTao menu with CE version' do
expect(subject.link).to eq zentao_integration.url
end
- it 'contains only open ZenTao item' do
- expect(subject.renderable_items.map(&:item_id)).to match_array [:open_zentao]
+ it 'renders external-link icon' do
+ expect(subject.sprite_icon).to eq 'external-link'
+ end
+
+ it 'renders ZenTao menu' do
+ expect(subject.title).to eq s_('ZentaoIntegration|ZenTao')
+ end
+
+ it 'does not contain items' do
+ expect(subject.renderable_items.count).to eq 0
end
end
end
diff --git a/spec/support/shared_examples/lib/wikis_api_examples.rb b/spec/support/shared_examples/lib/wikis_api_examples.rb
index f068a7676ad..c57ac328a60 100644
--- a/spec/support/shared_examples/lib/wikis_api_examples.rb
+++ b/spec/support/shared_examples/lib/wikis_api_examples.rb
@@ -80,6 +80,8 @@ RSpec.shared_examples_for 'wikis API returns wiki page' do
context 'when wiki page has versions' do
let(:new_content) { 'New content' }
+ let(:old_content) { page.content }
+ let(:old_version_id) { page.version.id }
before do
wiki.update_page(page.page, content: new_content, message: 'updated page')
@@ -96,10 +98,10 @@ RSpec.shared_examples_for 'wikis API returns wiki page' do
end
context 'when version param is set' do
- let(:params) { { version: page.version.id } }
+ let(:params) { { version: old_version_id } }
it 'retrieves the specific page version' do
- expect(json_response['content']).to eq(page.content)
+ expect(json_response['content']).to eq(old_content)
end
context 'when version param is not valid or inexistent' do
diff --git a/spec/support/shared_examples/models/application_setting_shared_examples.rb b/spec/support/shared_examples/models/application_setting_shared_examples.rb
index 38f5c7be393..74ec6474e80 100644
--- a/spec/support/shared_examples/models/application_setting_shared_examples.rb
+++ b/spec/support/shared_examples/models/application_setting_shared_examples.rb
@@ -239,7 +239,7 @@ RSpec.shared_examples 'application settings examples' do
describe '#allowed_key_types' do
it 'includes all key types by default' do
- expect(setting.allowed_key_types).to contain_exactly(*described_class::SUPPORTED_KEY_TYPES)
+ expect(setting.allowed_key_types).to contain_exactly(*Gitlab::SSHPublicKey.supported_types)
end
it 'excludes disabled key types' do
diff --git a/spec/support/shared_examples/models/concerns/bulk_users_by_email_load_shared_examples.rb b/spec/support/shared_examples/models/concerns/bulk_users_by_email_load_shared_examples.rb
new file mode 100644
index 00000000000..c3e9ff5c91a
--- /dev/null
+++ b/spec/support/shared_examples/models/concerns/bulk_users_by_email_load_shared_examples.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'a BulkUsersByEmailLoad model' do
+ describe '#users_by_emails' do
+ let_it_be(:user1) { create(:user, emails: [create(:email, email: 'user1@example.com')]) }
+ let_it_be(:user2) { create(:user, emails: [create(:email, email: 'user2@example.com')]) }
+
+ subject(:model) { described_class.new(id: non_existing_record_id) }
+
+ context 'when nothing is loaded' do
+ let(:passed_emails) { [user1.emails.first.email, user2.email] }
+
+ it 'preforms the yielded query and supplies the data with only emails desired' do
+ expect(model.users_by_emails(passed_emails).keys).to contain_exactly(*passed_emails)
+ end
+ end
+
+ context 'when store is preloaded', :request_store do
+ let(:passed_emails) { [user1.emails.first.email, user2.email, user1.email] }
+ let(:resource_data) do
+ {
+ user1.emails.first.email => instance_double('User'),
+ user2.email => instance_double('User')
+ }
+ end
+
+ before do
+ Gitlab::SafeRequestStore["user_by_email_for_users:#{model.class.name}:#{model.id}"] = resource_data
+ end
+
+ it 'passes back loaded data and does not update the items that already exist' do
+ users_by_emails = model.users_by_emails(passed_emails)
+
+ expect(users_by_emails.keys).to contain_exactly(*passed_emails)
+ expect(users_by_emails).to include(resource_data.merge(user1.email => user1))
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/models/concerns/from_set_operator_shared_examples.rb b/spec/support/shared_examples/models/concerns/from_set_operator_shared_examples.rb
index 6b208c0024d..e625ba785d2 100644
--- a/spec/support/shared_examples/models/concerns/from_set_operator_shared_examples.rb
+++ b/spec/support/shared_examples/models/concerns/from_set_operator_shared_examples.rb
@@ -20,6 +20,12 @@ RSpec.shared_examples 'from set operator' do |sql_klass|
expect(query.to_sql).to match(/FROM \(\(SELECT.+\)\n#{operator_keyword}\n\(SELECT.+\)\) users/m)
end
+ it "returns empty set when passing empty array" do
+ query = model.public_send(operator_method, [])
+
+ expect(query.to_sql).to match(/WHERE \(1=0\)/m)
+ end
+
it 'supports the use of a custom alias for the sub query' do
query = model.public_send(operator_method,
[model.where(id: 1), model.where(id: 2)],
diff --git a/spec/support/shared_examples/models/concerns/integrations/slack_mattermost_notifier_shared_examples.rb b/spec/support/shared_examples/models/concerns/integrations/slack_mattermost_notifier_shared_examples.rb
index d6415e98289..da5c35c970a 100644
--- a/spec/support/shared_examples/models/concerns/integrations/slack_mattermost_notifier_shared_examples.rb
+++ b/spec/support/shared_examples/models/concerns/integrations/slack_mattermost_notifier_shared_examples.rb
@@ -227,9 +227,7 @@ RSpec.shared_examples Integrations::SlackMattermostNotifier do |integration_name
end
context 'for confidential notes' do
- before_all do
- issue_note.update!(confidential: true)
- end
+ let_it_be(:issue_note) { create(:note_on_issue, project: project, note: "issue note", confidential: true) }
it 'falls back to note channel' do
expect(::Slack::Messenger).to execute_with_options(channel: ['random'])
diff --git a/spec/support/shared_examples/models/group_shared_examples.rb b/spec/support/shared_examples/models/group_shared_examples.rb
new file mode 100644
index 00000000000..9f3359ba4ab
--- /dev/null
+++ b/spec/support/shared_examples/models/group_shared_examples.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'checks self and root ancestor feature flag' do
+ let_it_be(:root_group) { create(:group) }
+ let_it_be(:group) { create(:group, parent: root_group) }
+ let_it_be(:project) { create(:project, group: group) }
+
+ subject { group.public_send(feature_flag_method) }
+
+ context 'when FF is enabled for the root group' do
+ before do
+ stub_feature_flags(feature_flag => root_group)
+ end
+
+ it { is_expected.to be_truthy }
+ end
+
+ context 'when FF is enabled for the group' do
+ before do
+ stub_feature_flags(feature_flag => group)
+ end
+
+ it { is_expected.to be_truthy }
+
+ context 'when root_group is the actor' do
+ it 'is not enabled if the FF is enabled for a child' do
+ expect(root_group.public_send(feature_flag_method)).to be_falsey
+ end
+ end
+ end
+
+ context 'when FF is disabled globally' do
+ before do
+ stub_feature_flags(feature_flag => false)
+ end
+
+ it { is_expected.to be_falsey }
+ end
+
+ context 'when FF is enabled globally' do
+ it { is_expected.to be_truthy }
+ end
+end
diff --git a/spec/support/shared_examples/models/issuable_hook_data_shared_examples.rb b/spec/support/shared_examples/models/issuable_hook_data_shared_examples.rb
deleted file mode 100644
index 13ffc1b7f87..00000000000
--- a/spec/support/shared_examples/models/issuable_hook_data_shared_examples.rb
+++ /dev/null
@@ -1,61 +0,0 @@
-# frozen_string_literal: true
-
-# This shared example requires a `builder` and `user` variable
-RSpec.shared_examples 'issuable hook data' do |kind|
- let(:data) { builder.build(user: user) }
-
- include_examples 'project hook data' do
- let(:project) { builder.issuable.project }
- end
-
- include_examples 'deprecated repository hook data'
-
- context "with a #{kind}" do
- it 'contains issuable data' do
- expect(data[:object_kind]).to eq(kind)
- expect(data[:user]).to eq(user.hook_attrs)
- expect(data[:project]).to eq(builder.issuable.project.hook_attrs)
- expect(data[:object_attributes]).to eq(builder.issuable.hook_attrs)
- expect(data[:changes]).to eq({})
- expect(data[:repository]).to eq(builder.issuable.project.hook_attrs.slice(:name, :url, :description, :homepage))
- end
-
- it 'does not contain certain keys' do
- expect(data).not_to have_key(:assignees)
- expect(data).not_to have_key(:assignee)
- end
-
- describe 'changes are given' do
- let(:changes) do
- {
- cached_markdown_version: %w[foo bar],
- description: ['A description', 'A cool description'],
- description_html: %w[foo bar],
- in_progress_merge_commit_sha: %w[foo bar],
- lock_version: %w[foo bar],
- merge_jid: %w[foo bar],
- title: ['A title', 'Hello World'],
- title_html: %w[foo bar]
- }
- end
-
- let(:data) { builder.build(user: user, changes: changes) }
-
- it 'populates the :changes hash' do
- expect(data[:changes]).to match(hash_including({
- title: { previous: 'A title', current: 'Hello World' },
- description: { previous: 'A description', current: 'A cool description' }
- }))
- end
-
- it 'does not contain certain keys' do
- expect(data[:changes]).not_to have_key('cached_markdown_version')
- expect(data[:changes]).not_to have_key('description_html')
- expect(data[:changes]).not_to have_key('lock_version')
- expect(data[:changes]).not_to have_key('title_html')
- expect(data[:changes]).not_to have_key('in_progress_merge_commit_sha')
- expect(data[:changes]).not_to have_key('merge_jid')
- end
- end
- end
-end
diff --git a/spec/support/shared_examples/models/issuable_link_shared_examples.rb b/spec/support/shared_examples/models/issuable_link_shared_examples.rb
index ca98c2597a2..9892e66b582 100644
--- a/spec/support/shared_examples/models/issuable_link_shared_examples.rb
+++ b/spec/support/shared_examples/models/issuable_link_shared_examples.rb
@@ -55,6 +55,19 @@ RSpec.shared_examples 'issuable link' do
end
end
+ describe 'scopes' do
+ describe '.for_source_or_target' do
+ it 'returns only links where id is either source or target id' do
+ link1 = create(issuable_link_factory, source: issuable_link.source)
+ link2 = create(issuable_link_factory, target: issuable_link.source)
+ # unrelated link, should not be included in result list
+ create(issuable_link_factory) # rubocop: disable Rails/SaveBang
+
+ expect(described_class.for_source_or_target(issuable_link.source_id)).to match_array([issuable_link, link1, link2])
+ end
+ end
+ end
+
describe '.link_type' do
it { is_expected.to define_enum_for(:link_type).with_values(relates_to: 0, blocks: 1) }
diff --git a/spec/support/shared_examples/models/member_shared_examples.rb b/spec/support/shared_examples/models/member_shared_examples.rb
index 17026f085bb..a329a6dca91 100644
--- a/spec/support/shared_examples/models/member_shared_examples.rb
+++ b/spec/support/shared_examples/models/member_shared_examples.rb
@@ -88,19 +88,55 @@ RSpec.shared_examples_for "member creation" do
expect(member).to be_persisted
end
- context 'when admin mode is enabled', :enable_admin_mode do
+ context 'when adding a project_bot' do
+ let_it_be(:project_bot) { create(:user, :project_bot) }
+
+ before_all do
+ source.add_owner(user)
+ end
+
+ context 'when project_bot is already a member' do
+ before do
+ source.add_developer(project_bot)
+ end
+
+ it 'does not update the member' do
+ member = described_class.new(source, project_bot, :maintainer, current_user: user).execute
+
+ expect(source.users.reload).to include(project_bot)
+ expect(member).to be_persisted
+ expect(member.access_level).to eq(Gitlab::Access::DEVELOPER)
+ expect(member.errors.full_messages).to include(/not authorized to update member/)
+ end
+ end
+
+ context 'when project_bot is not already a member' do
+ it 'adds the member' do
+ member = described_class.new(source, project_bot, :maintainer, current_user: user).execute
+
+ expect(source.users.reload).to include(project_bot)
+ expect(member).to be_persisted
+ end
+ end
+ end
+
+ context 'when admin mode is enabled', :enable_admin_mode, :aggregate_failures do
it 'sets members.created_by to the given admin current_user' do
member = described_class.new(source, user, :maintainer, current_user: admin).execute
+ expect(member).to be_persisted
+ expect(source.users.reload).to include(user)
expect(member.created_by).to eq(admin)
end
end
context 'when admin mode is disabled' do
- it 'rejects setting members.created_by to the given admin current_user' do
+ it 'rejects setting members.created_by to the given admin current_user', :aggregate_failures do
member = described_class.new(source, user, :maintainer, current_user: admin).execute
- expect(member.created_by).to be_nil
+ expect(member).not_to be_persisted
+ expect(source.users.reload).not_to include(user)
+ expect(member.errors.full_messages).to include(/not authorized to create member/)
end
end
@@ -142,7 +178,7 @@ RSpec.shared_examples_for "member creation" do
end
context 'when called with an unknown user id' do
- it 'adds the user as a member' do
+ it 'does not add the user as a member' do
expect(source.users).not_to include(user)
described_class.new(source, non_existing_record_id, :maintainer).execute
@@ -410,6 +446,22 @@ RSpec.shared_examples_for "bulk member creation" do
end
end
+ it 'with the same user sent more than once by user and by email' do
+ members = described_class.add_users(source, [user1, user1.email], :maintainer)
+
+ expect(members.map(&:user)).to contain_exactly(user1)
+ expect(members).to all(be_a(member_type))
+ expect(members).to all(be_persisted)
+ end
+
+ it 'with the same user sent more than once by user id and by email' do
+ members = described_class.add_users(source, [user1.id, user1.email], :maintainer)
+
+ expect(members.map(&:user)).to contain_exactly(user1)
+ expect(members).to all(be_a(member_type))
+ expect(members).to all(be_persisted)
+ end
+
context 'when a member already exists' do
before do
source.add_user(user1, :developer)
diff --git a/spec/support/shared_examples/models/project_shared_examples.rb b/spec/support/shared_examples/models/project_shared_examples.rb
new file mode 100644
index 00000000000..475ac1da04b
--- /dev/null
+++ b/spec/support/shared_examples/models/project_shared_examples.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'returns true if project is inactive' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:storage_size, :last_activity_at, :expected_result) do
+ 1.megabyte | 1.month.ago | false
+ 1.megabyte | 3.years.ago | false
+ 8.megabytes | 1.month.ago | false
+ 8.megabytes | 3.years.ago | true
+ end
+
+ with_them do
+ before do
+ stub_application_setting(inactive_projects_min_size_mb: 5)
+ stub_application_setting(inactive_projects_send_warning_email_after_months: 24)
+
+ project.statistics.storage_size = storage_size
+ project.last_activity_at = last_activity_at
+ project.save!
+ end
+
+ it 'returns expected result' do
+ expect(project.inactive?).to eq(expected_result)
+ end
+ end
+end
diff --git a/spec/support/shared_examples/models/wiki_shared_examples.rb b/spec/support/shared_examples/models/wiki_shared_examples.rb
index b3f79d9fe6e..03e9dd65e33 100644
--- a/spec/support/shared_examples/models/wiki_shared_examples.rb
+++ b/spec/support/shared_examples/models/wiki_shared_examples.rb
@@ -11,6 +11,10 @@ RSpec.shared_examples 'wiki model' do
subject { wiki }
+ it 'VALID_USER_MARKUPS contains all valid markups' do
+ expect(described_class::VALID_USER_MARKUPS.keys).to match_array(%i(markdown rdoc asciidoc org))
+ end
+
it 'container class includes HasWiki' do
# NOTE: This is not enforced at runtime, since we also need to support Geo::DeletedProject
expect(wiki_container).to be_kind_of(HasWiki)
@@ -427,45 +431,131 @@ RSpec.shared_examples 'wiki model' do
end
describe '#update_page' do
- let(:page) { create(:wiki_page, wiki: subject, title: 'update-page') }
+ shared_examples 'update_page tests' do
+ with_them do
+ let!(:page) { create(:wiki_page, wiki: subject, title: original_title, format: original_format, content: 'original content') }
+
+ let(:message) { 'updated page' }
+ let(:updated_content) { 'updated content' }
+
+ def update_page
+ subject.update_page(
+ page.page,
+ content: updated_content,
+ title: updated_title,
+ format: updated_format,
+ message: message
+ )
+ end
+
+ specify :aggregate_failures do
+ expect(subject).to receive(:after_wiki_activity)
+ expect(update_page).to eq true
+
+ page = subject.find_page(updated_title.presence || original_title)
- def update_page
- subject.update_page(
- page.page,
- content: 'some other content',
- format: :markdown,
- message: 'updated page'
- )
+ expect(page.raw_content).to eq(updated_content)
+ expect(page.path).to eq(expected_path)
+ expect(page.version.message).to eq(message)
+ expect(user.commit_email).not_to eq(user.email)
+ expect(commit.author_email).to eq(user.commit_email)
+ expect(commit.committer_email).to eq(user.commit_email)
+ end
+ end
end
- it 'updates the content of the page' do
- update_page
- page = subject.find_page('update-page')
+ shared_context 'common examples' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:original_title, :original_format, :updated_title, :updated_format, :expected_path) do
+ 'test page' | :markdown | 'new test page' | :markdown | 'new-test-page.md'
+ 'test page' | :markdown | 'test page' | :markdown | 'test-page.md'
+ 'test page' | :markdown | 'test page' | :asciidoc | 'test-page.asciidoc'
+
+ 'test page' | :markdown | 'new dir/new test page' | :markdown | 'new-dir/new-test-page.md'
+ 'test page' | :markdown | 'new dir/test page' | :markdown | 'new-dir/test-page.md'
- expect(page.raw_content).to eq('some other content')
+ 'test dir/test page' | :markdown | 'new dir/new test page' | :markdown | 'new-dir/new-test-page.md'
+ 'test dir/test page' | :markdown | 'test dir/test page' | :markdown | 'test-dir/test-page.md'
+ 'test dir/test page' | :markdown | 'test dir/test page' | :asciidoc | 'test-dir/test-page.asciidoc'
+
+ 'test dir/test page' | :markdown | 'new test page' | :markdown | 'new-test-page.md'
+ 'test dir/test page' | :markdown | 'test page' | :markdown | 'test-page.md'
+
+ 'test page' | :markdown | nil | :markdown | 'test-page.md'
+ 'test.page' | :markdown | nil | :markdown | 'test.page.md'
+ end
end
- it 'sets the correct commit message' do
- update_page
- page = subject.find_page('update-page')
+ # There are two bugs in Gollum. THe first one is when the title and the format are updated
+ # at the same time https://gitlab.com/gitlab-org/gitlab/-/issues/243519.
+ # The second one is when the wiki page is within a dir and the `title` argument
+ # we pass to the update method is `nil`. Gollum will remove the dir and move the page.
+ #
+ # We can include this context into the former once it is fixed
+ # or when Gollum is removed since the Gitaly approach already fixes it.
+ shared_context 'extended examples' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:original_title, :original_format, :updated_title, :updated_format, :expected_path) do
+ 'test page' | :markdown | 'new test page' | :asciidoc | 'new-test-page.asciidoc'
+ 'test page' | :markdown | 'new dir/new test page' | :asciidoc | 'new-dir/new-test-page.asciidoc'
+ 'test dir/test page' | :markdown | 'new dir/new test page' | :asciidoc | 'new-dir/new-test-page.asciidoc'
+ 'test dir/test page' | :markdown | 'new test page' | :asciidoc | 'new-test-page.asciidoc'
+ 'test page' | :markdown | nil | :asciidoc | 'test-page.asciidoc'
+ 'test dir/test page' | :markdown | nil | :asciidoc | 'test-dir/test-page.asciidoc'
+ 'test dir/test page' | :markdown | nil | :markdown | 'test-dir/test-page.md'
+ 'test page' | :markdown | '' | :markdown | 'test-page.md'
+ 'test.page' | :markdown | '' | :markdown | 'test.page.md'
+ end
+ end
- expect(page.version.message).to eq('updated page')
+ it_behaves_like 'update_page tests' do
+ include_context 'common examples'
+ include_context 'extended examples'
end
- it 'sets the correct commit email' do
- update_page
+ context 'when format is invalid' do
+ let!(:page) { create(:wiki_page, wiki: subject, title: 'test page') }
- expect(user.commit_email).not_to eq(user.email)
- expect(commit.author_email).to eq(user.commit_email)
- expect(commit.committer_email).to eq(user.commit_email)
+ it 'returns false and sets error message' do
+ expect(subject.update_page(page.page, content: 'new content', format: :foobar)).to eq false
+ expect(subject.error_message).to match(/Invalid format selected/)
+ end
end
- it 'runs after_wiki_activity callbacks' do
- page
+ context 'when format is not allowed' do
+ let!(:page) { create(:wiki_page, wiki: subject, title: 'test page') }
- expect(subject).to receive(:after_wiki_activity)
+ it 'returns false and sets error message' do
+ expect(subject.update_page(page.page, content: 'new content', format: :creole)).to eq false
+ expect(subject.error_message).to match(/Invalid format selected/)
+ end
+ end
+
+ context 'when page path does not have a default extension' do
+ let!(:page) { create(:wiki_page, wiki: subject, title: 'test page') }
+
+ context 'when format is not different' do
+ it 'does not change the default extension' do
+ path = 'test-page.markdown'
+ page.page.instance_variable_set(:@path, path)
- update_page
+ expect(subject.repository).to receive(:update_file).with(user, path, anything, anything)
+
+ subject.update_page(page.page, content: 'new content', format: :markdown)
+ end
+ end
+ end
+
+ context 'when feature flag :gitaly_replace_wiki_update_page is disabled' do
+ before do
+ stub_feature_flags(gitaly_replace_wiki_update_page: false)
+ end
+
+ it_behaves_like 'update_page tests' do
+ include_context 'common examples'
+ end
end
end
diff --git a/spec/support/shared_examples/policies/wiki_policies_shared_examples.rb b/spec/support/shared_examples/policies/wiki_policies_shared_examples.rb
index 58822f4309b..991d6289373 100644
--- a/spec/support/shared_examples/policies/wiki_policies_shared_examples.rb
+++ b/spec/support/shared_examples/policies/wiki_policies_shared_examples.rb
@@ -107,10 +107,4 @@ RSpec.shared_examples 'model with wiki policies' do
expect_disallowed(*disallowed_permissions)
end
end
-
- # TODO: Remove this helper once we implement group features
- # https://gitlab.com/gitlab-org/gitlab/-/issues/208412
- def set_access_level(access_level)
- raise NotImplementedError
- end
end
diff --git a/spec/support/shared_examples/requests/api/composer_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/composer_packages_shared_examples.rb
index 9f4fdcf7ba1..dc2c4f890b1 100644
--- a/spec/support/shared_examples/requests/api/composer_packages_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/composer_packages_shared_examples.rb
@@ -163,11 +163,11 @@ RSpec.shared_examples 'rejects Composer access with unknown project id' do
let(:project) { double(id: non_existing_record_id) }
context 'as anonymous' do
- it_behaves_like 'process Composer api request', :anonymous, :not_found
+ it_behaves_like 'process Composer api request', :anonymous, :unauthorized
end
context 'as authenticated user' do
- subject { get api(url), headers: basic_auth_header(user.username, personal_access_token.token) }
+ subject { get api(url), params: params, headers: basic_auth_header(user.username, personal_access_token.token) }
it_behaves_like 'process Composer api request', :anonymous, :not_found
end
diff --git a/spec/support/shared_examples/requests/api/container_repositories_shared_examples.rb b/spec/support/shared_examples/requests/api/container_repositories_shared_examples.rb
index e1e75be2494..c1eccafa987 100644
--- a/spec/support/shared_examples/requests/api/container_repositories_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/container_repositories_shared_examples.rb
@@ -116,3 +116,93 @@ RSpec.shared_examples 'not hitting graphql network errors with the container reg
expect_graphql_errors_to_be_empty
end
end
+
+RSpec.shared_examples 'reconciling migration_state' do
+ shared_examples 'enforcing states coherence to' do |expected_migration_state|
+ it 'leaves the repository in the expected migration_state' do
+ expect(repository.gitlab_api_client).not_to receive(:pre_import_repository)
+ expect(repository.gitlab_api_client).not_to receive(:import_repository)
+
+ subject
+
+ expect(repository.reload.migration_state).to eq(expected_migration_state)
+ end
+ end
+
+ shared_examples 'retrying the pre_import' do
+ it 'retries the pre_import' do
+ expect(repository).to receive(:migration_pre_import).and_return(:ok)
+
+ expect { subject }.to change { repository.reload.migration_state }.to('pre_importing')
+ end
+ end
+
+ shared_examples 'retrying the import' do
+ it 'retries the import' do
+ expect(repository).to receive(:migration_import).and_return(:ok)
+
+ expect { subject }.to change { repository.reload.migration_state }.to('importing')
+ end
+ end
+
+ context 'native response' do
+ let(:status) { 'native' }
+
+ it 'finishes the import' do
+ expect { subject }
+ .to change { repository.reload.migration_state }.to('import_done')
+ .and change { repository.reload.migration_skipped_reason }.to('native_import')
+ end
+ end
+
+ context 'import_in_progress response' do
+ let(:status) { 'import_in_progress' }
+
+ it_behaves_like 'enforcing states coherence to', 'importing'
+ end
+
+ context 'import_complete response' do
+ let(:status) { 'import_complete' }
+
+ it 'finishes the import' do
+ expect { subject }.to change { repository.reload.migration_state }.to('import_done')
+ end
+ end
+
+ context 'import_failed response' do
+ let(:status) { 'import_failed' }
+
+ it_behaves_like 'retrying the import'
+ end
+
+ context 'pre_import_in_progress response' do
+ let(:status) { 'pre_import_in_progress' }
+
+ it_behaves_like 'enforcing states coherence to', 'pre_importing'
+ end
+
+ context 'pre_import_complete response' do
+ let(:status) { 'pre_import_complete' }
+
+ it 'finishes the pre_import and starts the import' do
+ expect(repository).to receive(:finish_pre_import).and_call_original
+ expect(repository).to receive(:migration_import).and_return(:ok)
+
+ expect { subject }.to change { repository.reload.migration_state }.to('importing')
+ end
+ end
+
+ context 'pre_import_failed response' do
+ let(:status) { 'pre_import_failed' }
+
+ it_behaves_like 'retrying the pre_import'
+ end
+
+ %w[pre_import_canceled import_canceled].each do |canceled_status|
+ context "#{canceled_status} response" do
+ let(:status) { canceled_status }
+
+ it_behaves_like 'enforcing states coherence to', 'import_skipped'
+ end
+ end
+end
diff --git a/spec/support/shared_examples/requests/api/graphql/group_and_project_boards_query_shared_examples.rb b/spec/support/shared_examples/requests/api/graphql/group_and_project_boards_query_shared_examples.rb
index 01ed6c26576..da9d254039b 100644
--- a/spec/support/shared_examples/requests/api/graphql/group_and_project_boards_query_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/graphql/group_and_project_boards_query_shared_examples.rb
@@ -54,11 +54,13 @@ RSpec.shared_examples 'group and project boards query' do
end
context 'when using default sorting' do
+ # rubocop:disable RSpec/VariableName
let!(:board_B) { create(:board, resource_parent: board_parent, name: 'B') }
let!(:board_C) { create(:board, resource_parent: board_parent, name: 'C') }
let!(:board_a) { create(:board, resource_parent: board_parent, name: 'a') }
let!(:board_A) { create(:board, resource_parent: board_parent, name: 'A') }
let(:boards) { [board_a, board_A, board_B, board_C] }
+ # rubocop:enable RSpec/VariableName
context 'when ascending' do
it_behaves_like 'sorted paginated query' do
diff --git a/spec/support/shared_examples/requests/api/issuable_participants_examples.rb b/spec/support/shared_examples/requests/api/issuable_participants_examples.rb
index c5e5803c0a7..673d7741017 100644
--- a/spec/support/shared_examples/requests/api/issuable_participants_examples.rb
+++ b/spec/support/shared_examples/requests/api/issuable_participants_examples.rb
@@ -28,34 +28,4 @@ RSpec.shared_examples 'issuable participants endpoint' do
expect(response).to have_gitlab_http_status(:not_found)
end
-
- context 'with a confidential note' do
- let!(:note) do
- create(
- :note,
- :confidential,
- project: project,
- noteable: entity,
- author: create(:user)
- )
- end
-
- it 'returns a full list of participants' do
- get api("/projects/#{project.id}/#{area}/#{entity.iid}/participants", user)
-
- expect(response).to have_gitlab_http_status(:ok)
- participant_ids = json_response.map { |el| el['id'] }
- expect(participant_ids).to match_array([entity.author_id, note.author_id])
- end
-
- context 'when user cannot see a confidential note' do
- it 'returns a limited list of participants' do
- get api("/projects/#{project.id}/#{area}/#{entity.iid}/participants", create(:user))
-
- expect(response).to have_gitlab_http_status(:ok)
- participant_ids = json_response.map { |el| el['id'] }
- expect(participant_ids).to match_array([entity.author_id])
- end
- end
- end
end
diff --git a/spec/support/shared_examples/requests/api/notes_shared_examples.rb b/spec/support/shared_examples/requests/api/notes_shared_examples.rb
index 2a157f6e855..e7e30665b08 100644
--- a/spec/support/shared_examples/requests/api/notes_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/notes_shared_examples.rb
@@ -142,15 +142,6 @@ RSpec.shared_examples 'noteable API' do |parent_type, noteable_type, id_name|
expect(json_response['author']['username']).to eq(user.username)
end
- it "creates a confidential note if confidential is set to true" do
- post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user), params: { body: 'hi!', confidential: true }
-
- expect(response).to have_gitlab_http_status(:created)
- expect(json_response['body']).to eq('hi!')
- expect(json_response['confidential']).to be_truthy
- expect(json_response['author']['username']).to eq(user.username)
- end
-
it "returns a 400 bad request error if body not given" do
post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user)
@@ -306,52 +297,31 @@ RSpec.shared_examples 'noteable API' do |parent_type, noteable_type, id_name|
end
describe "PUT /#{parent_type}/:id/#{noteable_type}/:noteable_id/notes/:note_id" do
- let(:params) { { body: 'Hello!', confidential: false } }
+ let(:params) { { body: 'Hello!' } }
subject do
put api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes/#{note.id}", user), params: params
end
- context 'when eveything is ok' do
- before do
- note.update!(confidential: true)
- end
+ context 'when only body param is present' do
+ let(:params) { { body: 'Hello!' } }
- context 'with multiple params present' do
- before do
- subject
- end
-
- it 'returns modified note' do
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response['body']).to eq('Hello!')
- expect(json_response['confidential']).to be_falsey
- end
-
- it 'updates the note' do
- expect(note.reload.note).to eq('Hello!')
- expect(note.confidential).to be_falsey
- end
- end
-
- context 'when only body param is present' do
- let(:params) { { body: 'Hello!' } }
-
- it 'updates only the note text' do
- expect { subject }.not_to change { note.reload.confidential }
+ it 'updates the note text' do
+ subject
- expect(note.note).to eq('Hello!')
- end
+ expect(note.reload.note).to eq('Hello!')
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['body']).to eq('Hello!')
end
+ end
- context 'when only confidential param is present' do
- let(:params) { { confidential: false } }
+ context 'when confidential param is present' do
+ let(:params) { { confidential: true } }
- it 'updates only the note text' do
- expect { subject }.not_to change { note.reload.note }
+ it 'does not allow to change confidentiality' do
+ expect { subject }.not_to change { note.reload.note }
- expect(note.confidential).to be_falsey
- end
+ expect(response).to have_gitlab_http_status(:bad_request)
end
end
@@ -393,3 +363,24 @@ RSpec.shared_examples 'noteable API' do |parent_type, noteable_type, id_name|
end
end
end
+
+RSpec.shared_examples 'noteable API with confidential notes' do |parent_type, noteable_type, id_name|
+ it_behaves_like 'noteable API', parent_type, noteable_type, id_name
+
+ describe "POST /#{parent_type}/:id/#{noteable_type}/:noteable_id/notes" do
+ let(:params) { { body: 'hi!' } }
+
+ subject do
+ post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user), params: params
+ end
+
+ it "creates a confidential note if confidential is set to true" do
+ post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/notes", user), params: params.merge(confidential: true)
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['body']).to eq('hi!')
+ expect(json_response['confidential']).to be_truthy
+ expect(json_response['author']['username']).to eq(user.username)
+ end
+ end
+end
diff --git a/spec/support/shared_examples/serializers/environment_serializer_shared_examples.rb b/spec/support/shared_examples/serializers/environment_serializer_shared_examples.rb
index 87a33060435..fcd52cdf7fa 100644
--- a/spec/support/shared_examples/serializers/environment_serializer_shared_examples.rb
+++ b/spec/support/shared_examples/serializers/environment_serializer_shared_examples.rb
@@ -1,8 +1,5 @@
# frozen_string_literal: true
RSpec.shared_examples 'avoid N+1 on environments serialization' do |ee: false|
- # Investigating in https://gitlab.com/gitlab-org/gitlab/-/issues/353209
- let(:query_threshold) { 1 + (ee ? 4 : 0) }
-
it 'avoids N+1 database queries with grouping', :request_store do
create_environment_with_associations(project)
@@ -11,9 +8,11 @@ RSpec.shared_examples 'avoid N+1 on environments serialization' do |ee: false|
create_environment_with_associations(project)
create_environment_with_associations(project)
- expect { serialize(grouping: true) }
- .not_to exceed_query_limit(control.count)
- .with_threshold(query_threshold)
+ # Fix N+1 queries introduced by multi stop_actions for environment.
+ # Tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/358780
+ relax_count = 14
+
+ expect { serialize(grouping: true) }.not_to exceed_query_limit(control.count + relax_count)
end
it 'avoids N+1 database queries without grouping', :request_store do
@@ -24,9 +23,11 @@ RSpec.shared_examples 'avoid N+1 on environments serialization' do |ee: false|
create_environment_with_associations(project)
create_environment_with_associations(project)
- expect { serialize(grouping: false) }
- .not_to exceed_query_limit(control.count)
- .with_threshold(query_threshold)
+ # Fix N+1 queries introduced by multi stop_actions for environment.
+ # Tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/358780
+ relax_count = 14
+
+ expect { serialize(grouping: false) }.not_to exceed_query_limit(control.count + relax_count)
end
it 'does not preload for environments that does not exist in the page', :request_store do
diff --git a/spec/support/shared_examples/services/boards/boards_list_service_shared_examples.rb b/spec/support/shared_examples/services/boards/boards_list_service_shared_examples.rb
index 0e2bddc19ab..fd832d4484d 100644
--- a/spec/support/shared_examples/services/boards/boards_list_service_shared_examples.rb
+++ b/spec/support/shared_examples/services/boards/boards_list_service_shared_examples.rb
@@ -13,10 +13,12 @@ RSpec.shared_examples 'boards list service' do
end
RSpec.shared_examples 'multiple boards list service' do
+ # rubocop:disable RSpec/VariableName
let(:service) { described_class.new(parent, double) }
let!(:board_B) { create(:board, resource_parent: parent, name: 'B-board') }
let!(:board_c) { create(:board, resource_parent: parent, name: 'c-board') }
let!(:board_a) { create(:board, resource_parent: parent, name: 'a-board') }
+ # rubocop:enable RSpec/VariableName
describe '#execute' do
it 'returns all issue boards' do
diff --git a/spec/support/shared_examples/services/container_registry_auth_service_shared_examples.rb b/spec/support/shared_examples/services/container_registry_auth_service_shared_examples.rb
index a780952d51b..7677e5d8cb2 100644
--- a/spec/support/shared_examples/services/container_registry_auth_service_shared_examples.rb
+++ b/spec/support/shared_examples/services/container_registry_auth_service_shared_examples.rb
@@ -154,6 +154,30 @@ RSpec.shared_examples 'logs an auth warning' do |requested_actions|
end
end
+RSpec.shared_examples 'allowed to delete container repository images' do
+ let(:authentication_abilities) do
+ [:admin_container_image]
+ end
+
+ it_behaves_like 'a valid token'
+
+ context 'allow to delete images' do
+ let(:current_params) do
+ { scopes: ["repository:#{project.full_path}:*"] }
+ end
+
+ it_behaves_like 'a deletable'
+ end
+
+ context 'allow to delete images since registry 2.7' do
+ let(:current_params) do
+ { scopes: ["repository:#{project.full_path}:delete"] }
+ end
+
+ it_behaves_like 'a deletable since registry 2.7'
+ end
+end
+
RSpec.shared_examples 'a container registry auth service' do
include_context 'container registry auth service context'
@@ -204,6 +228,46 @@ RSpec.shared_examples 'a container registry auth service' do
it_behaves_like 'not a container repository factory'
end
+ describe '.pull_nested_repositories_access_token' do
+ let_it_be(:project) { create(:project) }
+
+ let(:token) { described_class.pull_nested_repositories_access_token(project.full_path) }
+ let(:access) do
+ [
+ {
+ 'type' => 'repository',
+ 'name' => project.full_path,
+ 'actions' => ['pull']
+ },
+ {
+ 'type' => 'repository',
+ 'name' => "#{project.full_path}/*",
+ 'actions' => ['pull']
+ }
+ ]
+ end
+
+ subject { { token: token } }
+
+ it 'has the correct scope' do
+ expect(payload).to include('access' => access)
+ end
+
+ it_behaves_like 'a valid token'
+ it_behaves_like 'not a container repository factory'
+
+ context 'with path ending with a slash' do
+ let(:token) { described_class.pull_nested_repositories_access_token("#{project.full_path}/") }
+
+ it 'has the correct scope' do
+ expect(payload).to include('access' => access)
+ end
+
+ it_behaves_like 'a valid token'
+ it_behaves_like 'not a container repository factory'
+ end
+ end
+
context 'user authorization' do
let_it_be(:current_user) { create(:user) }
@@ -504,38 +568,14 @@ RSpec.shared_examples 'a container registry auth service' do
end
context 'delete authorized as maintainer' do
- let_it_be(:current_project) { create(:project) }
+ let_it_be(:project) { create(:project) }
let_it_be(:current_user) { create(:user) }
- let(:authentication_abilities) do
- [:admin_container_image]
- end
-
before_all do
- current_project.add_maintainer(current_user)
- end
-
- it_behaves_like 'a valid token'
-
- context 'allow to delete images' do
- let(:current_params) do
- { scopes: ["repository:#{current_project.full_path}:*"] }
- end
-
- it_behaves_like 'a deletable' do
- let(:project) { current_project }
- end
+ project.add_maintainer(current_user)
end
- context 'allow to delete images since registry 2.7' do
- let(:current_params) do
- { scopes: ["repository:#{current_project.full_path}:delete"] }
- end
-
- it_behaves_like 'a deletable since registry 2.7' do
- let(:project) { current_project }
- end
- end
+ it_behaves_like 'allowed to delete container repository images'
end
context 'build authorized as user' do
diff --git a/spec/support/shared_examples/services/issuable_links/create_links_shared_examples.rb b/spec/support/shared_examples/services/issuable_links/create_links_shared_examples.rb
index 6146aae6b9b..9610cdd18a3 100644
--- a/spec/support/shared_examples/services/issuable_links/create_links_shared_examples.rb
+++ b/spec/support/shared_examples/services/issuable_links/create_links_shared_examples.rb
@@ -70,8 +70,10 @@ shared_examples 'issuable link creation' do
expect(issuable_link_class.find_by!(target: issuable3)).to have_attributes(source: issuable, link_type: 'relates_to')
end
- it 'returns success status' do
- is_expected.to eq(status: :success)
+ it 'returns success status and created links', :aggregate_failures do
+ expect(subject.keys).to match_array([:status, :created_references])
+ expect(subject[:status]).to eq(:success)
+ expect(subject[:created_references].map(&:target_id)).to match_array([issuable2.id, issuable3.id])
end
it 'creates notes' do
diff --git a/spec/support/shared_examples/views/milestone_shared_examples.rb b/spec/support/shared_examples/views/milestone_shared_examples.rb
new file mode 100644
index 00000000000..b6f4d0db0e9
--- /dev/null
+++ b/spec/support/shared_examples/views/milestone_shared_examples.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'milestone empty states' do
+ include Devise::Test::ControllerHelpers
+
+ let_it_be(:user) { build(:user) }
+ let(:empty_state) { 'Use milestones to track issues and merge requests over a fixed period of time' }
+
+ before do
+ assign(:projects, [])
+ allow(view).to receive(:current_user).and_return(user)
+ end
+
+ context 'with no milestones' do
+ before do
+ assign(:milestones, [])
+ assign(:milestone_states, { opened: 0, closed: 0, all: 0 })
+ render
+ end
+
+ it 'shows empty state' do
+ expect(rendered).to have_content(empty_state)
+ end
+
+ it 'does not show tabs or searchbar' do
+ expect(rendered).not_to have_link('Open')
+ expect(rendered).not_to have_link('Closed')
+ expect(rendered).not_to have_link('All')
+ end
+ end
+
+ context 'with no open milestones' do
+ before do
+ allow(view).to receive(:milestone_path).and_return("/milestones/1")
+ assign(:milestones, [])
+ assign(:milestone_states, { opened: 0, closed: 1, all: 1 })
+ end
+
+ it 'shows tabs and searchbar', :aggregate_failures do
+ render
+
+ expect(rendered).not_to have_content(empty_state)
+ expect(rendered).to have_link('Open')
+ expect(rendered).to have_link('Closed')
+ expect(rendered).to have_link('All')
+ end
+
+ it 'shows empty state' do
+ render
+
+ expect(rendered).to have_content('There are no open milestones')
+ end
+ end
+
+ context 'with no closed milestones' do
+ before do
+ allow(view).to receive(:milestone_path).and_return("/milestones/1")
+ allow(view).to receive(:params).and_return(state: 'closed')
+ assign(:milestones, [])
+ assign(:milestone_states, { opened: 1, closed: 0, all: 1 })
+ end
+
+ it 'shows tabs and searchbar', :aggregate_failures do
+ render
+
+ expect(rendered).not_to have_content(empty_state)
+ expect(rendered).to have_link('Open')
+ expect(rendered).to have_link('Closed')
+ expect(rendered).to have_link('All')
+ end
+
+ it 'shows empty state on closed milestones' do
+ render
+
+ expect(rendered).to have_content('There are no closed milestones')
+ end
+ end
+end
diff --git a/spec/support/shared_examples/workers/batched_background_migration_worker_shared_examples.rb b/spec/support/shared_examples/workers/batched_background_migration_worker_shared_examples.rb
index d202c4e00f0..26731f34ed6 100644
--- a/spec/support/shared_examples/workers/batched_background_migration_worker_shared_examples.rb
+++ b/spec/support/shared_examples/workers/batched_background_migration_worker_shared_examples.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-RSpec.shared_examples 'it runs batched background migration jobs' do |tracking_database|
+RSpec.shared_examples 'it runs batched background migration jobs' do |tracking_database, feature_flag:|
include ExclusiveLeaseHelpers
describe 'defining the job attributes' do
@@ -39,6 +39,16 @@ RSpec.shared_examples 'it runs batched background migration jobs' do |tracking_d
end
end
+ describe '.enabled?' do
+ it 'does not raise an error' do
+ expect { described_class.enabled? }.not_to raise_error
+ end
+
+ it 'returns true' do
+ expect(described_class.enabled?).to be_truthy
+ end
+ end
+
describe '#perform' do
subject(:worker) { described_class.new }
@@ -76,7 +86,7 @@ RSpec.shared_examples 'it runs batched background migration jobs' do |tracking_d
context 'when the feature flag is disabled' do
before do
- stub_feature_flags(execute_batched_migrations_on_schedule: false)
+ stub_feature_flags(feature_flag => false)
end
it 'does nothing' do
@@ -89,7 +99,7 @@ RSpec.shared_examples 'it runs batched background migration jobs' do |tracking_d
context 'when the feature flag is enabled' do
before do
- stub_feature_flags(execute_batched_migrations_on_schedule: true)
+ stub_feature_flags(feature_flag => true)
allow(Gitlab::Database::BackgroundMigration::BatchedMigration).to receive(:active_migration).and_return(nil)
end
diff --git a/spec/support/shared_examples/workers/concerns/git_garbage_collect_methods_shared_examples.rb b/spec/support/shared_examples/workers/concerns/git_garbage_collect_methods_shared_examples.rb
index 202606c6aa6..4751d91efde 100644
--- a/spec/support/shared_examples/workers/concerns/git_garbage_collect_methods_shared_examples.rb
+++ b/spec/support/shared_examples/workers/concerns/git_garbage_collect_methods_shared_examples.rb
@@ -230,76 +230,6 @@ RSpec.shared_examples 'can collect git garbage' do |update_statistics: true|
stub_feature_flags(optimized_housekeeping: false)
end
- it 'incremental repack adds a new packfile' do
- create_objects(resource)
- before_packs = packs(resource)
-
- expect(before_packs.count).to be >= 1
-
- subject.perform(resource.id, 'incremental_repack', lease_key, lease_uuid)
- after_packs = packs(resource)
-
- # Exactly one new pack should have been created
- expect(after_packs.count).to eq(before_packs.count + 1)
-
- # Previously existing packs are still around
- expect(before_packs & after_packs).to eq(before_packs)
- end
-
- it 'full repack consolidates into 1 packfile' do
- create_objects(resource)
- subject.perform(resource.id, 'incremental_repack', lease_key, lease_uuid)
- before_packs = packs(resource)
-
- expect(before_packs.count).to be >= 2
-
- subject.perform(resource.id, 'full_repack', lease_key, lease_uuid)
- after_packs = packs(resource)
-
- expect(after_packs.count).to eq(1)
-
- # Previously existing packs should be gone now
- expect(after_packs - before_packs).to eq(after_packs)
-
- expect(File.exist?(bitmap_path(after_packs.first))).to eq(bitmaps_enabled)
- end
-
- it 'gc consolidates into 1 packfile and updates packed-refs' do
- create_objects(resource)
- before_packs = packs(resource)
- before_packed_refs = packed_refs(resource)
-
- expect(before_packs.count).to be >= 1
-
- # It's quite difficult to use `expect_next_instance_of` in this place
- # because the RepositoryService is instantiated several times to do
- # some repository calls like `exists?`, `create_repository`, ... .
- # Therefore, since we're instantiating the object several times,
- # RSpec has troubles figuring out which instance is the next and which
- # one we want to mock.
- # Besides, at this point, we actually want to perform the call to Gitaly,
- # otherwise we would just use `instance_double` like in other parts of the
- # spec file.
- expect_any_instance_of(Gitlab::GitalyClient::RepositoryService) # rubocop:disable RSpec/AnyInstanceOf
- .to receive(:garbage_collect)
- .with(bitmaps_enabled, prune: false)
- .and_call_original
-
- subject.perform(resource.id, 'gc', lease_key, lease_uuid)
- after_packed_refs = packed_refs(resource)
- after_packs = packs(resource)
-
- expect(after_packs.count).to eq(1)
-
- # Previously existing packs should be gone now
- expect(after_packs - before_packs).to eq(after_packs)
-
- # The packed-refs file should have been updated during 'git gc'
- expect(before_packed_refs).not_to eq(after_packed_refs)
-
- expect(File.exist?(bitmap_path(after_packs.first))).to eq(bitmaps_enabled)
- end
-
it 'cleans up repository after finishing' do
expect(resource).to receive(:cleanup).and_call_original
diff --git a/spec/tasks/dev_rake_spec.rb b/spec/tasks/dev_rake_spec.rb
index 7bc27d2732c..73b1604aa10 100644
--- a/spec/tasks/dev_rake_spec.rb
+++ b/spec/tasks/dev_rake_spec.rb
@@ -7,9 +7,20 @@ RSpec.describe 'dev rake tasks' do
Rake.application.rake_require 'tasks/gitlab/setup'
Rake.application.rake_require 'tasks/gitlab/shell'
Rake.application.rake_require 'tasks/dev'
+ Rake.application.rake_require 'active_record/railties/databases'
+ Rake.application.rake_require 'tasks/gitlab/db'
end
describe 'setup' do
+ around do |example|
+ old_force_value = ENV['force']
+
+ # setup rake task sets the force env var, so reset it
+ example.run
+
+ ENV['force'] = old_force_value # rubocop:disable RSpec/EnvAssignment
+ end
+
subject(:setup_task) { run_rake_task('dev:setup') }
let(:connections) { Gitlab::Database.database_base_models.values.map(&:connection) }
@@ -17,7 +28,9 @@ RSpec.describe 'dev rake tasks' do
it 'sets up the development environment', :aggregate_failures do
expect(Rake::Task['gitlab:setup']).to receive(:invoke)
+ expect(connections).to all(receive(:execute).with('SET statement_timeout TO 0'))
expect(connections).to all(receive(:execute).with('ANALYZE'))
+ expect(connections).to all(receive(:execute).with('RESET statement_timeout'))
expect(Rake::Task['gitlab:shell:setup']).to receive(:invoke)
@@ -35,4 +48,103 @@ RSpec.describe 'dev rake tasks' do
load_task
end
end
+
+ describe 'terminate_all_connections' do
+ let(:connections) do
+ Gitlab::Database.database_base_models.values.filter_map do |model|
+ model.connection if Gitlab::Database.db_config_share_with(model.connection_db_config).nil?
+ end
+ end
+
+ def expect_connections_to_be_terminated
+ expect(Gitlab::Database::EachDatabase).to receive(:each_database_connection)
+ .with(include_shared: false)
+ .and_call_original
+
+ expect(connections).to all(receive(:execute).with(/SELECT pg_terminate_backend/))
+ end
+
+ def expect_connections_not_to_be_terminated
+ connections.each do |connection|
+ expect(connection).not_to receive(:execute)
+ end
+ end
+
+ subject(:terminate_task) { run_rake_task('dev:terminate_all_connections') }
+
+ it 'terminates all connections' do
+ expect_connections_to_be_terminated
+
+ terminate_task
+ end
+
+ context 'when in the production environment' do
+ it 'does not terminate connections' do
+ expect(Rails.env).to receive(:production?).and_return(true)
+ expect_connections_not_to_be_terminated
+
+ terminate_task
+ end
+ end
+
+ context 'when a database is not found' do
+ before do
+ skip_if_multiple_databases_not_setup
+ end
+
+ it 'continues to next connection' do
+ expect(connections.first).to receive(:execute).and_raise(ActiveRecord::NoDatabaseError)
+ expect(connections.second).to receive(:execute).with(/SELECT pg_terminate_backend/)
+
+ terminate_task
+ end
+ end
+ end
+
+ context 'multiple databases' do
+ before do
+ skip_if_multiple_databases_not_setup
+ end
+
+ context 'with a valid database' do
+ describe 'copy_db:ci' do
+ before do
+ allow(Rake::Task['dev:terminate_all_connections']).to receive(:invoke)
+
+ configurations = instance_double(ActiveRecord::DatabaseConfigurations)
+ allow(ActiveRecord::Base).to receive(:configurations).and_return(configurations)
+ allow(configurations).to receive(:configs_for).with(env_name: Rails.env, name: 'ci').and_return(ci_configuration)
+ end
+
+ subject(:load_task) { run_rake_task('dev:setup_ci_db') }
+
+ let(:ci_configuration) { instance_double(ActiveRecord::DatabaseConfigurations::HashConfig, name: 'ci', database: '__test_db_ci') }
+
+ it 'creates the database from main' do
+ expect(ApplicationRecord.connection).to receive(:create_database).with(
+ ci_configuration.database,
+ template: ApplicationRecord.connection_db_config.database
+ )
+
+ expect(Rake::Task['dev:terminate_all_connections']).to receive(:invoke)
+
+ run_rake_task('dev:copy_db:ci')
+ end
+
+ context 'when the database already exists' do
+ it 'prints out a warning' do
+ expect(ApplicationRecord.connection).to receive(:create_database).and_raise(ActiveRecord::DatabaseAlreadyExists)
+
+ expect { run_rake_task('dev:copy_db:ci') }.to output(/Database '#{ci_configuration.database}' already exists/).to_stderr
+ end
+ end
+ end
+ end
+
+ context 'with an invalid database' do
+ it 'raises an error' do
+ expect { run_rake_task('dev:copy_db:foo') }.to raise_error(RuntimeError, /Don't know how to build task/)
+ end
+ end
+ end
end
diff --git a/spec/tasks/gitlab/backup_rake_spec.rb b/spec/tasks/gitlab/backup_rake_spec.rb
index df9f2a0d3bb..6080948403d 100644
--- a/spec/tasks/gitlab/backup_rake_spec.rb
+++ b/spec/tasks/gitlab/backup_rake_spec.rb
@@ -199,18 +199,25 @@ RSpec.describe 'gitlab:app namespace rake task', :delete do
end
it 'logs the progress to log file' do
- expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping database ... ")
- expect(Gitlab::BackupLogger).to receive(:info).with(message: "[SKIPPED]")
+ expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping database ... [SKIPPED]")
expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping repositories ... ")
+ expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping repositories ... done")
expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping uploads ... ")
+ expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping uploads ... done")
expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping builds ... ")
+ expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping builds ... done")
expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping artifacts ... ")
+ expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping artifacts ... done")
expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping pages ... ")
+ expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping pages ... done")
expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping lfs objects ... ")
+ expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping lfs objects ... done")
expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping terraform states ... ")
+ expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping terraform states ... done")
expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping container registry images ... ")
+ expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping container registry images ... done")
expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping packages ... ")
- expect(Gitlab::BackupLogger).to receive(:info).with(message: "done").exactly(9).times
+ expect(Gitlab::BackupLogger).to receive(:info).with(message: "Dumping packages ... done")
backup_tasks.each do |task|
run_rake_task("gitlab:backup:#{task}:create")
@@ -228,19 +235,19 @@ RSpec.describe 'gitlab:app namespace rake task', :delete do
db_backup_error = Backup::DatabaseBackupError.new(config, db_file_name)
where(:backup_class, :rake_task, :error) do
- Backup::Database | 'gitlab:backup:db:create' | db_backup_error
- Backup::Builds | 'gitlab:backup:builds:create' | file_backup_error
- Backup::Uploads | 'gitlab:backup:uploads:create' | file_backup_error
- Backup::Artifacts | 'gitlab:backup:artifacts:create' | file_backup_error
- Backup::Pages | 'gitlab:backup:pages:create' | file_backup_error
- Backup::Lfs | 'gitlab:backup:lfs:create' | file_backup_error
- Backup::Registry | 'gitlab:backup:registry:create' | file_backup_error
+ Backup::Database | 'gitlab:backup:db:create' | db_backup_error
+ Backup::Files | 'gitlab:backup:builds:create' | file_backup_error
+ Backup::Files | 'gitlab:backup:uploads:create' | file_backup_error
+ Backup::Files | 'gitlab:backup:artifacts:create' | file_backup_error
+ Backup::Files | 'gitlab:backup:pages:create' | file_backup_error
+ Backup::Files | 'gitlab:backup:lfs:create' | file_backup_error
+ Backup::Files | 'gitlab:backup:registry:create' | file_backup_error
end
with_them do
before do
- expect_next_instance_of(backup_class) do |instance|
- expect(instance).to receive(:dump).and_raise(error)
+ allow_next_instance_of(backup_class) do |instance|
+ allow(instance).to receive(:dump).and_raise(error)
end
end
@@ -408,25 +415,12 @@ RSpec.describe 'gitlab:app namespace rake task', :delete do
create(:project, :repository)
end
- it 'has defaults' do
- expect(::Backup::Repositories).to receive(:new)
- .with(anything, strategy: anything, max_concurrency: 1, max_storage_concurrency: 1)
- .and_call_original
-
- expect { run_rake_task('gitlab:backup:create') }.to output.to_stdout_from_any_process
- end
-
it 'passes through concurrency environment variables' do
- # The way concurrency is handled will change with the `gitaly_backup`
- # feature flag. For now we need to check that both ways continue to
- # work. This will be cleaned up in the rollout issue.
- # See https://gitlab.com/gitlab-org/gitlab/-/issues/333034
-
stub_env('GITLAB_BACKUP_MAX_CONCURRENCY', 5)
stub_env('GITLAB_BACKUP_MAX_STORAGE_CONCURRENCY', 2)
expect(::Backup::Repositories).to receive(:new)
- .with(anything, strategy: anything, max_concurrency: 5, max_storage_concurrency: 2)
+ .with(anything, strategy: anything)
.and_call_original
expect(::Backup::GitalyBackup).to receive(:new).with(anything, max_parallelism: 5, storage_parallelism: 2, incremental: false).and_call_original
diff --git a/spec/tasks/gitlab/db/validate_config_rake_spec.rb b/spec/tasks/gitlab/db/validate_config_rake_spec.rb
new file mode 100644
index 00000000000..0b2c844a91f
--- /dev/null
+++ b/spec/tasks/gitlab/db/validate_config_rake_spec.rb
@@ -0,0 +1,205 @@
+# frozen_string_literal: true
+
+require 'rake_helper'
+
+RSpec.describe 'gitlab:db:validate_config', :silence_stdout do
+ before :all do
+ Rake.application.rake_require 'active_record/railties/databases'
+ Rake.application.rake_require 'tasks/seed_fu'
+ Rake.application.rake_require 'tasks/gitlab/db/validate_config'
+
+ # empty task as env is already loaded
+ Rake::Task.define_task :environment
+ end
+
+ context "when validating config" do
+ let(:main_database_config) do
+ Rails.application.config.load_database_yaml
+ .dig('test', 'main')
+ .slice('adapter', 'encoding', 'database', 'username', 'password', 'host')
+ .symbolize_keys
+ end
+
+ let(:additional_database_config) do
+ # Use built-in postgres database
+ main_database_config.merge(database: 'postgres')
+ end
+
+ around do |example|
+ with_reestablished_active_record_base(reconnect: true) do
+ with_db_configs(test: test_config) do
+ example.run
+ end
+ end
+ end
+
+ shared_examples 'validates successfully' do
+ it 'by default' do
+ expect { run_rake_task('gitlab:db:validate_config') }.not_to output(/Database config validation failure/).to_stderr
+ expect { run_rake_task('gitlab:db:validate_config') }.not_to raise_error
+ end
+
+ it 'for production' do
+ allow(Gitlab).to receive(:dev_or_test_env?).and_return(false)
+
+ expect { run_rake_task('gitlab:db:validate_config') }.not_to output(/Database config validation failure/).to_stderr
+ expect { run_rake_task('gitlab:db:validate_config') }.not_to raise_error
+ end
+
+ it 'always re-establishes ActiveRecord::Base connection to main config' do
+ run_rake_task('gitlab:db:validate_config')
+
+ expect(ActiveRecord::Base.connection_db_config.configuration_hash).to include(main_database_config) # rubocop: disable Database/MultipleDatabases
+ end
+
+ it 'if GITLAB_VALIDATE_DATABASE_CONFIG is set' do
+ stub_env('GITLAB_VALIDATE_DATABASE_CONFIG', '1')
+ allow(Gitlab).to receive(:dev_or_test_env?).and_return(false)
+
+ expect { run_rake_task('gitlab:db:validate_config') }.not_to output(/Database config validation failure/).to_stderr
+ expect { run_rake_task('gitlab:db:validate_config') }.not_to raise_error
+ end
+
+ context 'when finding the initializer fails' do
+ where(:raised_error) { [ActiveRecord::NoDatabaseError, ActiveRecord::ConnectionNotEstablished, PG::ConnectionBad] }
+ with_them do
+ it "does not raise an error for #{params[:raised_error]}" do
+ allow(ActiveRecord::Base.connection).to receive(:select_one).and_raise(raised_error) # rubocop: disable Database/MultipleDatabases
+
+ expect { run_rake_task('gitlab:db:validate_config') }.not_to output(/Database config validation failure/).to_stderr
+ expect { run_rake_task('gitlab:db:validate_config') }.not_to raise_error
+ end
+ end
+ end
+ end
+
+ shared_examples 'raises an error' do |match|
+ it 'by default' do
+ expect { run_rake_task('gitlab:db:validate_config') }.to raise_error(match)
+ end
+
+ it 'for production' do
+ allow(Gitlab).to receive(:dev_or_test_env?).and_return(false)
+
+ expect { run_rake_task('gitlab:db:validate_config') }.to raise_error(match)
+ end
+
+ it 'always re-establishes ActiveRecord::Base connection to main config' do
+ expect { run_rake_task('gitlab:db:validate_config') }.to raise_error(match)
+
+ expect(ActiveRecord::Base.connection_db_config.configuration_hash).to include(main_database_config) # rubocop: disable Database/MultipleDatabases
+ end
+
+ it 'if GITLAB_VALIDATE_DATABASE_CONFIG=1' do
+ stub_env('GITLAB_VALIDATE_DATABASE_CONFIG', '1')
+
+ expect { run_rake_task('gitlab:db:validate_config') }.to raise_error(match)
+ end
+
+ it 'to stderr if GITLAB_VALIDATE_DATABASE_CONFIG=0' do
+ stub_env('GITLAB_VALIDATE_DATABASE_CONFIG', '0')
+
+ expect { run_rake_task('gitlab:db:validate_config') }.to output(match).to_stderr
+ end
+ end
+
+ context 'when only main: is specified' do
+ let(:test_config) do
+ {
+ main: main_database_config
+ }
+ end
+
+ it_behaves_like 'validates successfully'
+ end
+
+ context 'when main: uses database_tasks=false' do
+ let(:test_config) do
+ {
+ main: main_database_config.merge(database_tasks: false)
+ }
+ end
+
+ it_behaves_like 'raises an error', /The 'main' is required to use 'database_tasks: true'/
+ end
+
+ context 'when many configurations share the same database' do
+ context 'when no database_tasks is specified, assumes true' do
+ let(:test_config) do
+ {
+ main: main_database_config,
+ ci: main_database_config
+ }
+ end
+
+ it_behaves_like 'raises an error', /Many configurations \(main, ci\) share the same database/
+ end
+
+ context 'when database_tasks is specified' do
+ let(:test_config) do
+ {
+ main: main_database_config.merge(database_tasks: true),
+ ci: main_database_config.merge(database_tasks: true)
+ }
+ end
+
+ it_behaves_like 'raises an error', /Many configurations \(main, ci\) share the same database/
+ end
+
+ context "when there's no main: but something different, as currently we only can share with main:" do
+ let(:test_config) do
+ {
+ archive: main_database_config,
+ ci: main_database_config.merge(database_tasks: false)
+ }
+ end
+
+ it_behaves_like 'raises an error', /The 'ci' is expecting to share configuration with 'main', but no such is to be found/
+ end
+ end
+
+ context 'when ci: uses different database' do
+ context 'and does not specify database_tasks which indicates using dedicated database' do
+ let(:test_config) do
+ {
+ main: main_database_config,
+ ci: additional_database_config
+ }
+ end
+
+ it_behaves_like 'validates successfully'
+ end
+
+ context 'and does specify database_tasks=false which indicates sharing with main:' do
+ let(:test_config) do
+ {
+ main: main_database_config,
+ ci: additional_database_config.merge(database_tasks: false)
+ }
+ end
+
+ it_behaves_like 'raises an error', /The 'ci' since it is using 'database_tasks: false' should share database with 'main:'/
+ end
+ end
+ end
+
+ %w[db:migrate db:schema:load db:schema:dump].each do |task|
+ context "when running #{task}" do
+ it "does run gitlab:db:validate_config before" do
+ expect(Rake::Task['gitlab:db:validate_config']).to receive(:execute).and_return(true)
+ expect(Rake::Task[task]).to receive(:execute).and_return(true)
+
+ Rake::Task['gitlab:db:validate_config'].reenable
+ run_rake_task(task)
+ end
+ end
+ end
+
+ def with_db_configs(test: test_config)
+ current_configurations = ActiveRecord::Base.configurations # rubocop:disable Database/MultipleDatabases
+ ActiveRecord::Base.configurations = { test: test_config }
+ yield
+ ensure
+ ActiveRecord::Base.configurations = current_configurations
+ end
+end
diff --git a/spec/tasks/gitlab/db_rake_spec.rb b/spec/tasks/gitlab/db_rake_spec.rb
index 8d3ec7b1ee2..73f3b55e12e 100644
--- a/spec/tasks/gitlab/db_rake_spec.rb
+++ b/spec/tasks/gitlab/db_rake_spec.rb
@@ -20,14 +20,6 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout do
allow(Rake::Task['db:seed_fu']).to receive(:invoke).and_return(true)
end
- describe 'clear_all_connections' do
- it 'calls clear_all_connections!' do
- expect(ActiveRecord::Base).to receive(:clear_all_connections!)
-
- run_rake_task('gitlab:db:clear_all_connections')
- end
- end
-
describe 'mark_migration_complete' do
context 'with a single database' do
let(:main_model) { ActiveRecord::Base }
@@ -51,7 +43,7 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout do
let(:base_models) { { 'main' => main_model, 'ci' => ci_model } }
before do
- skip_if_multiple_databases_not_setup
+ skip_unless_ci_uses_database_tasks
allow(Gitlab::Database).to receive(:database_base_models).and_return(base_models)
end
@@ -80,6 +72,17 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout do
run_rake_task('gitlab:db:mark_migration_complete:main', '[123]')
end
end
+
+ context 'with geo configured' do
+ before do
+ skip_unless_geo_configured
+ end
+
+ it 'does not create a task for the geo database' do
+ expect { run_rake_task('gitlab:db:mark_migration_complete:geo') }
+ .to raise_error(/Don't know how to build task 'gitlab:db:mark_migration_complete:geo'/)
+ end
+ end
end
context 'when the migration is already marked complete' do
@@ -122,79 +125,228 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout do
end
describe 'configure' do
- it 'invokes db:migrate when schema has already been loaded' do
- allow(ActiveRecord::Base.connection).to receive(:tables).and_return(%w[table1 table2])
- expect(Rake::Task['db:migrate']).to receive(:invoke)
- expect(Rake::Task['db:structure:load']).not_to receive(:invoke)
- expect(Rake::Task['db:seed_fu']).not_to receive(:invoke)
- expect { run_rake_task('gitlab:db:configure') }.not_to raise_error
- end
+ context 'with a single database' do
+ let(:connection) { Gitlab::Database.database_base_models[:main].connection }
+ let(:main_config) { double(:config, name: 'main') }
- it 'invokes db:shema:load and db:seed_fu when schema is not loaded' do
- allow(ActiveRecord::Base.connection).to receive(:tables).and_return([])
- expect(Rake::Task['db:structure:load']).to receive(:invoke)
- expect(Rake::Task['db:seed_fu']).to receive(:invoke)
- expect(Rake::Task['db:migrate']).not_to receive(:invoke)
- expect { run_rake_task('gitlab:db:configure') }.not_to raise_error
- end
+ before do
+ skip_if_multiple_databases_are_setup
+ end
- it 'invokes db:shema:load and db:seed_fu when there is only a single table present' do
- allow(ActiveRecord::Base.connection).to receive(:tables).and_return(['default'])
- expect(Rake::Task['db:structure:load']).to receive(:invoke)
- expect(Rake::Task['db:seed_fu']).to receive(:invoke)
- expect(Rake::Task['db:migrate']).not_to receive(:invoke)
- expect { run_rake_task('gitlab:db:configure') }.not_to raise_error
- end
+ context 'when geo is not configured' do
+ before do
+ allow(ActiveRecord::Base).to receive_message_chain('configurations.configs_for').and_return([main_config])
+ end
- it 'does not invoke any other rake tasks during an error' do
- allow(ActiveRecord::Base).to receive(:connection).and_raise(RuntimeError, 'error')
- expect(Rake::Task['db:migrate']).not_to receive(:invoke)
- expect(Rake::Task['db:structure:load']).not_to receive(:invoke)
- expect(Rake::Task['db:seed_fu']).not_to receive(:invoke)
- expect { run_rake_task('gitlab:db:configure') }.to raise_error(RuntimeError, 'error')
- # unstub connection so that the database cleaner still works
- allow(ActiveRecord::Base).to receive(:connection).and_call_original
- end
+ context 'when the schema is already loaded' do
+ it 'migrates the database' do
+ allow(connection).to receive(:tables).and_return(%w[table1 table2])
+
+ expect(Rake::Task['db:migrate']).to receive(:invoke)
+ expect(Rake::Task['db:schema:load']).not_to receive(:invoke)
+ expect(Rake::Task['db:seed_fu']).not_to receive(:invoke)
+
+ run_rake_task('gitlab:db:configure')
+ end
+ end
+
+ context 'when the schema is not loaded' do
+ it 'loads the schema and seeds the database' do
+ allow(connection).to receive(:tables).and_return([])
+
+ expect(Rake::Task['db:schema:load']).to receive(:invoke)
+ expect(Rake::Task['db:seed_fu']).to receive(:invoke)
+ expect(Rake::Task['db:migrate']).not_to receive(:invoke)
+
+ run_rake_task('gitlab:db:configure')
+ end
+ end
+
+ context 'when only a single table is present' do
+ it 'loads the schema and seeds the database' do
+ allow(connection).to receive(:tables).and_return(['default'])
+
+ expect(Rake::Task['db:schema:load']).to receive(:invoke)
+ expect(Rake::Task['db:seed_fu']).to receive(:invoke)
+ expect(Rake::Task['db:migrate']).not_to receive(:invoke)
+
+ run_rake_task('gitlab:db:configure')
+ end
+ end
+
+ context 'when loading the schema fails' do
+ it 'does not seed the database' do
+ allow(connection).to receive(:tables).and_return([])
+
+ expect(Rake::Task['db:schema:load']).to receive(:invoke).and_raise('error')
+ expect(Rake::Task['db:seed_fu']).not_to receive(:invoke)
+ expect(Rake::Task['db:migrate']).not_to receive(:invoke)
+
+ expect { run_rake_task('gitlab:db:configure') }.to raise_error(RuntimeError, 'error')
+ end
+ end
+
+ context 'SKIP_POST_DEPLOYMENT_MIGRATIONS environment variable set' do
+ let(:rails_paths) { { 'db' => ['db'], 'db/migrate' => ['db/migrate'] } }
+
+ before do
+ stub_env('SKIP_POST_DEPLOYMENT_MIGRATIONS', true)
+
+ # Our environment has already been loaded, so we need to pretend like post_migrations were not
+ allow(Rails.application.config).to receive(:paths).and_return(rails_paths)
+ allow(ActiveRecord::Migrator).to receive(:migrations_paths).and_return(rails_paths['db/migrate'].dup)
+ end
+
+ context 'when the schema is not loaded' do
+ it 'adds the post deployment migration path before schema load' do
+ allow(connection).to receive(:tables).and_return([])
+
+ expect(Gitlab::Database).to receive(:add_post_migrate_path_to_rails).and_call_original
+ expect(Rake::Task['db:schema:load']).to receive(:invoke)
+ expect(Rake::Task['db:seed_fu']).to receive(:invoke)
+ expect(Rake::Task['db:migrate']).not_to receive(:invoke)
+
+ run_rake_task('gitlab:db:configure')
+
+ expect(rails_paths['db/migrate'].include?(File.join(Rails.root, 'db', 'post_migrate'))).to be(true)
+ end
+ end
+
+ context 'when the schema is loaded' do
+ it 'ignores post deployment migrations' do
+ allow(connection).to receive(:tables).and_return(%w[table1 table2])
+
+ expect(Rake::Task['db:migrate']).to receive(:invoke)
+ expect(Gitlab::Database).not_to receive(:add_post_migrate_path_to_rails)
+ expect(Rake::Task['db:schema:load']).not_to receive(:invoke)
+ expect(Rake::Task['db:seed_fu']).not_to receive(:invoke)
- it 'does not invoke seed after a failed schema_load' do
- allow(ActiveRecord::Base.connection).to receive(:tables).and_return([])
- allow(Rake::Task['db:structure:load']).to receive(:invoke).and_raise(RuntimeError, 'error')
- expect(Rake::Task['db:structure:load']).to receive(:invoke)
- expect(Rake::Task['db:seed_fu']).not_to receive(:invoke)
- expect(Rake::Task['db:migrate']).not_to receive(:invoke)
- expect { run_rake_task('gitlab:db:configure') }.to raise_error(RuntimeError, 'error')
+ run_rake_task('gitlab:db:configure')
+
+ expect(rails_paths['db/migrate'].include?(File.join(Rails.root, 'db', 'post_migrate'))).to be(false)
+ end
+ end
+ end
+ end
+
+ context 'when geo is configured' do
+ context 'when the main database is also configured' do
+ before do
+ skip_unless_geo_configured
+ end
+
+ it 'only configures the main database' do
+ allow(connection).to receive(:tables).and_return(%w[table1 table2])
+
+ expect(Rake::Task['db:migrate:main']).to receive(:invoke)
+
+ expect(Rake::Task['db:migrate:geo']).not_to receive(:invoke)
+ expect(Rake::Task['db:schema:load:geo']).not_to receive(:invoke)
+
+ run_rake_task('gitlab:db:configure')
+ end
+ end
+ end
end
- context 'SKIP_POST_DEPLOYMENT_MIGRATIONS environment variable set' do
- let(:rails_paths) { { 'db' => ['db'], 'db/migrate' => ['db/migrate'] } }
+ context 'with multiple databases' do
+ let(:main_model) { double(:model, connection: double(:connection)) }
+ let(:ci_model) { double(:model, connection: double(:connection)) }
+ let(:base_models) { { 'main' => main_model, 'ci' => ci_model }.with_indifferent_access }
+
+ let(:main_config) { double(:config, name: 'main') }
+ let(:ci_config) { double(:config, name: 'ci') }
before do
- allow(ENV).to receive(:[]).and_call_original
- allow(ENV).to receive(:[]).with('SKIP_POST_DEPLOYMENT_MIGRATIONS').and_return true
+ skip_unless_ci_uses_database_tasks
- # Our environment has already been loaded, so we need to pretend like post_migrations were not
- allow(Rails.application.config).to receive(:paths).and_return(rails_paths)
- allow(ActiveRecord::Migrator).to receive(:migrations_paths).and_return(rails_paths['db/migrate'].dup)
+ allow(Gitlab::Database).to receive(:database_base_models).and_return(base_models)
end
- it 'adds post deployment migrations before schema load if the schema is not already loaded' do
- allow(ActiveRecord::Base.connection).to receive(:tables).and_return([])
- expect(Gitlab::Database).to receive(:add_post_migrate_path_to_rails).and_call_original
- expect(Rake::Task['db:structure:load']).to receive(:invoke)
- expect(Rake::Task['db:seed_fu']).to receive(:invoke)
- expect(Rake::Task['db:migrate']).not_to receive(:invoke)
- expect { run_rake_task('gitlab:db:configure') }.not_to raise_error
- expect(rails_paths['db/migrate'].include?(File.join(Rails.root, 'db', 'post_migrate'))).to be(true)
+ context 'when geo is not configured' do
+ before do
+ allow(ActiveRecord::Base).to receive_message_chain('configurations.configs_for')
+ .and_return([main_config, ci_config])
+ end
+
+ context 'when no database has the schema loaded' do
+ before do
+ allow(main_model.connection).to receive(:tables).and_return(%w[schema_migrations])
+ allow(ci_model.connection).to receive(:tables).and_return([])
+ end
+
+ it 'loads the schema and seeds all the databases' do
+ expect(Rake::Task['db:schema:load:main']).to receive(:invoke)
+ expect(Rake::Task['db:schema:load:ci']).to receive(:invoke)
+
+ expect(Rake::Task['db:migrate:main']).not_to receive(:invoke)
+ expect(Rake::Task['db:migrate:ci']).not_to receive(:invoke)
+
+ expect(Rake::Task['db:seed_fu']).to receive(:invoke)
+
+ run_rake_task('gitlab:db:configure')
+ end
+ end
+
+ context 'when both databases have the schema loaded' do
+ before do
+ allow(main_model.connection).to receive(:tables).and_return(%w[table1 table2])
+ allow(ci_model.connection).to receive(:tables).and_return(%w[table1 table2])
+ end
+
+ it 'migrates the databases without seeding them' do
+ expect(Rake::Task['db:migrate:main']).to receive(:invoke)
+ expect(Rake::Task['db:migrate:ci']).to receive(:invoke)
+
+ expect(Rake::Task['db:schema:load:main']).not_to receive(:invoke)
+ expect(Rake::Task['db:schema:load:ci']).not_to receive(:invoke)
+
+ expect(Rake::Task['db:seed_fu']).not_to receive(:invoke)
+
+ run_rake_task('gitlab:db:configure')
+ end
+ end
+
+ context 'when only one database has the schema loaded' do
+ before do
+ allow(main_model.connection).to receive(:tables).and_return(%w[table1 table2])
+ allow(ci_model.connection).to receive(:tables).and_return([])
+ end
+
+ it 'migrates and loads the schema correctly, without seeding the databases' do
+ expect(Rake::Task['db:migrate:main']).to receive(:invoke)
+ expect(Rake::Task['db:schema:load:main']).not_to receive(:invoke)
+
+ expect(Rake::Task['db:schema:load:ci']).to receive(:invoke)
+ expect(Rake::Task['db:migrate:ci']).not_to receive(:invoke)
+
+ expect(Rake::Task['db:seed_fu']).not_to receive(:invoke)
+
+ run_rake_task('gitlab:db:configure')
+ end
+ end
end
- it 'ignores post deployment migrations when schema has already been loaded' do
- allow(ActiveRecord::Base.connection).to receive(:tables).and_return(%w[table1 table2])
- expect(Rake::Task['db:migrate']).to receive(:invoke)
- expect(Gitlab::Database).not_to receive(:add_post_migrate_path_to_rails)
- expect(Rake::Task['db:structure:load']).not_to receive(:invoke)
- expect(Rake::Task['db:seed_fu']).not_to receive(:invoke)
- expect { run_rake_task('gitlab:db:configure') }.not_to raise_error
- expect(rails_paths['db/migrate'].include?(File.join(Rails.root, 'db', 'post_migrate'))).to be(false)
+ context 'when geo is configured' do
+ let(:geo_config) { double(:config, name: 'geo') }
+
+ before do
+ skip_unless_geo_configured
+
+ allow(main_model.connection).to receive(:tables).and_return(%w[schema_migrations])
+ allow(ci_model.connection).to receive(:tables).and_return(%w[schema_migrations])
+ end
+
+ it 'does not run tasks against geo' do
+ expect(Rake::Task['db:schema:load:main']).to receive(:invoke)
+ expect(Rake::Task['db:schema:load:ci']).to receive(:invoke)
+ expect(Rake::Task['db:seed_fu']).to receive(:invoke)
+
+ expect(Rake::Task['db:migrate:geo']).not_to receive(:invoke)
+ expect(Rake::Task['db:schema:load:geo']).not_to receive(:invoke)
+
+ run_rake_task('gitlab:db:configure')
+ end
end
end
end
@@ -290,7 +442,7 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout do
let(:base_models) { { 'main' => main_model, 'ci' => ci_model } }
before do
- skip_if_multiple_databases_not_setup
+ skip_unless_ci_uses_database_tasks
allow(Gitlab::Database).to receive(:database_base_models).and_return(base_models)
@@ -319,6 +471,17 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout do
run_rake_task('gitlab:db:drop_tables:main')
end
end
+
+ context 'with geo configured' do
+ before do
+ skip_unless_geo_configured
+ end
+
+ it 'does not create a task for the geo database' do
+ expect { run_rake_task('gitlab:db:drop_tables:geo') }
+ .to raise_error(/Don't know how to build task 'gitlab:db:drop_tables:geo'/)
+ end
+ end
end
def expect_objects_to_be_dropped(connection)
@@ -336,38 +499,119 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout do
end
end
- describe 'reindex' do
- it 'delegates to Gitlab::Database::Reindexing' do
- expect(Gitlab::Database::Reindexing).to receive(:invoke)
+ describe 'create_dynamic_partitions' do
+ context 'with a single database' do
+ before do
+ skip_if_multiple_databases_are_setup
+ end
+
+ it 'delegates syncing of partitions without limiting databases' do
+ expect(Gitlab::Database::Partitioning).to receive(:sync_partitions)
+
+ run_rake_task('gitlab:db:create_dynamic_partitions')
+ end
+ end
+
+ context 'with multiple databases' do
+ before do
+ skip_unless_ci_uses_database_tasks
+ end
+
+ context 'when running the multi-database variant' do
+ it 'delegates syncing of partitions without limiting databases' do
+ expect(Gitlab::Database::Partitioning).to receive(:sync_partitions)
- run_rake_task('gitlab:db:reindex')
+ run_rake_task('gitlab:db:create_dynamic_partitions')
+ end
+ end
+
+ context 'when running a single-database variant' do
+ it 'delegates syncing of partitions for the chosen database' do
+ expect(Gitlab::Database::Partitioning).to receive(:sync_partitions).with(only_on: 'main')
+
+ run_rake_task('gitlab:db:create_dynamic_partitions:main')
+ end
+ end
end
- context 'when reindexing is not enabled' do
- it 'is a no-op' do
- expect(Gitlab::Database::Reindexing).to receive(:enabled?).and_return(false)
- expect(Gitlab::Database::Reindexing).not_to receive(:invoke)
+ context 'with geo configured' do
+ before do
+ skip_unless_geo_configured
+ end
- expect { run_rake_task('gitlab:db:reindex') }.to raise_error(SystemExit)
+ it 'does not create a task for the geo database' do
+ expect { run_rake_task('gitlab:db:create_dynamic_partitions:geo') }
+ .to raise_error(/Don't know how to build task 'gitlab:db:create_dynamic_partitions:geo'/)
end
end
end
- databases = ActiveRecord::Tasks::DatabaseTasks.setup_initial_database_yaml
- ActiveRecord::Tasks::DatabaseTasks.for_each(databases) do |database_name|
- describe "reindex:#{database_name}" do
+ describe 'reindex' do
+ context 'with a single database' do
+ before do
+ skip_if_multiple_databases_are_setup
+ end
+
it 'delegates to Gitlab::Database::Reindexing' do
- expect(Gitlab::Database::Reindexing).to receive(:invoke).with(database_name)
+ expect(Gitlab::Database::Reindexing).to receive(:invoke).with(no_args)
- run_rake_task("gitlab:db:reindex:#{database_name}")
+ run_rake_task('gitlab:db:reindex')
end
context 'when reindexing is not enabled' do
it 'is a no-op' do
expect(Gitlab::Database::Reindexing).to receive(:enabled?).and_return(false)
- expect(Gitlab::Database::Reindexing).not_to receive(:invoke).with(database_name)
+ expect(Gitlab::Database::Reindexing).not_to receive(:invoke)
- expect { run_rake_task("gitlab:db:reindex:#{database_name}") }.to raise_error(SystemExit)
+ expect { run_rake_task('gitlab:db:reindex') }.to raise_error(SystemExit)
+ end
+ end
+ end
+
+ context 'with multiple databases' do
+ let(:base_models) { { 'main' => double(:model), 'ci' => double(:model) } }
+
+ before do
+ skip_if_multiple_databases_not_setup
+
+ allow(Gitlab::Database).to receive(:database_base_models).and_return(base_models)
+ end
+
+ it 'delegates to Gitlab::Database::Reindexing without a specific database' do
+ expect(Gitlab::Database::Reindexing).to receive(:invoke).with(no_args)
+
+ run_rake_task('gitlab:db:reindex')
+ end
+
+ context 'when the single database task is used' do
+ before do
+ skip_unless_ci_uses_database_tasks
+ end
+
+ it 'delegates to Gitlab::Database::Reindexing with a specific database' do
+ expect(Gitlab::Database::Reindexing).to receive(:invoke).with('ci')
+
+ run_rake_task('gitlab:db:reindex:ci')
+ end
+
+ context 'when reindexing is not enabled' do
+ it 'is a no-op' do
+ expect(Gitlab::Database::Reindexing).to receive(:enabled?).and_return(false)
+ expect(Gitlab::Database::Reindexing).not_to receive(:invoke)
+
+ expect { run_rake_task('gitlab:db:reindex:ci') }.to raise_error(SystemExit)
+ end
+ end
+ end
+
+ context 'with geo configured' do
+ before do
+ skip_unless_geo_configured
+ end
+
+ it 'does not create a task for the geo database' do
+ expect { run_rake_task('gitlab:db:reindex:geo') }
+ .to raise_error(/Don't know how to build task 'gitlab:db:reindex:geo'/)
end
end
end
@@ -439,34 +683,77 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout do
subject
end
end
+
+ describe '#sample_background_migrations' do
+ it 'delegates to the migration runner with a default sample duration' do
+ expect(::Gitlab::Database::Migrations::Runner).to receive_message_chain(:background_migrations, :run_jobs).with(for_duration: 30.minutes)
+
+ run_rake_task('gitlab:db:migration_testing:sample_background_migrations')
+ end
+
+ it 'delegates to the migration runner with a configured sample duration' do
+ expect(::Gitlab::Database::Migrations::Runner).to receive_message_chain(:background_migrations, :run_jobs).with(for_duration: 100.seconds)
+
+ run_rake_task('gitlab:db:migration_testing:sample_background_migrations', '[100]')
+ end
+ end
end
describe '#execute_batched_migrations' do
- subject { run_rake_task('gitlab:db:execute_batched_migrations') }
+ subject(:execute_batched_migrations) { run_rake_task('gitlab:db:execute_batched_migrations') }
- let(:migrations) { create_list(:batched_background_migration, 2) }
- let(:runner) { instance_double('Gitlab::Database::BackgroundMigration::BatchedMigrationRunner') }
+ let(:connections) do
+ {
+ main: instance_double(ActiveRecord::ConnectionAdapters::PostgreSQLAdapter),
+ ci: instance_double(ActiveRecord::ConnectionAdapters::PostgreSQLAdapter)
+ }
+ end
+
+ let(:runners) do
+ {
+ main: instance_double('Gitlab::Database::BackgroundMigration::BatchedMigrationRunner'),
+ ci: instance_double('Gitlab::Database::BackgroundMigration::BatchedMigrationRunner')
+ }
+ end
+
+ let(:migrations) do
+ {
+ main: build_list(:batched_background_migration, 1),
+ ci: build_list(:batched_background_migration, 1)
+ }
+ end
before do
- allow(Gitlab::Database::BackgroundMigration::BatchedMigration).to receive_message_chain(:active, :queue_order).and_return(migrations)
- allow(Gitlab::Database::BackgroundMigration::BatchedMigrationRunner).to receive(:new).and_return(runner)
+ each_database = class_double('Gitlab::Database::EachDatabase').as_stubbed_const
+
+ allow(each_database).to receive(:each_database_connection)
+ .and_yield(connections[:main], 'main')
+ .and_yield(connections[:ci], 'ci')
+
+ keys = migrations.keys
+ allow(Gitlab::Database::BackgroundMigration::BatchedMigration)
+ .to receive_message_chain(:with_status, :queue_order) { migrations[keys.shift] }
end
it 'executes all migrations' do
- migrations.each do |migration|
- expect(runner).to receive(:run_entire_migration).with(migration)
+ [:main, :ci].each do |name|
+ expect(Gitlab::Database::BackgroundMigration::BatchedMigrationRunner).to receive(:new)
+ .with(connection: connections[name])
+ .and_return(runners[name])
+
+ expect(runners[name]).to receive(:run_entire_migration).with(migrations[name].first)
end
- subject
+ execute_batched_migrations
end
end
context 'with multiple databases', :reestablished_active_record_base do
before do
- skip_if_multiple_databases_not_setup
+ skip_unless_ci_uses_database_tasks
end
- describe 'db:structure:dump' do
+ describe 'db:structure:dump against a single database' do
it 'invokes gitlab:db:clean_structure_sql' do
expect(Rake::Task['gitlab:db:clean_structure_sql']).to receive(:invoke).twice.and_return(true)
@@ -474,7 +761,7 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout do
end
end
- describe 'db:schema:dump' do
+ describe 'db:schema:dump against a single database' do
it 'invokes gitlab:db:clean_structure_sql' do
expect(Rake::Task['gitlab:db:clean_structure_sql']).to receive(:invoke).once.and_return(true)
@@ -482,26 +769,24 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout do
end
end
- describe 'db:migrate' do
- it 'invokes gitlab:db:create_dynamic_partitions' do
- expect(Rake::Task['gitlab:db:create_dynamic_partitions']).to receive(:invoke).once.and_return(true)
+ describe 'db:migrate against a single database' do
+ it 'invokes gitlab:db:create_dynamic_partitions for the same database' do
+ expect(Rake::Task['gitlab:db:create_dynamic_partitions:main']).to receive(:invoke).once.and_return(true)
expect { run_rake_task('db:migrate:main') }.not_to raise_error
end
end
describe 'db:migrate:geo' do
- it 'does not invoke gitlab:db:create_dynamic_partitions' do
- skip 'Skipping because geo database is not setup' unless geo_configured?
+ before do
+ skip_unless_geo_configured
+ end
+ it 'does not invoke gitlab:db:create_dynamic_partitions' do
expect(Rake::Task['gitlab:db:create_dynamic_partitions']).not_to receive(:invoke)
expect { run_rake_task('db:migrate:geo') }.not_to raise_error
end
-
- def geo_configured?
- !!ActiveRecord::Base.configurations.configs_for(env_name: Rails.env, name: 'geo')
- end
end
end
@@ -559,4 +844,20 @@ RSpec.describe 'gitlab:db namespace rake task', :silence_stdout do
run_rake_task(test_task_name)
end
+
+ def skip_unless_ci_uses_database_tasks
+ skip "Skipping because database tasks won't run against the ci database" unless ci_database_tasks?
+ end
+
+ def ci_database_tasks?
+ !!ActiveRecord::Base.configurations.configs_for(env_name: Rails.env, name: 'ci')&.database_tasks?
+ end
+
+ def skip_unless_geo_configured
+ skip 'Skipping because the geo database is not configured' unless geo_configured?
+ end
+
+ def geo_configured?
+ !!ActiveRecord::Base.configurations.configs_for(env_name: Rails.env, name: 'geo')
+ end
end
diff --git a/spec/tasks/gitlab/refresh_project_statistics_build_artifacts_size_rake_spec.rb b/spec/tasks/gitlab/refresh_project_statistics_build_artifacts_size_rake_spec.rb
index e57704d0ebe..3495b535cff 100644
--- a/spec/tasks/gitlab/refresh_project_statistics_build_artifacts_size_rake_spec.rb
+++ b/spec/tasks/gitlab/refresh_project_statistics_build_artifacts_size_rake_spec.rb
@@ -11,37 +11,53 @@ RSpec.describe 'gitlab:refresh_project_statistics_build_artifacts_size rake task
let_it_be(:project_3) { create(:project) }
let(:string_of_ids) { "#{project_1.id} #{project_2.id} #{project_3.id} 999999" }
+ let(:csv_url) { 'https://www.example.com/foo.csv' }
+ let(:csv_body) do
+ <<~BODY
+ PROJECT_ID
+ #{project_1.id}
+ #{project_2.id}
+ #{project_3.id}
+ BODY
+ end
before do
Rake.application.rake_require('tasks/gitlab/refresh_project_statistics_build_artifacts_size')
stub_const("BUILD_ARTIFACTS_SIZE_REFRESH_ENQUEUE_BATCH_SIZE", 2)
- end
- context 'when given a list of space-separated IDs through STDIN' do
- before do
- allow($stdin).to receive(:tty?).and_return(false)
- allow($stdin).to receive(:read).and_return(string_of_ids)
- end
+ stub_request(:get, csv_url).to_return(status: 200, body: csv_body)
+ allow(Kernel).to receive(:sleep).with(1)
+ end
+ context 'when given a list of space-separated IDs through rake argument' do
it 'enqueues the projects for refresh' do
- expect { run_rake_task(rake_task) }.to output(/Done/).to_stdout
+ expect { run_rake_task(rake_task, csv_url) }.to output(/Done/).to_stdout
expect(Projects::BuildArtifactsSizeRefresh.all.map(&:project)).to match_array([project_1, project_2, project_3])
end
- end
- context 'when given a list of space-separated IDs through rake argument' do
- it 'enqueues the projects for refresh' do
- expect { run_rake_task(rake_task, string_of_ids) }.to output(/Done/).to_stdout
+ it 'inserts refreshes in batches with a sleep' do
+ expect(Projects::BuildArtifactsSizeRefresh).to receive(:enqueue_refresh).with([project_1, project_2]).ordered
+ expect(Kernel).to receive(:sleep).with(1)
+ expect(Projects::BuildArtifactsSizeRefresh).to receive(:enqueue_refresh).with([project_3]).ordered
- expect(Projects::BuildArtifactsSizeRefresh.all.map(&:project)).to match_array([project_1, project_2, project_3])
+ run_rake_task(rake_task, csv_url)
end
end
- context 'when not given any IDs' do
+ context 'when CSV has invalid header' do
+ let(:csv_body) do
+ <<~BODY
+ projectid
+ #{project_1.id}
+ #{project_2.id}
+ #{project_3.id}
+ BODY
+ end
+
it 'returns an error message' do
- expect { run_rake_task(rake_task) }.to output(/Please provide a string of space-separated project IDs/).to_stdout
+ expect { run_rake_task(rake_task, csv_url) }.to output(/Project IDs must be listed in the CSV under the header PROJECT_ID/).to_stdout
end
end
end
diff --git a/spec/tasks/gitlab/setup_rake_spec.rb b/spec/tasks/gitlab/setup_rake_spec.rb
index 6e4d5087517..c31546fc259 100644
--- a/spec/tasks/gitlab/setup_rake_spec.rb
+++ b/spec/tasks/gitlab/setup_rake_spec.rb
@@ -6,6 +6,7 @@ RSpec.describe 'gitlab:setup namespace rake tasks', :silence_stdout do
before do
Rake.application.rake_require 'active_record/railties/databases'
Rake.application.rake_require 'tasks/seed_fu'
+ Rake.application.rake_require 'tasks/dev'
Rake.application.rake_require 'tasks/gitlab/setup'
end
@@ -22,8 +23,6 @@ RSpec.describe 'gitlab:setup namespace rake tasks', :silence_stdout do
let(:server_service1) { double(:server_service) }
let(:server_service2) { double(:server_service) }
- let(:connections) { Gitlab::Database.database_base_models.values.map(&:connection) }
-
before do
allow(Gitlab).to receive_message_chain('config.repositories.storages').and_return(storages)
@@ -98,18 +97,6 @@ RSpec.describe 'gitlab:setup namespace rake tasks', :silence_stdout do
end
end
- context 'when the database is not found when terminating connections' do
- it 'continues setting up the database', :aggregate_failures do
- expect_gitaly_connections_to_be_checked
-
- expect(connections).to all(receive(:execute).and_raise(ActiveRecord::NoDatabaseError))
-
- expect_database_to_be_setup
-
- setup_task
- end
- end
-
def expect_gitaly_connections_to_be_checked
expect(Gitlab::GitalyClient::ServerService).to receive(:new).with('name1').and_return(server_service1)
expect(server_service1).to receive(:info)
@@ -119,13 +106,11 @@ RSpec.describe 'gitlab:setup namespace rake tasks', :silence_stdout do
end
def expect_connections_to_be_terminated
- expect(connections).to all(receive(:execute).with(/SELECT pg_terminate_backend/))
+ expect(Rake::Task['dev:terminate_all_connections']).to receive(:invoke)
end
def expect_connections_not_to_be_terminated
- connections.each do |connection|
- expect(connection).not_to receive(:execute)
- end
+ expect(Rake::Task['dev:terminate_all_connections']).not_to receive(:invoke)
end
def expect_database_to_be_setup
diff --git a/spec/tooling/danger/product_intelligence_spec.rb b/spec/tooling/danger/product_intelligence_spec.rb
index d0d4b8d4df4..ea08e3bc6db 100644
--- a/spec/tooling/danger/product_intelligence_spec.rb
+++ b/spec/tooling/danger/product_intelligence_spec.rb
@@ -20,70 +20,105 @@ RSpec.describe Tooling::Danger::ProductIntelligence do
allow(fake_helper).to receive(:changed_lines).and_return(changed_lines)
end
- describe '#missing_labels' do
- subject { product_intelligence.missing_labels }
+ describe '#check!' do
+ subject { product_intelligence.check! }
+ let(:markdown_formatted_list) { 'markdown formatted list' }
+ let(:review_pending_label) { 'product intelligence::review pending' }
+ let(:approved_label) { 'product intelligence::approved' }
let(:ci_env) { true }
+ let(:previous_label_to_add) { 'label_to_add' }
+ let(:labels_to_add) { [previous_label_to_add] }
+ let(:has_product_intelligence_label) { true }
before do
- allow(fake_helper).to receive(:mr_has_labels?).and_return(false)
+ allow(fake_helper).to receive(:changes_by_category).and_return(product_intelligence: changed_files, database: ['other_files.yml'])
allow(fake_helper).to receive(:ci?).and_return(ci_env)
+ allow(fake_helper).to receive(:mr_has_labels?).with('product intelligence').and_return(has_product_intelligence_label)
+ allow(fake_helper).to receive(:markdown_list).with(changed_files).and_return(markdown_formatted_list)
+ allow(fake_helper).to receive(:labels_to_add).and_return(labels_to_add)
end
- context 'with ci? false' do
- let(:ci_env) { false }
+ shared_examples "doesn't add new labels" do
+ it "doesn't add new labels" do
+ subject
- it { is_expected.to be_empty }
+ expect(labels_to_add).to match_array [previous_label_to_add]
+ end
end
- context 'with ci? true' do
- let(:expected_labels) { ['product intelligence', 'product intelligence::review pending'] }
+ shared_examples "doesn't add new warnings" do
+ it "doesn't add new warnings" do
+ expect(product_intelligence).not_to receive(:warn)
- it { is_expected.to match_array(expected_labels) }
+ subject
+ end
end
- context 'with product intelligence label' do
- let(:expected_labels) { ['product intelligence::review pending'] }
- let(:mr_labels) { [] }
+ shared_examples 'adds new labels' do
+ it 'adds new labels' do
+ subject
+
+ expect(labels_to_add).to match_array [previous_label_to_add, review_pending_label]
+ end
+ end
+ context 'with growth experiment label' do
before do
- allow(fake_helper).to receive(:mr_has_labels?).with('product intelligence').and_return(true)
- allow(fake_helper).to receive(:mr_labels).and_return(mr_labels)
+ allow(fake_helper).to receive(:mr_has_labels?).with('growth experiment').and_return(true)
end
- it { is_expected.to match_array(expected_labels) }
+ include_examples "doesn't add new labels"
+ include_examples "doesn't add new warnings"
+ end
+
+ context 'without growth experiment label' do
+ before do
+ allow(fake_helper).to receive(:mr_has_labels?).with('growth experiment').and_return(false)
+ end
- context 'with product intelligence::review pending' do
- let(:mr_labels) { ['product intelligence::review pending'] }
+ context 'with approved label' do
+ let(:mr_labels) { [approved_label] }
- it { is_expected.to be_empty }
+ include_examples "doesn't add new labels"
+ include_examples "doesn't add new warnings"
end
- context 'with product intelligence::approved' do
- let(:mr_labels) { ['product intelligence::approved'] }
+ context 'without approved label' do
+ include_examples 'adds new labels'
+
+ it 'warns with proper message' do
+ expect(product_intelligence).to receive(:warn).with(%r{#{markdown_formatted_list}})
- it { is_expected.to be_empty }
+ subject
+ end
end
- end
- end
- describe '#skip_review' do
- subject { product_intelligence.skip_review? }
+ context 'with product intelligence::review pending label' do
+ let(:mr_labels) { ['product intelligence::review pending'] }
- context 'with growth experiment label' do
- before do
- allow(fake_helper).to receive(:mr_has_labels?).with('growth experiment').and_return(true)
+ include_examples "doesn't add new labels"
end
- it { is_expected.to be true }
- end
+ context 'with product intelligence::approved label' do
+ let(:mr_labels) { ['product intelligence::approved'] }
- context 'without growth experiment label' do
- before do
- allow(fake_helper).to receive(:mr_has_labels?).with('growth experiment').and_return(false)
+ include_examples "doesn't add new labels"
end
- it { is_expected.to be false }
+ context 'with the product intelligence label' do
+ let(:has_product_intelligence_label) { true }
+
+ context 'with ci? false' do
+ let(:ci_env) { false }
+
+ include_examples "doesn't add new labels"
+ end
+
+ context 'with ci? true' do
+ include_examples 'adds new labels'
+ end
+ end
end
end
end
diff --git a/spec/tooling/danger/project_helper_spec.rb b/spec/tooling/danger/project_helper_spec.rb
index 902e01e2cbd..b3fb592c2e3 100644
--- a/spec/tooling/danger/project_helper_spec.rb
+++ b/spec/tooling/danger/project_helper_spec.rb
@@ -276,40 +276,6 @@ RSpec.describe Tooling::Danger::ProjectHelper do
end
end
- describe '.local_warning_message' do
- it 'returns an informational message with rules that can run' do
- expect(described_class.local_warning_message).to eq('==> Only the following Danger rules can be run locally: ci_config, database, documentation, duplicate_yarn_dependencies, eslint, gitaly, pajamas, pipeline, prettier, product_intelligence, utility_css, vue_shared_documentation, datateam')
- end
- end
-
- describe '.success_message' do
- it 'returns an informational success message' do
- expect(described_class.success_message).to eq('==> No Danger rule violations!')
- end
- end
-
- describe '#rule_names' do
- context 'when running locally' do
- before do
- expect(fake_helper).to receive(:ci?).and_return(false)
- end
-
- it 'returns local only rules' do
- expect(project_helper.rule_names).to match_array(described_class::LOCAL_RULES)
- end
- end
-
- context 'when running under CI' do
- before do
- expect(fake_helper).to receive(:ci?).and_return(true)
- end
-
- it 'returns all rules' do
- expect(project_helper.rule_names).to eq(described_class::LOCAL_RULES | described_class::CI_ONLY_RULES)
- end
- end
- end
-
describe '#file_lines' do
let(:filename) { 'spec/foo_spec.rb' }
let(:file_spy) { spy }
diff --git a/spec/uploaders/ci/secure_file_uploader_spec.rb b/spec/uploaders/ci/secure_file_uploader_spec.rb
index 3be4f742a24..4bac591704b 100644
--- a/spec/uploaders/ci/secure_file_uploader_spec.rb
+++ b/spec/uploaders/ci/secure_file_uploader_spec.rb
@@ -15,9 +15,9 @@ RSpec.describe Ci::SecureFileUploader do
describe '#key' do
it 'creates a digest with a secret key and the project id' do
- expect(OpenSSL::HMAC)
+ expect(Digest::SHA256)
.to receive(:digest)
- .with('SHA256', Gitlab::Application.secrets.db_key_base, ci_secure_file.project_id.to_s)
+ .with(ci_secure_file.key_data)
.and_return('digest')
expect(subject.key).to eq('digest')
diff --git a/spec/validators/addressable_url_validator_spec.rb b/spec/validators/addressable_url_validator_spec.rb
index 7e2cc2afa8a..b3a4459db30 100644
--- a/spec/validators/addressable_url_validator_spec.rb
+++ b/spec/validators/addressable_url_validator_spec.rb
@@ -30,7 +30,7 @@ RSpec.describe AddressableUrlValidator do
it 'allows urls with encoded CR or LF characters' do
aggregate_failures do
- valid_urls_with_CRLF.each do |url|
+ valid_urls_with_crlf.each do |url|
validator.validate_each(badge, :link_url, url)
expect(badge.errors).to be_empty
@@ -40,7 +40,7 @@ RSpec.describe AddressableUrlValidator do
it 'does not allow urls with CR or LF characters' do
aggregate_failures do
- urls_with_CRLF.each do |url|
+ urls_with_crlf.each do |url|
badge = build(:badge, link_url: 'http://www.example.com')
validator.validate_each(badge, :link_url, url)
diff --git a/spec/views/admin/application_settings/_ci_cd.html.haml_spec.rb b/spec/views/admin/application_settings/_ci_cd.html.haml_spec.rb
new file mode 100644
index 00000000000..12593b88009
--- /dev/null
+++ b/spec/views/admin/application_settings/_ci_cd.html.haml_spec.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'admin/application_settings/_ci_cd' do
+ let_it_be(:admin) { create(:admin) }
+ let_it_be(:application_setting) { build(:application_setting) }
+
+ let_it_be(:limits_attributes) do
+ {
+ ci_pipeline_size: 10,
+ ci_active_jobs: 20,
+ ci_project_subscriptions: 30,
+ ci_pipeline_schedules: 40,
+ ci_needs_size_limit: 50,
+ ci_registered_group_runners: 60,
+ ci_registered_project_runners: 70
+ }
+ end
+
+ let_it_be(:default_plan_limits) { create(:plan_limits, :default_plan, **limits_attributes) }
+
+ let(:page) { Capybara::Node::Simple.new(rendered) }
+
+ before do
+ assign(:application_setting, application_setting)
+ allow(view).to receive(:current_user) { admin }
+ allow(view).to receive(:expanded) { true }
+ end
+
+ subject { render partial: 'admin/application_settings/ci_cd' }
+
+ context 'limits' do
+ before do
+ assign(:plans, [default_plan_limits.plan])
+ end
+
+ it 'has fields for CI/CD limits', :aggregate_failures do
+ subject
+
+ expect(rendered).to have_field('Maximum number of jobs in a single pipeline', type: 'number')
+ expect(page.find_field('Maximum number of jobs in a single pipeline').value).to eq('10')
+
+ expect(rendered).to have_field('Total number of jobs in currently active pipelines', type: 'number')
+ expect(page.find_field('Total number of jobs in currently active pipelines').value).to eq('20')
+
+ expect(rendered).to have_field('Maximum number of pipeline subscriptions to and from a project', type: 'number')
+ expect(page.find_field('Maximum number of pipeline subscriptions to and from a project').value).to eq('30')
+
+ expect(rendered).to have_field('Maximum number of pipeline schedules', type: 'number')
+ expect(page.find_field('Maximum number of pipeline schedules').value).to eq('40')
+
+ expect(rendered).to have_field('Maximum number of DAG dependencies that a job can have', type: 'number')
+ expect(page.find_field('Maximum number of DAG dependencies that a job can have').value).to eq('50')
+
+ expect(rendered).to have_field('Maximum number of runners registered per group', type: 'number')
+ expect(page.find_field('Maximum number of runners registered per group').value).to eq('60')
+
+ expect(rendered).to have_field('Maximum number of runners registered per project', type: 'number')
+ expect(page.find_field('Maximum number of runners registered per project').value).to eq('70')
+ end
+
+ it 'does not display the plan name when there is only one plan' do
+ subject
+
+ expect(page).not_to have_selector('a[data-action="plan0"]')
+ end
+ end
+
+ context 'with multiple plans' do
+ let_it_be(:plan) { create(:plan, name: 'Ultimate') }
+ let_it_be(:ultimate_plan_limits) { create(:plan_limits, plan: plan, **limits_attributes) }
+
+ before do
+ assign(:plans, [default_plan_limits.plan, ultimate_plan_limits.plan])
+ end
+
+ it 'displays the plan name when there is more than one plan' do
+ subject
+
+ expect(page).to have_content('Default')
+ expect(page).to have_content('Ultimate')
+ expect(page).to have_selector('a[data-action="plan0"]')
+ expect(page).to have_selector('a[data-action="plan1"]')
+ end
+ end
+end
diff --git a/spec/views/admin/application_settings/_repository_storage.html.haml_spec.rb b/spec/views/admin/application_settings/_repository_storage.html.haml_spec.rb
index dc8f259eb56..244157a3b14 100644
--- a/spec/views/admin/application_settings/_repository_storage.html.haml_spec.rb
+++ b/spec/views/admin/application_settings/_repository_storage.html.haml_spec.rb
@@ -10,42 +10,32 @@ RSpec.describe 'admin/application_settings/_repository_storage.html.haml' do
assign(:application_setting, app_settings)
end
- context 'additional storage config' do
+ context 'with storage weights configured' do
let(:repository_storages_weighted) do
{
'default' => 100,
- 'mepmep' => 50
+ 'mepmep' => 50,
+ 'something_old' => 100
}
end
- it 'lists them all' do
+ it 'lists storages with weight', :aggregate_failures do
render
- Gitlab.config.repositories.storages.keys.each do |storage_name|
- expect(rendered).to have_content(storage_name)
- end
-
- expect(rendered).to have_content('foobar')
+ expect(rendered).to have_field('default', with: 100)
+ expect(rendered).to have_field('mepmep', with: 50)
end
- end
- context 'fewer storage configs' do
- let(:repository_storages_weighted) do
- {
- 'default' => 100,
- 'mepmep' => 50,
- 'something_old' => 100
- }
+ it 'lists storages without weight' do
+ render
+
+ expect(rendered).to have_field('foobar', with: 0)
end
it 'lists only configured storages' do
render
- Gitlab.config.repositories.storages.keys.each do |storage_name|
- expect(rendered).to have_content(storage_name)
- end
-
- expect(rendered).not_to have_content('something_old')
+ expect(rendered).not_to have_field('something_old')
end
end
end
diff --git a/spec/views/dashboard/milestones/index.html.haml_spec.rb b/spec/views/dashboard/milestones/index.html.haml_spec.rb
new file mode 100644
index 00000000000..738d31bfa3f
--- /dev/null
+++ b/spec/views/dashboard/milestones/index.html.haml_spec.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'dashboard/milestones/index.html.haml' do
+ it_behaves_like 'milestone empty states'
+end
diff --git a/spec/views/groups/milestones/index.html.haml_spec.rb b/spec/views/groups/milestones/index.html.haml_spec.rb
new file mode 100644
index 00000000000..40f175ebdc3
--- /dev/null
+++ b/spec/views/groups/milestones/index.html.haml_spec.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'groups/milestones/index.html.haml' do
+ it_behaves_like 'milestone empty states'
+end
diff --git a/spec/views/groups/runners/_sort_dropdown.html.haml_spec.rb b/spec/views/groups/runners/_sort_dropdown.html.haml_spec.rb
index 4b5027a5a56..5438fea85ee 100644
--- a/spec/views/groups/runners/_sort_dropdown.html.haml_spec.rb
+++ b/spec/views/groups/runners/_sort_dropdown.html.haml_spec.rb
@@ -4,27 +4,22 @@ require 'spec_helper'
RSpec.describe 'groups/runners/sort_dropdown.html.haml' do
describe 'render' do
- let_it_be(:sort_options_hash) { { by_title: 'Title' } }
- let_it_be(:sort_title_created_date) { 'Created date' }
-
- before do
- allow(view).to receive(:sort).and_return('by_title')
- end
-
describe 'when a sort option is not selected' do
it 'renders a default sort option' do
- render 'groups/runners/sort_dropdown', sort_options_hash: sort_options_hash, sort_title_created_date: sort_title_created_date
+ render 'groups/runners/sort_dropdown'
- expect(rendered).to have_content 'Created date'
+ expect(rendered).to have_content _('Created date')
end
end
describe 'when a sort option is selected' do
- it 'renders the selected sort option' do
- @sort = :by_title
- render 'groups/runners/sort_dropdown', sort_options_hash: sort_options_hash, sort_title_created_date: sort_title_created_date
+ before do
+ assign(:sort, 'contacted_asc')
+ render 'groups/runners/sort_dropdown'
+ end
- expect(rendered).to have_content 'Title'
+ it 'renders the selected sort option' do
+ expect(rendered).to have_content _('Last Contact')
end
end
end
diff --git a/spec/views/groups/show.html.haml_spec.rb b/spec/views/groups/show.html.haml_spec.rb
deleted file mode 100644
index 43e11d31611..00000000000
--- a/spec/views/groups/show.html.haml_spec.rb
+++ /dev/null
@@ -1,118 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'groups/edit.html.haml' do
- include Devise::Test::ControllerHelpers
-
- describe '"Share with group lock" setting' do
- let(:root_owner) { create(:user) }
- let(:root_group) { create(:group) }
-
- before do
- root_group.add_owner(root_owner)
- end
-
- shared_examples_for '"Share with group lock" setting' do |checkbox_options|
- it 'has the correct label, help text, and checkbox options' do
- assign(:group, test_group)
- allow(view).to receive(:can?).with(test_user, :admin_group, test_group).and_return(true)
- allow(view).to receive(:can_change_group_visibility_level?).and_return(false)
- allow(view).to receive(:current_user).and_return(test_user)
- expect(view).to receive(:can_change_share_with_group_lock?).and_return(!checkbox_options[:disabled])
- expect(view).to receive(:share_with_group_lock_help_text).and_return('help text here')
-
- render
-
- expect(rendered).to have_content("Prevent sharing a project within #{test_group.name} with other groups")
- expect(rendered).to have_content('help text here')
- expect(rendered).to have_field('group_share_with_group_lock', **checkbox_options)
- end
- end
-
- context 'for a root group' do
- let(:test_group) { root_group }
- let(:test_user) { root_owner }
-
- it_behaves_like '"Share with group lock" setting', { disabled: false, checked: false }
- end
-
- context 'for a subgroup' do
- let!(:subgroup) { create(:group, parent: root_group) }
- let(:sub_owner) { create(:user) }
- let(:test_group) { subgroup }
-
- context 'when the root_group has "Share with group lock" disabled' do
- context 'when the subgroup has "Share with group lock" disabled' do
- context 'as the root_owner' do
- let(:test_user) { root_owner }
-
- it_behaves_like '"Share with group lock" setting', { disabled: false, checked: false }
- end
-
- context 'as the sub_owner' do
- let(:test_user) { sub_owner }
-
- it_behaves_like '"Share with group lock" setting', { disabled: false, checked: false }
- end
- end
-
- context 'when the subgroup has "Share with group lock" enabled' do
- before do
- subgroup.update_column(:share_with_group_lock, true)
- end
-
- context 'as the root_owner' do
- let(:test_user) { root_owner }
-
- it_behaves_like '"Share with group lock" setting', { disabled: false, checked: true }
- end
-
- context 'as the sub_owner' do
- let(:test_user) { sub_owner }
-
- it_behaves_like '"Share with group lock" setting', { disabled: false, checked: true }
- end
- end
- end
-
- context 'when the root_group has "Share with group lock" enabled' do
- before do
- root_group.update_column(:share_with_group_lock, true)
- end
-
- context 'when the subgroup has "Share with group lock" disabled (parent overridden)' do
- context 'as the root_owner' do
- let(:test_user) { root_owner }
-
- it_behaves_like '"Share with group lock" setting', { disabled: false, checked: false }
- end
-
- context 'as the sub_owner' do
- let(:test_user) { sub_owner }
-
- it_behaves_like '"Share with group lock" setting', { disabled: false, checked: false }
- end
- end
-
- context 'when the subgroup has "Share with group lock" enabled (same as parent)' do
- before do
- subgroup.update_column(:share_with_group_lock, true)
- end
-
- context 'as the root_owner' do
- let(:test_user) { root_owner }
-
- it_behaves_like '"Share with group lock" setting', { disabled: false, checked: true }
- end
-
- context 'as the sub_owner' do
- let(:test_user) { sub_owner }
-
- it_behaves_like '"Share with group lock" setting', { disabled: true, checked: true }
- end
- end
- end
- end
- end
-end
diff --git a/spec/views/profiles/keys/_form.html.haml_spec.rb b/spec/views/profiles/keys/_form.html.haml_spec.rb
index 624d7492aea..ba8394178d9 100644
--- a/spec/views/profiles/keys/_form.html.haml_spec.rb
+++ b/spec/views/profiles/keys/_form.html.haml_spec.rb
@@ -30,8 +30,8 @@ RSpec.describe 'profiles/keys/_form.html.haml' do
end
it 'has the title field', :aggregate_failures do
- expect(rendered).to have_field('Title', type: 'text', placeholder: 'e.g. My MacBook key')
- expect(rendered).to have_text('Give your individual key a title. This will be publicly visible.')
+ expect(rendered).to have_field('Title', type: 'text', placeholder: 'Example: MacBook key')
+ expect(rendered).to have_text('Key titles are publicly visible.')
end
it 'has the expires at field', :aggregate_failures do
diff --git a/spec/views/projects/commit/show.html.haml_spec.rb b/spec/views/projects/commit/show.html.haml_spec.rb
index e23ffe300c5..59182f6e757 100644
--- a/spec/views/projects/commit/show.html.haml_spec.rb
+++ b/spec/views/projects/commit/show.html.haml_spec.rb
@@ -25,6 +25,7 @@ RSpec.describe 'projects/commit/show.html.haml' do
allow(view).to receive(:can_collaborate_with_project?).and_return(false)
allow(view).to receive(:current_ref).and_return(project.repository.root_ref)
allow(view).to receive(:diff_btn).and_return('')
+ allow(view).to receive(:pagination_params).and_return({})
end
context 'inline diff view' do
diff --git a/spec/views/projects/milestones/index.html.haml_spec.rb b/spec/views/projects/milestones/index.html.haml_spec.rb
new file mode 100644
index 00000000000..f8117a71310
--- /dev/null
+++ b/spec/views/projects/milestones/index.html.haml_spec.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'projects/milestones/index.html.haml' do
+ it_behaves_like 'milestone empty states'
+end
diff --git a/spec/views/projects/pipelines/show.html.haml_spec.rb b/spec/views/projects/pipelines/show.html.haml_spec.rb
index fcae587f8c8..7e300fb1e6e 100644
--- a/spec/views/projects/pipelines/show.html.haml_spec.rb
+++ b/spec/views/projects/pipelines/show.html.haml_spec.rb
@@ -13,6 +13,7 @@ RSpec.describe 'projects/pipelines/show' do
before do
assign(:project, project)
assign(:pipeline, presented_pipeline)
+ stub_feature_flags(pipeline_tabs_vue: false)
end
context 'when pipeline has errors' do
diff --git a/spec/views/shared/_global_alert.html.haml_spec.rb b/spec/views/shared/_global_alert.html.haml_spec.rb
deleted file mode 100644
index a400d5b39b0..00000000000
--- a/spec/views/shared/_global_alert.html.haml_spec.rb
+++ /dev/null
@@ -1,46 +0,0 @@
-# frozen_string_literal: true
-require 'spec_helper'
-
-RSpec.describe 'shared/_global_alert.html.haml' do
- before do
- allow(view).to receive(:sprite_icon).and_return('<span class="icon"></span>'.html_safe)
- end
-
- it 'renders the title' do
- title = "The alert's title"
- render partial: 'shared/global_alert', locals: { title: title }
-
- expect(rendered).to have_text(title)
- end
-
- context 'variants' do
- it 'renders an info alert by default' do
- render
-
- expect(rendered).to have_selector(".gl-alert-info")
- end
-
- %w[warning success danger tip].each do |variant|
- it "renders a #{variant} variant" do
- allow(view).to receive(:variant).and_return(variant)
- render partial: 'shared/global_alert', locals: { variant: variant }
-
- expect(rendered).to have_selector(".gl-alert-#{variant}")
- end
- end
- end
-
- context 'dismissible option' do
- it 'shows the dismiss button by default' do
- render
-
- expect(rendered).to have_selector('.gl-dismiss-btn')
- end
-
- it 'does not show the dismiss button when dismissible is false' do
- render partial: 'shared/global_alert', locals: { dismissible: false }
-
- expect(rendered).not_to have_selector('.gl-dismiss-btn')
- end
- end
-end
diff --git a/spec/views/shared/_milestones_sort_dropdown.html.haml_spec.rb b/spec/views/shared/_milestones_sort_dropdown.html.haml_spec.rb
new file mode 100644
index 00000000000..2fc286d607a
--- /dev/null
+++ b/spec/views/shared/_milestones_sort_dropdown.html.haml_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'shared/_milestones_sort_dropdown.html.haml' do
+ describe 'render' do
+ describe 'when a sort option is not selected' do
+ it 'renders a default sort option' do
+ render 'shared/milestones_sort_dropdown'
+
+ expect(rendered).to have_content 'Due soon'
+ end
+ end
+
+ describe 'when a sort option is selected' do
+ before do
+ assign(:sort, 'due_date_desc')
+
+ render 'shared/milestones_sort_dropdown'
+ end
+
+ it 'renders the selected sort option' do
+ expect(rendered).to have_content 'Due later'
+ end
+ end
+ end
+end
diff --git a/spec/views/shared/groups/_dropdown.html.haml_spec.rb b/spec/views/shared/groups/_dropdown.html.haml_spec.rb
new file mode 100644
index 00000000000..71fa3a30711
--- /dev/null
+++ b/spec/views/shared/groups/_dropdown.html.haml_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'shared/groups/_dropdown.html.haml' do
+ describe 'render' do
+ describe 'when a sort option is not selected' do
+ it 'renders a default sort option' do
+ render 'shared/groups/dropdown'
+
+ expect(rendered).to have_content 'Last created'
+ end
+ end
+
+ describe 'when a sort option is selected' do
+ before do
+ assign(:sort, 'name_desc')
+
+ render 'shared/groups/dropdown'
+ end
+
+ it 'renders the selected sort option' do
+ expect(rendered).to have_content 'Name, descending'
+ end
+ end
+ end
+end
diff --git a/spec/views/shared/projects/_list.html.haml_spec.rb b/spec/views/shared/projects/_list.html.haml_spec.rb
index 037f988257b..5c38bb79ea1 100644
--- a/spec/views/shared/projects/_list.html.haml_spec.rb
+++ b/spec/views/shared/projects/_list.html.haml_spec.rb
@@ -20,6 +20,18 @@ RSpec.describe 'shared/projects/_list' do
expect(rendered).to have_content(project.name)
end
end
+
+ it "will not show elements a user shouldn't be able to see" do
+ allow(view).to receive(:can_show_last_commit_in_list?).and_return(false)
+ allow(view).to receive(:able_to_see_merge_requests?).and_return(false)
+ allow(view).to receive(:able_to_see_issues?).and_return(false)
+
+ render
+
+ expect(rendered).not_to have_css('a.commit-row-message')
+ expect(rendered).not_to have_css('a.issues')
+ expect(rendered).not_to have_css('a.merge-requests')
+ end
end
context 'without projects' do
diff --git a/spec/workers/bulk_import_worker_spec.rb b/spec/workers/bulk_import_worker_spec.rb
index 12e29573156..7e301efe708 100644
--- a/spec/workers/bulk_import_worker_spec.rb
+++ b/spec/workers/bulk_import_worker_spec.rb
@@ -56,17 +56,6 @@ RSpec.describe BulkImportWorker do
end
end
- context 'when maximum allowed number of import entities in progress' do
- it 'reenqueues itself' do
- bulk_import = create(:bulk_import, :started)
- (described_class::DEFAULT_BATCH_SIZE + 1).times { |_| create(:bulk_import_entity, :started, bulk_import: bulk_import) }
-
- expect(described_class).to receive(:perform_in).with(described_class::PERFORM_DELAY, bulk_import.id)
-
- subject.perform(bulk_import.id)
- end
- end
-
context 'when bulk import is created' do
it 'marks bulk import as started' do
bulk_import = create(:bulk_import, :created)
@@ -84,7 +73,7 @@ RSpec.describe BulkImportWorker do
expect { subject.perform(bulk_import.id) }
.to change(BulkImports::Tracker, :count)
- .by(BulkImports::Groups::Stage.new(bulk_import).pipelines.size * 2)
+ .by(BulkImports::Groups::Stage.new(entity_1).pipelines.size * 2)
expect(entity_1.trackers).not_to be_empty
expect(entity_2.trackers).not_to be_empty
@@ -93,21 +82,17 @@ RSpec.describe BulkImportWorker do
context 'when there are created entities to process' do
let_it_be(:bulk_import) { create(:bulk_import, :created) }
- before do
- stub_const("#{described_class}::DEFAULT_BATCH_SIZE", 1)
- end
-
- it 'marks a batch of entities as started, enqueues EntityWorker, ExportRequestWorker and reenqueues' do
+ it 'marks all entities as started, enqueues EntityWorker, ExportRequestWorker and reenqueues' do
create(:bulk_import_entity, :created, bulk_import: bulk_import)
create(:bulk_import_entity, :created, bulk_import: bulk_import)
expect(described_class).to receive(:perform_in).with(described_class::PERFORM_DELAY, bulk_import.id)
- expect(BulkImports::EntityWorker).to receive(:perform_async)
- expect(BulkImports::ExportRequestWorker).to receive(:perform_async)
+ expect(BulkImports::EntityWorker).to receive(:perform_async).twice
+ expect(BulkImports::ExportRequestWorker).to receive(:perform_async).twice
subject.perform(bulk_import.id)
- expect(bulk_import.entities.map(&:status_name)).to contain_exactly(:created, :started)
+ expect(bulk_import.entities.map(&:status_name)).to contain_exactly(:started, :started)
end
context 'when there are project entities to process' do
diff --git a/spec/workers/bulk_imports/entity_worker_spec.rb b/spec/workers/bulk_imports/entity_worker_spec.rb
index ce45299c7f7..ab85b587975 100644
--- a/spec/workers/bulk_imports/entity_worker_spec.rb
+++ b/spec/workers/bulk_imports/entity_worker_spec.rb
@@ -36,9 +36,11 @@ RSpec.describe BulkImports::EntityWorker do
expect(logger)
.to receive(:info).twice
.with(
- worker: described_class.name,
- entity_id: entity.id,
- current_stage: nil
+ hash_including(
+ 'entity_id' => entity.id,
+ 'current_stage' => nil,
+ 'message' => 'Stage starting'
+ )
)
end
@@ -58,24 +60,26 @@ RSpec.describe BulkImports::EntityWorker do
expect(BulkImports::PipelineWorker)
.to receive(:perform_async)
- .and_raise(exception)
+ .and_raise(exception)
expect_next_instance_of(Gitlab::Import::Logger) do |logger|
expect(logger)
.to receive(:info).twice
.with(
- worker: described_class.name,
- entity_id: entity.id,
- current_stage: nil
+ hash_including(
+ 'entity_id' => entity.id,
+ 'current_stage' => nil
+ )
)
expect(logger)
.to receive(:error)
.with(
- worker: described_class.name,
- entity_id: entity.id,
- current_stage: nil,
- error_message: 'Error!'
+ hash_including(
+ 'entity_id' => entity.id,
+ 'current_stage' => nil,
+ 'message' => 'Error!'
+ )
)
end
@@ -90,6 +94,18 @@ RSpec.describe BulkImports::EntityWorker do
let(:job_args) { [entity.id, 0] }
it 'do not enqueue a new pipeline job if the current stage still running' do
+ expect_next_instance_of(Gitlab::Import::Logger) do |logger|
+ expect(logger)
+ .to receive(:info).twice
+ .with(
+ hash_including(
+ 'entity_id' => entity.id,
+ 'current_stage' => 0,
+ 'message' => 'Stage running'
+ )
+ )
+ end
+
expect(BulkImports::PipelineWorker)
.not_to receive(:perform_async)
@@ -110,9 +126,10 @@ RSpec.describe BulkImports::EntityWorker do
expect(logger)
.to receive(:info).twice
.with(
- worker: described_class.name,
- entity_id: entity.id,
- current_stage: 0
+ hash_including(
+ 'entity_id' => entity.id,
+ 'current_stage' => 0
+ )
)
end
diff --git a/spec/workers/bulk_imports/export_request_worker_spec.rb b/spec/workers/bulk_imports/export_request_worker_spec.rb
index 4f452e3dd60..846df63a4d7 100644
--- a/spec/workers/bulk_imports/export_request_worker_spec.rb
+++ b/spec/workers/bulk_imports/export_request_worker_spec.rb
@@ -35,14 +35,16 @@ RSpec.describe BulkImports::ExportRequestWorker do
expect(client).to receive(:post).and_raise(BulkImports::NetworkError, 'Export error').twice
end
- expect(Gitlab::Import::Logger).to receive(:warn).with(
- bulk_import_entity_id: entity.id,
- pipeline_class: 'ExportRequestWorker',
- exception_class: 'BulkImports::NetworkError',
- exception_message: 'Export error',
- correlation_id_value: anything,
- bulk_import_id: bulk_import.id,
- bulk_import_entity_type: entity.source_type
+ expect(Gitlab::Import::Logger).to receive(:error).with(
+ hash_including(
+ 'bulk_import_entity_id' => entity.id,
+ 'pipeline_class' => 'ExportRequestWorker',
+ 'exception_class' => 'BulkImports::NetworkError',
+ 'exception_message' => 'Export error',
+ 'correlation_id_value' => anything,
+ 'bulk_import_id' => bulk_import.id,
+ 'bulk_import_entity_type' => entity.source_type
+ )
).twice
perform_multiple(job_args)
diff --git a/spec/workers/bulk_imports/pipeline_worker_spec.rb b/spec/workers/bulk_imports/pipeline_worker_spec.rb
index cb7e70a6749..3578fec5bc0 100644
--- a/spec/workers/bulk_imports/pipeline_worker_spec.rb
+++ b/spec/workers/bulk_imports/pipeline_worker_spec.rb
@@ -34,9 +34,10 @@ RSpec.describe BulkImports::PipelineWorker do
expect(logger)
.to receive(:info)
.with(
- worker: described_class.name,
- pipeline_name: 'FakePipeline',
- entity_id: entity.id
+ hash_including(
+ 'pipeline_name' => 'FakePipeline',
+ 'entity_id' => entity.id
+ )
)
end
@@ -44,7 +45,7 @@ RSpec.describe BulkImports::PipelineWorker do
.to receive(:perform_async)
.with(entity.id, pipeline_tracker.stage)
- expect(subject).to receive(:jid).and_return('jid')
+ allow(subject).to receive(:jid).and_return('jid')
subject.perform(pipeline_tracker.id, pipeline_tracker.stage, entity.id)
@@ -79,10 +80,11 @@ RSpec.describe BulkImports::PipelineWorker do
expect(logger)
.to receive(:error)
.with(
- worker: described_class.name,
- pipeline_tracker_id: pipeline_tracker.id,
- entity_id: entity.id,
- message: 'Unstarted pipeline not found'
+ hash_including(
+ 'pipeline_tracker_id' => pipeline_tracker.id,
+ 'entity_id' => entity.id,
+ 'message' => 'Unstarted pipeline not found'
+ )
)
end
@@ -107,10 +109,11 @@ RSpec.describe BulkImports::PipelineWorker do
expect(logger)
.to receive(:error)
.with(
- worker: described_class.name,
- pipeline_name: 'InexistentPipeline',
- entity_id: entity.id,
- message: "'InexistentPipeline' is not a valid BulkImport Pipeline"
+ hash_including(
+ 'pipeline_name' => 'InexistentPipeline',
+ 'entity_id' => entity.id,
+ 'message' => "'InexistentPipeline' is not a valid BulkImport Pipeline"
+ )
)
end
@@ -126,7 +129,7 @@ RSpec.describe BulkImports::PipelineWorker do
.to receive(:perform_async)
.with(entity.id, pipeline_tracker.stage)
- expect(subject).to receive(:jid).and_return('jid')
+ allow(subject).to receive(:jid).and_return('jid')
subject.perform(pipeline_tracker.id, pipeline_tracker.stage, entity.id)
@@ -151,10 +154,11 @@ RSpec.describe BulkImports::PipelineWorker do
expect(logger)
.to receive(:error)
.with(
- worker: described_class.name,
- pipeline_name: 'Pipeline',
- entity_id: entity.id,
- message: 'Failed entity status'
+ hash_including(
+ 'pipeline_name' => 'Pipeline',
+ 'entity_id' => entity.id,
+ 'message' => 'Failed entity status'
+ )
)
end
@@ -183,7 +187,7 @@ RSpec.describe BulkImports::PipelineWorker do
.and_raise(exception)
end
- expect(subject).to receive(:jid).and_return('jid').twice
+ allow(subject).to receive(:jid).and_return('jid')
expect_any_instance_of(BulkImports::Tracker) do |tracker|
expect(tracker).to receive(:retry).and_call_original
@@ -193,9 +197,10 @@ RSpec.describe BulkImports::PipelineWorker do
expect(logger)
.to receive(:info)
.with(
- worker: described_class.name,
- pipeline_name: 'FakePipeline',
- entity_id: entity.id
+ hash_including(
+ 'pipeline_name' => 'FakePipeline',
+ 'entity_id' => entity.id
+ )
)
end
@@ -292,10 +297,11 @@ RSpec.describe BulkImports::PipelineWorker do
expect(logger)
.to receive(:error)
.with(
- worker: described_class.name,
- pipeline_name: 'NdjsonPipeline',
- entity_id: entity.id,
- message: 'Pipeline timeout'
+ hash_including(
+ 'pipeline_name' => 'NdjsonPipeline',
+ 'entity_id' => entity.id,
+ 'message' => 'Pipeline timeout'
+ )
)
end
@@ -318,10 +324,11 @@ RSpec.describe BulkImports::PipelineWorker do
expect(logger)
.to receive(:error)
.with(
- worker: described_class.name,
- pipeline_name: 'NdjsonPipeline',
- entity_id: entity.id,
- message: 'Error!'
+ hash_including(
+ 'pipeline_name' => 'NdjsonPipeline',
+ 'entity_id' => entity.id,
+ 'message' => 'Error!'
+ )
)
end
diff --git a/spec/workers/bulk_imports/stuck_import_worker_spec.rb b/spec/workers/bulk_imports/stuck_import_worker_spec.rb
new file mode 100644
index 00000000000..7dfb6532c07
--- /dev/null
+++ b/spec/workers/bulk_imports/stuck_import_worker_spec.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::StuckImportWorker do
+ let_it_be(:created_bulk_import) { create(:bulk_import, :created) }
+ let_it_be(:started_bulk_import) { create(:bulk_import, :started) }
+ let_it_be(:stale_created_bulk_import) { create(:bulk_import, :created, created_at: 3.days.ago) }
+ let_it_be(:stale_started_bulk_import) { create(:bulk_import, :started, created_at: 3.days.ago) }
+ let_it_be(:stale_created_bulk_import_entity) { create(:bulk_import_entity, :created, created_at: 3.days.ago) }
+ let_it_be(:stale_started_bulk_import_entity) { create(:bulk_import_entity, :started, created_at: 3.days.ago) }
+ let_it_be(:started_bulk_import_tracker) { create(:bulk_import_tracker, :started, entity: stale_started_bulk_import_entity) }
+
+ subject { described_class.new.perform }
+
+ describe 'perform' do
+ it 'updates the status of bulk imports to timeout' do
+ expect { subject }.to change { stale_created_bulk_import.reload.status_name }.from(:created).to(:timeout)
+ .and change { stale_started_bulk_import.reload.status_name }.from(:started).to(:timeout)
+ end
+
+ it 'updates the status of bulk import entities to timeout' do
+ expect { subject }.to change { stale_created_bulk_import_entity.reload.status_name }.from(:created).to(:timeout)
+ .and change { stale_started_bulk_import_entity.reload.status_name }.from(:started).to(:timeout)
+ end
+
+ it 'updates the status of stale entities trackers to timeout' do
+ expect { subject }.to change { started_bulk_import_tracker.reload.status_name }.from(:started).to(:timeout)
+ end
+
+ it 'does not update the status of non-stale records' do
+ expect { subject }.to not_change { created_bulk_import.reload.status }
+ .and not_change { started_bulk_import.reload.status }
+ end
+ end
+end
diff --git a/spec/workers/ci/update_locked_unknown_artifacts_worker_spec.rb b/spec/workers/ci/update_locked_unknown_artifacts_worker_spec.rb
new file mode 100644
index 00000000000..b42d135b1b6
--- /dev/null
+++ b/spec/workers/ci/update_locked_unknown_artifacts_worker_spec.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::UpdateLockedUnknownArtifactsWorker do
+ let(:worker) { described_class.new }
+
+ describe '#perform' do
+ it 'executes an instance of Ci::JobArtifacts::UpdateUnknownLockedStatusService' do
+ expect_next_instance_of(Ci::JobArtifacts::UpdateUnknownLockedStatusService) do |instance|
+ expect(instance).to receive(:execute).and_call_original
+ end
+
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:removed_count, 0)
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:locked_count, 0)
+
+ worker.perform
+ end
+
+ context 'with the ci_job_artifacts_backlog_work flag shut off' do
+ before do
+ stub_feature_flags(ci_job_artifacts_backlog_work: false)
+ end
+
+ it 'does not instantiate a new Ci::JobArtifacts::UpdateUnknownLockedStatusService' do
+ expect(Ci::JobArtifacts::UpdateUnknownLockedStatusService).not_to receive(:new)
+
+ worker.perform
+ end
+
+ it 'does not log any artifact counts' do
+ expect(worker).not_to receive(:log_extra_metadata_on_done)
+
+ worker.perform
+ end
+
+ it 'does not query the database' do
+ query_count = ActiveRecord::QueryRecorder.new { worker.perform }.count
+
+ expect(query_count).to eq(0)
+ end
+ end
+ end
+end
diff --git a/spec/workers/concerns/application_worker_spec.rb b/spec/workers/concerns/application_worker_spec.rb
index 95d9b982fc4..707fa0c9c78 100644
--- a/spec/workers/concerns/application_worker_spec.rb
+++ b/spec/workers/concerns/application_worker_spec.rb
@@ -49,7 +49,7 @@ RSpec.describe ApplicationWorker do
worker.feature_category :pages
expect(worker.sidekiq_options['queue']).to eq('queue_2')
- worker.feature_category_not_owned!
+ worker.feature_category :not_owned
expect(worker.sidekiq_options['queue']).to eq('queue_3')
worker.urgency :high
diff --git a/spec/workers/container_registry/migration/enqueuer_worker_spec.rb b/spec/workers/container_registry/migration/enqueuer_worker_spec.rb
index 12c14c35365..81fa28dc603 100644
--- a/spec/workers/container_registry/migration/enqueuer_worker_spec.rb
+++ b/spec/workers/container_registry/migration/enqueuer_worker_spec.rb
@@ -2,8 +2,13 @@
require 'spec_helper'
-RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures do
+RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures, :clean_gitlab_redis_shared_state do
+ using RSpec::Parameterized::TableSyntax
+ include ExclusiveLeaseHelpers
+
let_it_be_with_reload(:container_repository) { create(:container_repository, created_at: 2.days.ago) }
+ let_it_be(:importing_repository) { create(:container_repository, :importing) }
+ let_it_be(:pre_importing_repository) { create(:container_repository, :pre_importing) }
let(:worker) { described_class.new }
@@ -24,14 +29,14 @@ RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures
end
end
- shared_examples 're-enqueuing based on capacity' do
+ shared_examples 're-enqueuing based on capacity' do |capacity_limit: 4|
context 'below capacity' do
before do
- allow(ContainerRegistry::Migration).to receive(:capacity).and_return(9999)
+ allow(ContainerRegistry::Migration).to receive(:capacity).and_return(capacity_limit)
end
it 're-enqueues the worker' do
- expect(ContainerRegistry::Migration::EnqueuerWorker).to receive(:perform_async)
+ expect(described_class).to receive(:perform_async)
subject
end
@@ -43,7 +48,7 @@ RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures
end
it 'does not re-enqueue the worker' do
- expect(ContainerRegistry::Migration::EnqueuerWorker).not_to receive(:perform_async)
+ expect(described_class).not_to receive(:perform_async)
subject
end
@@ -51,24 +56,46 @@ RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures
end
context 'with qualified repository' do
- it 'starts the pre-import for the next qualified repository' do
+ before do
method = worker.method(:next_repository)
allow(worker).to receive(:next_repository) do
next_qualified_repository = method.call
allow(next_qualified_repository).to receive(:migration_pre_import).and_return(:ok)
next_qualified_repository
end
+ end
- expect(worker).to receive(:log_extra_metadata_on_done)
- .with(:container_repository_id, container_repository.id)
- expect(worker).to receive(:log_extra_metadata_on_done)
- .with(:import_type, 'next')
+ it 'starts the pre-import for the next qualified repository' do
+ expect_log_extra_metadata(
+ import_type: 'next',
+ container_repository_id: container_repository.id,
+ container_repository_path: container_repository.path,
+ container_repository_migration_state: 'pre_importing'
+ )
subject
expect(container_repository.reload).to be_pre_importing
end
+ context 'when the new pre-import maxes out the capacity' do
+ before do
+ # set capacity to 10
+ stub_feature_flags(
+ container_registry_migration_phase2_capacity_25: false
+ )
+
+ # Plus 2 created above gives 9 importing repositories
+ create_list(:container_repository, 7, :importing)
+ end
+
+ it 'does not re-enqueue the worker' do
+ expect(described_class).not_to receive(:perform_async)
+
+ subject
+ end
+ end
+
it_behaves_like 're-enqueuing based on capacity'
end
@@ -77,7 +104,11 @@ RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures
allow(ContainerRegistry::Migration).to receive(:enabled?).and_return(false)
end
- it_behaves_like 'no action'
+ it_behaves_like 'no action' do
+ before do
+ expect_log_extra_metadata(migration_enabled: false)
+ end
+ end
end
context 'above capacity' do
@@ -87,7 +118,11 @@ RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures
allow(ContainerRegistry::Migration).to receive(:capacity).and_return(1)
end
- it_behaves_like 'no action'
+ it_behaves_like 'no action' do
+ before do
+ expect_log_extra_metadata(below_capacity: false, max_capacity_setting: 1)
+ end
+ end
it 'does not re-enqueue the worker' do
expect(ContainerRegistry::Migration::EnqueuerWorker).not_to receive(:perform_async)
@@ -97,38 +132,91 @@ RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures
end
context 'too soon before previous completed import step' do
- before do
- create(:container_repository, :import_done, migration_import_done_at: 1.minute.ago)
- allow(ContainerRegistry::Migration).to receive(:enqueue_waiting_time).and_return(1.hour)
+ where(:state, :timestamp) do
+ :import_done | :migration_import_done_at
+ :pre_import_done | :migration_pre_import_done_at
+ :import_aborted | :migration_aborted_at
+ :import_skipped | :migration_skipped_at
+ end
+
+ with_them do
+ before do
+ allow(ContainerRegistry::Migration).to receive(:enqueue_waiting_time).and_return(45.minutes)
+ create(:container_repository, state, timestamp => 1.minute.ago)
+ end
+
+ it_behaves_like 'no action' do
+ before do
+ expect_log_extra_metadata(waiting_time_passed: false, current_waiting_time_setting: 45.minutes)
+ end
+ end
end
- it_behaves_like 'no action'
+ context 'when last completed repository has nil timestamps' do
+ before do
+ allow(ContainerRegistry::Migration).to receive(:enqueue_waiting_time).and_return(45.minutes)
+ create(:container_repository, migration_state: 'import_done')
+ end
+
+ it 'continues to try the next import' do
+ expect { subject }.to change { container_repository.reload.migration_state }
+ end
+ end
end
context 'when an aborted import is available' do
let_it_be(:aborted_repository) { create(:container_repository, :import_aborted) }
- it 'retries the import for the aborted repository' do
- method = worker.method(:next_aborted_repository)
- allow(worker).to receive(:next_aborted_repository) do
- next_aborted_repository = method.call
- allow(next_aborted_repository).to receive(:migration_import).and_return(:ok)
- allow(next_aborted_repository.gitlab_api_client).to receive(:import_status).and_return('import_failed')
- next_aborted_repository
+ context 'with a successful registry request' do
+ before do
+ method = worker.method(:next_aborted_repository)
+ allow(worker).to receive(:next_aborted_repository) do
+ next_aborted_repository = method.call
+ allow(next_aborted_repository).to receive(:migration_import).and_return(:ok)
+ allow(next_aborted_repository.gitlab_api_client).to receive(:import_status).and_return('import_failed')
+ next_aborted_repository
+ end
end
- expect(worker).to receive(:log_extra_metadata_on_done)
- .with(:container_repository_id, aborted_repository.id)
- expect(worker).to receive(:log_extra_metadata_on_done)
- .with(:import_type, 'retry')
+ it 'retries the import for the aborted repository' do
+ expect_log_extra_metadata(
+ import_type: 'retry',
+ container_repository_id: aborted_repository.id,
+ container_repository_path: aborted_repository.path,
+ container_repository_migration_state: 'importing'
+ )
- subject
+ subject
- expect(aborted_repository.reload).to be_importing
- expect(container_repository.reload).to be_default
+ expect(aborted_repository.reload).to be_importing
+ expect(container_repository.reload).to be_default
+ end
+
+ it_behaves_like 're-enqueuing based on capacity'
end
- it_behaves_like 're-enqueuing based on capacity'
+ context 'when an error occurs' do
+ it 'does not abort that migration' do
+ method = worker.method(:next_aborted_repository)
+ allow(worker).to receive(:next_aborted_repository) do
+ next_aborted_repository = method.call
+ allow(next_aborted_repository).to receive(:retry_aborted_migration).and_raise(StandardError)
+ next_aborted_repository
+ end
+
+ expect_log_extra_metadata(
+ import_type: 'retry',
+ container_repository_id: aborted_repository.id,
+ container_repository_path: aborted_repository.path,
+ container_repository_migration_state: 'import_aborted'
+ )
+
+ subject
+
+ expect(aborted_repository.reload).to be_import_aborted
+ expect(container_repository.reload).to be_default
+ end
+ end
end
context 'when no repository qualifies' do
@@ -147,6 +235,15 @@ RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures
end
it 'skips the repository' do
+ expect_log_extra_metadata(
+ import_type: 'next',
+ container_repository_id: container_repository.id,
+ container_repository_path: container_repository.path,
+ container_repository_migration_state: 'import_skipped',
+ tags_count_too_high: true,
+ max_tags_count_setting: 2
+ )
+
subject
expect(container_repository.reload).to be_import_skipped
@@ -154,7 +251,7 @@ RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures
expect(container_repository.migration_skipped_at).not_to be_nil
end
- it_behaves_like 're-enqueuing based on capacity'
+ it_behaves_like 're-enqueuing based on capacity', capacity_limit: 3
end
context 'when an error occurs' do
@@ -163,10 +260,16 @@ RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures
end
it 'aborts the import' do
+ expect_log_extra_metadata(
+ import_type: 'next',
+ container_repository_id: container_repository.id,
+ container_repository_path: container_repository.path,
+ container_repository_migration_state: 'import_aborted'
+ )
+
expect(Gitlab::ErrorTracking).to receive(:log_exception).with(
instance_of(StandardError),
- next_repository_id: container_repository.id,
- next_aborted_repository_id: nil
+ next_repository_id: container_repository.id
)
subject
@@ -174,5 +277,26 @@ RSpec.describe ContainerRegistry::Migration::EnqueuerWorker, :aggregate_failures
expect(container_repository.reload).to be_import_aborted
end
end
+
+ context 'with the exclusive lease taken' do
+ let(:lease_key) { worker.send(:lease_key) }
+
+ before do
+ stub_exclusive_lease_taken(lease_key, timeout: 30.minutes)
+ end
+
+ it 'does not perform' do
+ expect(worker).not_to receive(:runnable?)
+ expect(worker).not_to receive(:re_enqueue_if_capacity)
+
+ subject
+ end
+ end
+
+ def expect_log_extra_metadata(metadata)
+ metadata.each do |key, value|
+ expect(worker).to receive(:log_extra_metadata_on_done).with(key, value)
+ end
+ end
end
end
diff --git a/spec/workers/container_registry/migration/guard_worker_spec.rb b/spec/workers/container_registry/migration/guard_worker_spec.rb
index 7d1df320d4e..299d1204af3 100644
--- a/spec/workers/container_registry/migration/guard_worker_spec.rb
+++ b/spec/workers/container_registry/migration/guard_worker_spec.rb
@@ -3,8 +3,6 @@
require 'spec_helper'
RSpec.describe ContainerRegistry::Migration::GuardWorker, :aggregate_failures do
- include_context 'container registry client'
-
let(:worker) { described_class.new }
describe '#perform' do
@@ -13,11 +11,12 @@ RSpec.describe ContainerRegistry::Migration::GuardWorker, :aggregate_failures do
let(:importing_migrations) { ::ContainerRepository.with_migration_states(:importing) }
let(:import_aborted_migrations) { ::ContainerRepository.with_migration_states(:import_aborted) }
let(:import_done_migrations) { ::ContainerRepository.with_migration_states(:import_done) }
+ let(:import_skipped_migrations) { ::ContainerRepository.with_migration_states(:import_skipped) }
subject { worker.perform }
before do
- stub_container_registry_config(enabled: true, api_url: registry_api_url, key: 'spec/fixtures/x509_certificate_pk.key')
+ stub_container_registry_config(enabled: true, api_url: 'http://container-registry', key: 'spec/fixtures/x509_certificate_pk.key')
allow(::ContainerRegistry::Migration).to receive(:max_step_duration).and_return(5.minutes)
end
@@ -26,20 +25,57 @@ RSpec.describe ContainerRegistry::Migration::GuardWorker, :aggregate_failures do
allow(::Gitlab).to receive(:com?).and_return(true)
end
- shared_examples 'not aborting any migration' do
- it 'will not abort the migration' do
- expect(worker).to receive(:log_extra_metadata_on_done).with(:stale_migrations_count, 1)
- expect(worker).to receive(:log_extra_metadata_on_done).with(:aborted_stale_migrations_count, 0)
- expect(worker).to receive(:log_extra_metadata_on_done).with(:long_running_stale_migration_container_repository_ids, [stale_migration.id])
+ shared_examples 'handling long running migrations' do
+ before do
+ allow_next_found_instance_of(ContainerRepository) do |repository|
+ allow(repository).to receive(:migration_cancel).and_return(migration_cancel_response)
+ end
+ end
- expect { subject }
- .to not_change(pre_importing_migrations, :count)
- .and not_change(pre_import_done_migrations, :count)
- .and not_change(importing_migrations, :count)
- .and not_change(import_done_migrations, :count)
- .and not_change(import_aborted_migrations, :count)
- .and not_change { stale_migration.reload.migration_state }
- .and not_change { ongoing_migration.migration_state }
+ context 'migration is canceled' do
+ let(:migration_cancel_response) { { status: :ok } }
+
+ it 'will not abort the migration' do
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:stale_migrations_count, 1)
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:aborted_stale_migrations_count, 1)
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:aborted_long_running_migration_ids, [stale_migration.id])
+
+ expect { subject }
+ .to change(import_skipped_migrations, :count)
+
+ expect(stale_migration.reload.migration_state).to eq('import_skipped')
+ expect(stale_migration.reload.migration_skipped_reason).to eq('migration_canceled')
+ end
+ end
+
+ context 'migration cancelation fails with an error' do
+ let(:migration_cancel_response) { { status: :error } }
+
+ it 'will abort the migration' do
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:stale_migrations_count, 1)
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:aborted_stale_migrations_count, 1)
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:aborted_long_running_migration_ids, [stale_migration.id])
+
+ expect { subject }
+ .to change(import_aborted_migrations, :count).by(1)
+ .and change { stale_migration.reload.migration_state }.to('import_aborted')
+ .and not_change { ongoing_migration.migration_state }
+ end
+ end
+
+ context 'migration receives bad request with a new status' do
+ let(:migration_cancel_response) { { status: :bad_request, migration_state: :import_done } }
+
+ it 'will abort the migration' do
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:stale_migrations_count, 1)
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:aborted_stale_migrations_count, 1)
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:aborted_long_running_migration_ids, [stale_migration.id])
+
+ expect { subject }
+ .to change(import_aborted_migrations, :count).by(1)
+ .and change { stale_migration.reload.migration_state }.to('import_aborted')
+ .and not_change { ongoing_migration.migration_state }
+ end
end
end
@@ -86,7 +122,7 @@ RSpec.describe ContainerRegistry::Migration::GuardWorker, :aggregate_failures do
context 'the client returns pre_import_in_progress' do
let(:import_status) { 'pre_import_in_progress' }
- it_behaves_like 'not aborting any migration'
+ it_behaves_like 'handling long running migrations'
end
end
@@ -141,7 +177,7 @@ RSpec.describe ContainerRegistry::Migration::GuardWorker, :aggregate_failures do
context 'the client returns import_in_progress' do
let(:import_status) { 'import_in_progress' }
- it_behaves_like 'not aborting any migration'
+ it_behaves_like 'handling long running migrations'
end
end
end
diff --git a/spec/workers/database/batched_background_migration/ci_database_worker_spec.rb b/spec/workers/database/batched_background_migration/ci_database_worker_spec.rb
index 2663c650986..f3cf5450048 100644
--- a/spec/workers/database/batched_background_migration/ci_database_worker_spec.rb
+++ b/spec/workers/database/batched_background_migration/ci_database_worker_spec.rb
@@ -3,5 +3,5 @@
require 'spec_helper'
RSpec.describe Database::BatchedBackgroundMigration::CiDatabaseWorker, :clean_gitlab_redis_shared_state do
- it_behaves_like 'it runs batched background migration jobs', 'ci'
+ it_behaves_like 'it runs batched background migration jobs', 'ci', feature_flag: :execute_batched_migrations_on_schedule_ci_database
end
diff --git a/spec/workers/database/batched_background_migration_worker_spec.rb b/spec/workers/database/batched_background_migration_worker_spec.rb
index a6c7db60abe..7f0883def3c 100644
--- a/spec/workers/database/batched_background_migration_worker_spec.rb
+++ b/spec/workers/database/batched_background_migration_worker_spec.rb
@@ -3,5 +3,5 @@
require 'spec_helper'
RSpec.describe Database::BatchedBackgroundMigrationWorker do
- it_behaves_like 'it runs batched background migration jobs', :main
+ it_behaves_like 'it runs batched background migration jobs', :main, feature_flag: :execute_batched_migrations_on_schedule
end
diff --git a/spec/workers/database/ci_namespace_mirrors_consistency_check_worker_spec.rb b/spec/workers/database/ci_namespace_mirrors_consistency_check_worker_spec.rb
new file mode 100644
index 00000000000..116026ea8f7
--- /dev/null
+++ b/spec/workers/database/ci_namespace_mirrors_consistency_check_worker_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Database::CiNamespaceMirrorsConsistencyCheckWorker do
+ let(:worker) { described_class.new }
+
+ describe '#perform' do
+ context 'feature flag is disabled' do
+ before do
+ stub_feature_flags(ci_namespace_mirrors_consistency_check: false)
+ end
+
+ it 'does not perform the consistency check on namespaces' do
+ expect(Database::ConsistencyCheckService).not_to receive(:new)
+ expect(worker).not_to receive(:log_extra_metadata_on_done)
+ worker.perform
+ end
+ end
+
+ context 'feature flag is enabled' do
+ before do
+ stub_feature_flags(ci_namespace_mirrors_consistency_check: true)
+ end
+
+ it 'executes the consistency check on namespaces' do
+ expect(Database::ConsistencyCheckService).to receive(:new).and_call_original
+ expected_result = { batches: 0, matches: 0, mismatches: 0, mismatches_details: [] }
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:results, expected_result)
+ worker.perform
+ end
+ end
+
+ context 'logs should contain the detailed mismatches' do
+ let(:first_namespace) { Namespace.all.order(:id).limit(1).first }
+ let(:missing_namespace) { Namespace.all.order(:id).limit(2).last }
+
+ before do
+ redis_shared_state_cleanup!
+ stub_feature_flags(ci_namespace_mirrors_consistency_check: true)
+ create_list(:namespace, 10) # This will also create Ci::NameSpaceMirror objects
+ missing_namespace.delete
+
+ allow_next_instance_of(Database::ConsistencyCheckService) do |instance|
+ allow(instance).to receive(:random_start_id).and_return(Namespace.first.id)
+ end
+ end
+
+ it 'reports the differences to the logs' do
+ expected_result = {
+ batches: 1,
+ matches: 9,
+ mismatches: 1,
+ mismatches_details: [{
+ id: missing_namespace.id,
+ source_table: nil,
+ target_table: [missing_namespace.traversal_ids]
+ }],
+ start_id: first_namespace.id,
+ next_start_id: first_namespace.id # The batch size > number of namespaces
+ }
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:results, expected_result)
+ worker.perform
+ end
+ end
+ end
+end
diff --git a/spec/workers/database/ci_project_mirrors_consistency_check_worker_spec.rb b/spec/workers/database/ci_project_mirrors_consistency_check_worker_spec.rb
new file mode 100644
index 00000000000..b6bd825ffcd
--- /dev/null
+++ b/spec/workers/database/ci_project_mirrors_consistency_check_worker_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Database::CiProjectMirrorsConsistencyCheckWorker do
+ let(:worker) { described_class.new }
+
+ describe '#perform' do
+ context 'feature flag is disabled' do
+ before do
+ stub_feature_flags(ci_project_mirrors_consistency_check: false)
+ end
+
+ it 'does not perform the consistency check on projects' do
+ expect(Database::ConsistencyCheckService).not_to receive(:new)
+ expect(worker).not_to receive(:log_extra_metadata_on_done)
+ worker.perform
+ end
+ end
+
+ context 'feature flag is enabled' do
+ before do
+ stub_feature_flags(ci_project_mirrors_consistency_check: true)
+ end
+
+ it 'executes the consistency check on projects' do
+ expect(Database::ConsistencyCheckService).to receive(:new).and_call_original
+ expected_result = { batches: 0, matches: 0, mismatches: 0, mismatches_details: [] }
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:results, expected_result)
+ worker.perform
+ end
+ end
+
+ context 'logs should contain the detailed mismatches' do
+ let(:first_project) { Project.all.order(:id).limit(1).first }
+ let(:missing_project) { Project.all.order(:id).limit(2).last }
+
+ before do
+ redis_shared_state_cleanup!
+ stub_feature_flags(ci_project_mirrors_consistency_check: true)
+ create_list(:project, 10) # This will also create Ci::NameSpaceMirror objects
+ missing_project.delete
+
+ allow_next_instance_of(Database::ConsistencyCheckService) do |instance|
+ allow(instance).to receive(:random_start_id).and_return(Project.first.id)
+ end
+ end
+
+ it 'reports the differences to the logs' do
+ expected_result = {
+ batches: 1,
+ matches: 9,
+ mismatches: 1,
+ mismatches_details: [{
+ id: missing_project.id,
+ source_table: nil,
+ target_table: [missing_project.namespace_id]
+ }],
+ start_id: first_project.id,
+ next_start_id: first_project.id # The batch size > number of projects
+ }
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:results, expected_result)
+ worker.perform
+ end
+ end
+ end
+end
diff --git a/spec/workers/every_sidekiq_worker_spec.rb b/spec/workers/every_sidekiq_worker_spec.rb
index 47205943f70..0351b500747 100644
--- a/spec/workers/every_sidekiq_worker_spec.rb
+++ b/spec/workers/every_sidekiq_worker_spec.rb
@@ -54,7 +54,7 @@ RSpec.describe 'Every Sidekiq worker' do
# All Sidekiq worker classes should declare a valid `feature_category`
# or explicitly be excluded with the `feature_category_not_owned!` annotation.
# Please see doc/development/sidekiq_style_guide.md#feature-categorization for more details.
- it 'has a feature_category or feature_category_not_owned! attribute', :aggregate_failures do
+ it 'has a feature_category attribute', :aggregate_failures do
workers_without_defaults.each do |worker|
expect(worker.get_feature_category).to be_a(Symbol), "expected #{worker.inspect} to declare a feature_category or feature_category_not_owned!"
end
diff --git a/spec/workers/gitlab/github_import/import_diff_note_worker_spec.rb b/spec/workers/gitlab/github_import/import_diff_note_worker_spec.rb
index 34073d0ea39..af15f465107 100644
--- a/spec/workers/gitlab/github_import/import_diff_note_worker_spec.rb
+++ b/spec/workers/gitlab/github_import/import_diff_note_worker_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe Gitlab::GithubImport::ImportDiffNoteWorker do
describe '#import' do
it 'imports a diff note' do
- project = double(:project, full_path: 'foo/bar', id: 1)
+ project = double(:project, full_path: 'foo/bar', id: 1, import_state: nil)
client = double(:client)
importer = double(:importer)
hash = {
diff --git a/spec/workers/gitlab/github_import/import_issue_worker_spec.rb b/spec/workers/gitlab/github_import/import_issue_worker_spec.rb
index dc0338eccad..29f21c1d184 100644
--- a/spec/workers/gitlab/github_import/import_issue_worker_spec.rb
+++ b/spec/workers/gitlab/github_import/import_issue_worker_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe Gitlab::GithubImport::ImportIssueWorker do
describe '#import' do
it 'imports an issue' do
- project = double(:project, full_path: 'foo/bar', id: 1)
+ project = double(:project, full_path: 'foo/bar', id: 1, import_state: nil)
client = double(:client)
importer = double(:importer)
hash = {
diff --git a/spec/workers/gitlab/github_import/import_note_worker_spec.rb b/spec/workers/gitlab/github_import/import_note_worker_spec.rb
index bc254e6246d..f4598340938 100644
--- a/spec/workers/gitlab/github_import/import_note_worker_spec.rb
+++ b/spec/workers/gitlab/github_import/import_note_worker_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe Gitlab::GithubImport::ImportNoteWorker do
describe '#import' do
it 'imports a note' do
- project = double(:project, full_path: 'foo/bar', id: 1)
+ project = double(:project, full_path: 'foo/bar', id: 1, import_state: nil)
client = double(:client)
importer = double(:importer)
hash = {
diff --git a/spec/workers/gitlab/github_import/import_pull_request_worker_spec.rb b/spec/workers/gitlab/github_import/import_pull_request_worker_spec.rb
index 6fe9741075f..faed2f8f340 100644
--- a/spec/workers/gitlab/github_import/import_pull_request_worker_spec.rb
+++ b/spec/workers/gitlab/github_import/import_pull_request_worker_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe Gitlab::GithubImport::ImportPullRequestWorker do
describe '#import' do
it 'imports a pull request' do
- project = double(:project, full_path: 'foo/bar', id: 1)
+ project = double(:project, full_path: 'foo/bar', id: 1, import_state: nil)
client = double(:client)
importer = double(:importer)
hash = {
diff --git a/spec/workers/merge_requests/update_head_pipeline_worker_spec.rb b/spec/workers/merge_requests/update_head_pipeline_worker_spec.rb
index f3ea14ad539..5e0b07067df 100644
--- a/spec/workers/merge_requests/update_head_pipeline_worker_spec.rb
+++ b/spec/workers/merge_requests/update_head_pipeline_worker_spec.rb
@@ -11,11 +11,9 @@ RSpec.describe MergeRequests::UpdateHeadPipelineWorker do
let(:pipeline) { create(:ci_pipeline, project: project, ref: ref) }
let(:event) { Ci::PipelineCreatedEvent.new(data: { pipeline_id: pipeline.id }) }
- subject { consume_event(event) }
+ subject { consume_event(subscriber: described_class, event: event) }
- def consume_event(event)
- described_class.new.perform(event.class.name, event.data)
- end
+ it_behaves_like 'subscribes to event'
context 'when merge requests already exist for this source branch', :sidekiq_inline do
let(:merge_request_1) do
diff --git a/spec/workers/namespaces/invite_team_email_worker_spec.rb b/spec/workers/namespaces/invite_team_email_worker_spec.rb
deleted file mode 100644
index 47fdff9a8ef..00000000000
--- a/spec/workers/namespaces/invite_team_email_worker_spec.rb
+++ /dev/null
@@ -1,27 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Namespaces::InviteTeamEmailWorker do
- let_it_be(:user) { create(:user) }
- let_it_be(:group) { create(:group) }
-
- it 'sends the email' do
- expect(Namespaces::InviteTeamEmailService).to receive(:send_email).with(user, group).once
- subject.perform(group.id, user.id)
- end
-
- context 'when user id is non-existent' do
- it 'does not send the email' do
- expect(Namespaces::InviteTeamEmailService).not_to receive(:send_email)
- subject.perform(group.id, non_existing_record_id)
- end
- end
-
- context 'when group id is non-existent' do
- it 'does not send the email' do
- expect(Namespaces::InviteTeamEmailService).not_to receive(:send_email)
- subject.perform(non_existing_record_id, user.id)
- end
- end
-end
diff --git a/spec/workers/namespaces/root_statistics_worker_spec.rb b/spec/workers/namespaces/root_statistics_worker_spec.rb
index a97a850bbcf..7b774da0bdc 100644
--- a/spec/workers/namespaces/root_statistics_worker_spec.rb
+++ b/spec/workers/namespaces/root_statistics_worker_spec.rb
@@ -10,7 +10,7 @@ RSpec.describe Namespaces::RootStatisticsWorker, '#perform' do
context 'with a namespace' do
it 'executes refresher service' do
expect_any_instance_of(Namespaces::StatisticsRefresherService)
- .to receive(:execute)
+ .to receive(:execute).and_call_original
worker.perform(group.id)
end
diff --git a/spec/workers/namespaces/update_root_statistics_worker_spec.rb b/spec/workers/namespaces/update_root_statistics_worker_spec.rb
index a525904b757..f2f633a39ca 100644
--- a/spec/workers/namespaces/update_root_statistics_worker_spec.rb
+++ b/spec/workers/namespaces/update_root_statistics_worker_spec.rb
@@ -9,11 +9,9 @@ RSpec.describe Namespaces::UpdateRootStatisticsWorker do
Projects::ProjectDeletedEvent.new(data: { project_id: 1, namespace_id: namespace_id })
end
- subject { consume_event(event) }
+ subject { consume_event(subscriber: described_class, event: event) }
- def consume_event(event)
- described_class.new.perform(event.class.name, event.data)
- end
+ it_behaves_like 'subscribes to event'
it 'enqueues ScheduleAggregationWorker' do
expect(Namespaces::ScheduleAggregationWorker).to receive(:perform_async).with(namespace_id)
diff --git a/spec/workers/packages/cleanup_package_file_worker_spec.rb b/spec/workers/packages/cleanup_package_file_worker_spec.rb
index 33f89826312..380e8916d13 100644
--- a/spec/workers/packages/cleanup_package_file_worker_spec.rb
+++ b/spec/workers/packages/cleanup_package_file_worker_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe Packages::CleanupPackageFileWorker do
- let_it_be(:package) { create(:package) }
+ let_it_be_with_reload(:package) { create(:package) }
let(:worker) { described_class.new }
@@ -23,24 +23,60 @@ RSpec.describe Packages::CleanupPackageFileWorker do
expect(worker).to receive(:log_extra_metadata_on_done).twice
expect { subject }.to change { Packages::PackageFile.count }.by(-1)
- .and not_change { Packages::Package.count }
+ .and not_change { Packages::Package.count }
+ expect { package_file2.reload }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+
+ context 'with a duplicated PyPI package file' do
+ let_it_be_with_reload(:duplicated_package_file) { create(:package_file, package: package) }
+
+ before do
+ package.update!(package_type: :pypi, version: '1.2.3')
+ duplicated_package_file.update_column(:file_name, package_file2.file_name)
+ end
+
+ it 'deletes one of the duplicates' do
+ expect { subject }.to change { Packages::PackageFile.count }.by(-1)
+ .and not_change { Packages::Package.count }
+ expect { package_file2.reload }.to raise_error(ActiveRecord::RecordNotFound)
+ end
end
end
- context 'with an error during the destroy' do
+ context 'with a package file to destroy' do
let_it_be(:package_file) { create(:package_file, :pending_destruction) }
- before do
- expect(worker).to receive(:log_metadata).and_raise('Error!')
+ context 'with an error during the destroy' do
+ before do
+ allow(worker).to receive(:log_metadata).and_raise('Error!')
+ end
+
+ it 'handles the error' do
+ expect { subject }.to change { Packages::PackageFile.error.count }.from(0).to(1)
+ expect(package_file.reload).to be_error
+ end
end
- it 'handles the error' do
- expect { subject }.to change { Packages::PackageFile.error.count }.from(0).to(1)
- expect(package_file.reload).to be_error
+ context 'when trying to destroy a destroyed record' do
+ before do
+ allow_next_found_instance_of(Packages::PackageFile) do |package_file|
+ destroy_method = package_file.method(:destroy!)
+
+ allow(package_file).to receive(:destroy!) do
+ destroy_method.call
+
+ raise 'Error!'
+ end
+ end
+ end
+
+ it 'handles the error' do
+ expect { subject }.to change { Packages::PackageFile.count }.by(-1)
+ end
end
end
- context 'removing the last package file' do
+ describe 'removing the last package file' do
let_it_be(:package_file) { create(:package_file, :pending_destruction, package: package) }
it 'deletes the package file and the package' do
@@ -65,12 +101,12 @@ RSpec.describe Packages::CleanupPackageFileWorker do
end
describe '#remaining_work_count' do
- before(:context) do
- create_list(:package_file, 3, :pending_destruction, package: package)
+ before_all do
+ create_list(:package_file, 2, :pending_destruction, package: package)
end
subject { worker.remaining_work_count }
- it { is_expected.to eq(3) }
+ it { is_expected.to eq(2) }
end
end
diff --git a/spec/workers/project_export_worker_spec.rb b/spec/workers/project_export_worker_spec.rb
index 9923d8bde7f..dd0a921059d 100644
--- a/spec/workers/project_export_worker_spec.rb
+++ b/spec/workers/project_export_worker_spec.rb
@@ -4,4 +4,30 @@ require 'spec_helper'
RSpec.describe ProjectExportWorker do
it_behaves_like 'export worker'
+
+ context 'exporters duration measuring' do
+ let(:user) { create(:user) }
+ let(:project) { create(:project) }
+ let(:worker) { described_class.new }
+
+ subject { worker.perform(user.id, project.id) }
+
+ before do
+ project.add_owner(user)
+ end
+
+ it 'logs exporters execution duration' do
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:version_saver_duration_s, anything)
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:avatar_saver_duration_s, anything)
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:tree_saver_duration_s, anything)
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:uploads_saver_duration_s, anything)
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:repo_saver_duration_s, anything)
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:wiki_repo_saver_duration_s, anything)
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:lfs_saver_duration_s, anything)
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:snippets_repo_saver_duration_s, anything)
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:design_repo_saver_duration_s, anything)
+
+ subject
+ end
+ end
end
diff --git a/spec/workers/projects/post_creation_worker_spec.rb b/spec/workers/projects/post_creation_worker_spec.rb
index 06acf601666..3158ac9fa27 100644
--- a/spec/workers/projects/post_creation_worker_spec.rb
+++ b/spec/workers/projects/post_creation_worker_spec.rb
@@ -63,7 +63,7 @@ RSpec.describe Projects::PostCreationWorker do
end
it 'cleans invalid record and logs warning', :aggregate_failures do
- invalid_integration_record = build(:prometheus_integration, properties: { api_url: nil, manual_configuration: true }.to_json)
+ invalid_integration_record = build(:prometheus_integration, properties: { api_url: nil, manual_configuration: true })
allow(::Integrations::Prometheus).to receive(:new).and_return(invalid_integration_record)
expect(Gitlab::ErrorTracking).to receive(:track_exception).with(an_instance_of(ActiveRecord::RecordInvalid), include(extra: { project_id: a_kind_of(Integer) })).twice
diff --git a/spec/workers/projects/record_target_platforms_worker_spec.rb b/spec/workers/projects/record_target_platforms_worker_spec.rb
new file mode 100644
index 00000000000..eb53e3f8608
--- /dev/null
+++ b/spec/workers/projects/record_target_platforms_worker_spec.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::RecordTargetPlatformsWorker do
+ include ExclusiveLeaseHelpers
+
+ let_it_be(:swift) { create(:programming_language, name: 'Swift') }
+ let_it_be(:objective_c) { create(:programming_language, name: 'Objective-C') }
+ let_it_be(:project) { create(:project, :repository, detected_repository_languages: true) }
+
+ let(:worker) { described_class.new }
+ let(:service_result) { %w(ios osx watchos) }
+ let(:service_double) { instance_double(Projects::RecordTargetPlatformsService, execute: service_result) }
+ let(:lease_key) { "#{described_class.name.underscore}:#{project.id}" }
+ let(:lease_timeout) { described_class::LEASE_TIMEOUT }
+
+ subject(:perform) { worker.perform(project.id) }
+
+ before do
+ stub_exclusive_lease(lease_key, timeout: lease_timeout)
+ end
+
+ shared_examples 'performs detection' do
+ it 'creates and executes a Projects::RecordTargetPlatformService instance for the project', :aggregate_failures do
+ expect(Projects::RecordTargetPlatformsService).to receive(:new).with(project) { service_double }
+ expect(service_double).to receive(:execute)
+
+ perform
+ end
+
+ it 'logs extra metadata on done', :aggregate_failures do
+ expect(Projects::RecordTargetPlatformsService).to receive(:new).with(project) { service_double }
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:target_platforms, service_result)
+
+ perform
+ end
+ end
+
+ shared_examples 'does nothing' do
+ it 'does nothing' do
+ expect(Projects::RecordTargetPlatformsService).not_to receive(:new)
+
+ perform
+ end
+ end
+
+ context 'when project uses Swift programming language' do
+ let!(:repository_language) { create(:repository_language, project: project, programming_language: swift) }
+
+ include_examples 'performs detection'
+ end
+
+ context 'when project uses Objective-C programming language' do
+ let!(:repository_language) { create(:repository_language, project: project, programming_language: objective_c) }
+
+ include_examples 'performs detection'
+ end
+
+ context 'when the project does not contain programming languages for Apple platforms' do
+ it_behaves_like 'does nothing'
+ end
+
+ context 'when project is not found' do
+ it 'does nothing' do
+ expect(Projects::RecordTargetPlatformsService).not_to receive(:new)
+
+ worker.perform(non_existing_record_id)
+ end
+ end
+
+ context 'when exclusive lease cannot be obtained' do
+ before do
+ stub_exclusive_lease_taken(lease_key)
+ end
+
+ it_behaves_like 'does nothing'
+ end
+
+ it 'has the `until_executed` deduplicate strategy' do
+ expect(described_class.get_deduplicate_strategy).to eq(:until_executed)
+ end
+
+ it 'overrides #lease_release? to return false' do
+ expect(worker.send(:lease_release?)).to eq false
+ end
+end
diff --git a/spec/workers/quality/test_data_cleanup_worker_spec.rb b/spec/workers/quality/test_data_cleanup_worker_spec.rb
deleted file mode 100644
index a17e6e0cb1a..00000000000
--- a/spec/workers/quality/test_data_cleanup_worker_spec.rb
+++ /dev/null
@@ -1,44 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Quality::TestDataCleanupWorker do
- subject { described_class.new }
-
- shared_examples 'successful deletion' do
- before do
- allow(Gitlab).to receive(:staging?).and_return(true)
- end
-
- it 'removes test groups' do
- expect { subject.perform }.to change(Group, :count).by(-test_group_count)
- end
- end
-
- describe "#perform" do
- context 'with multiple test groups to remove' do
- let(:test_group_count) { 5 }
- let!(:groups_to_remove) { create_list(:group, test_group_count, :test_group) }
- let!(:group_to_keep) { create(:group, path: 'test-group-fulfillment-keep', created_at: 1.day.ago) }
- let!(:non_test_group) { create(:group) }
- let(:non_test_owner_group) { create(:group, path: 'test-group-fulfillment1234', created_at: 4.days.ago) }
-
- before do
- non_test_owner_group.add_owner(create(:user))
- end
-
- it_behaves_like 'successful deletion'
- end
-
- context 'with paid groups' do
- let(:test_group_count) { 1 }
- let!(:paid_group) { create(:group, :test_group) }
-
- before do
- allow(paid_group).to receive(:paid?).and_return(true)
- end
-
- it_behaves_like 'successful deletion'
- end
- end
-end